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,183 @@
1
+ # An unset hooks path is not an absent hook
2
+
3
+ Status: **accepted** · 2026-10-06
4
+
5
+ Affects: `simt-dev hooks install`, `simt-dev hooks status`, `simt-dev compare`, `src/hooks/where.mjs`, `src/hooks/shim.mjs`
6
+
7
+ ## Context
8
+
9
+ Phase 4 began by piloting `simt-dev init` on `simt-space`. The dry run was
10
+ unremarkable. The defect appeared when I read what `hooks install` would actually
11
+ do to that repository, which is something I had not done in any of the earlier
12
+ phases.
13
+
14
+ `simt-space` has a hand-written `.git/hooks/pre-commit` — 4088 bytes, with prose
15
+ explaining why it deliberately does not run tests. `core.hooksPath` is unset.
16
+ `simt-dev hooks status --root ../simt-space --json` reported:
17
+
18
+ ```json
19
+ { "hooksPath": null, "installed": [{ "event": "pre-commit", "present": false }],
20
+ "problems": ["core.hooksPath is (unset), expected .simt-hooks",
21
+ "pre-commit not installed"] }
22
+ ```
23
+
24
+ and exited with `ok: true`. A live, working gate was reported as not installed.
25
+
26
+ `hooks install` then did this, with no warning of any kind:
27
+
28
+ ```
29
+ core.hooksPath created (unset) -> .simt-hooks
30
+ CREATED .simt-hooks/pre-commit
31
+ CREATED .simt-hooks/pre-push
32
+ WROTE shims and set core.hooksPath
33
+ gates preCommit = (none declared)
34
+ prePush = (none declared)
35
+ ```
36
+
37
+ Three things went wrong at once, and the first one is the load-bearing fact:
38
+
39
+ 1. **An unset `core.hooksPath` was read as "git runs no hooks".** It means the
40
+ opposite: git falls back to its default directory and runs whatever is in it.
41
+ 2. **The takeover guard only looked at `core.hooksPath`.** It existed to stop
42
+ simt-dev silently replacing routing somebody else had chosen — and it checked
43
+ only for a *configured* value. The most common way to have hooks is the one it
44
+ could not see.
45
+ 3. **The install created two shims that enforced nothing.** `simt-dev init` had
46
+ already said, in prose, that this repo declares no gate it can run and there is
47
+ nothing to install. `hooks install` disagreed, and in doing so replaced a
48
+ working gate with a gate that checks nothing.
49
+
50
+ The consequence in a real adoption is worse than a misreport. A repo that adopts
51
+ and then installs hooks gets, in place of its working formatter, two shims that
52
+ fail closed because `@quazarmedia/simt-dev` is not yet a dependency. Every commit
53
+ is blocked. The only exit is `--no-verify` — the exact spiral the dispatcher was
54
+ built in step 8 to prevent.
55
+
56
+ The same class of error sat under the two commands that already existed.
57
+ `src/compare/hooksstate.mjs` was written as "the read half of what `hooks status`
58
+ does inline", but `hooks status` never called it: it had its own `readHooksPath`,
59
+ its own `describeShim` and its own `collectProblems`. Three copies of the same
60
+ question, which is why fixing the shared one changed nothing visible.
61
+
62
+ ## Ruling
63
+
64
+ 1. **Ask git where hooks are, never infer it from whether config exists.**
65
+ `resolveHooksDir(root)` implements the two rules directly: `core.hooksPath`
66
+ when set (a relative value resolves against the top of the working tree, which
67
+ is what makes `simt-app`'s `hooks` work), otherwise `<git-common-dir>/hooks`.
68
+
69
+ `git rev-parse --git-path hooks` is the obvious shortcut and is unusable. It
70
+ returns `.git/hooks` in a normal repository, `hooks` in a linked worktree, and
71
+ **`./`** in a linked worktree with `core.hooksPath` unset — because a worktree's
72
+ `.git` is a file, not a directory. An implementation that trusted it would
73
+ enumerate a worktree's entire root as its hooks directory and report every file
74
+ in the repository as a hook.
75
+
76
+ 2. **A hook git runs and simt-dev did not write is a first-class fact.** It is
77
+ named in `hooks status` text and JSON, in `hooks install`'s refusal and its
78
+ post-install notes, and as a row-level `foreignHooks` field. Identity is decided
79
+ by content, never by name: a simt-dev shim is also called `pre-commit`, so only
80
+ the bytes can tell ours from somebody else's.
81
+
82
+ 3. **Taking over a foreign hook requires `--yes`, and the refusal names the hook.**
83
+ The existing guard was right about the principle and incomplete about the facts.
84
+ It now covers both a configured path and an unconfigured default directory.
85
+
86
+ 4. **A shim is installed only for an event that declares at least one gate, and
87
+ `install` refuses when no event does.** An empty-gate shim enforces nothing but
88
+ still displaces whatever was there, so installing one over a working hook is a
89
+ downgrade. `init` and `install` must not describe different tools.
90
+
91
+ 5. **In `compare`, a repo running its own hooks is a note, not a problem.** Notes
92
+ do not affect the exit code. "Not adopted yet" is the normal state of a
93
+ migration, and step 9 established — deliberately, after finding that a dashboard
94
+ which goes red for the normal state is a dashboard everybody ignores — that only
95
+ defects drive the exit code. Six of the sixteen repositories run their own hooks,
96
+ so putting this in `problems` would have made the entire estate red on day one.
97
+
98
+ 6. **A missing shim is only a defect when a gate needs it.** A repository that
99
+ declares no `preCommit` gates has no reason to want a `pre-commit` shim. A
100
+ repository with *no manifest* is a different case: we do not know what it wants,
101
+ so we cannot rule the shim out. Absence of knowledge is not a decision.
102
+
103
+ 7. **A foreign hook for an event this tool does not gate is chained, not stopped.**
104
+ `core.hooksPath` does not scope this tool to the events it cares about and leave
105
+ the rest to git; git reads *every* hook from the named directory and from nowhere
106
+ else. So the moment routing lands, `simt-app`'s `post-checkout`, `post-commit`
107
+ and `post-merge` — events this tool gates nothing about — stop running. Nothing
108
+ fails, nothing is logged, and the hook is still on disk looking intact: the
109
+ failure is indistinguishable from the hook having passed. Ruling 3's refusal
110
+ names the hook, but a refusal the operator accepts is not a chain.
111
+
112
+ `hooks install` therefore writes a second kind of shim for those events, which
113
+ runs the repository's own hook and forwards git's arguments to it. There is
114
+ nothing to enforce on an event this tool does not gate, so it does not run the
115
+ dispatcher at all. Git's arguments *are* forwarded here, unlike in the dispatcher
116
+ shim, because the program on the other end is the repository's hook and expects
117
+ them.
118
+
119
+ The target is written relative to the repository's top level wherever the hook
120
+ lives inside it, so a clone or a move does not silently stop resolving it. If the
121
+ hook is not there at all, the shim refuses and names the path — a hook that
122
+ silently stops running is the exact outcome this ruling exists to prevent.
123
+
124
+ A gate-type foreign hook is deliberately **not** chained — *when this repository
125
+ declares a gate for that event*. The dispatcher replaces the hook there by design, the
126
+ hook's real place is `gates.commands`, and its verdict belongs in the gate report next
127
+ to the others. Chaining it would run it *after* the gates had already passed, outside
128
+ anything the author sees.
129
+
130
+ "Does this tool gate it" is a fact about the manifest, not about the event vocabulary,
131
+ and reading it the second way cost `simt-app` its `pre-push`. That hook is git-lfs.
132
+ `pre-push` is an event this tool gates *in general*, so a vocabulary check displaced it
133
+ — and because `simt-app` declares no `pre-push` gate there is no shim for that event
134
+ either, so git would have found no `pre-push` hook at all. LFS objects would have
135
+ stopped being uploaded, with nothing to notice it: the same silent failure this ruling
136
+ exists to prevent, reached by fixing the wrong half of it. The test asks the manifest.
137
+
138
+ The install note says which of the two happened, because "stopped running" was wrong
139
+ about a carried hook and would have pushed an operator to restore a hook that was
140
+ already running for them.
141
+
142
+ ## Consequences
143
+
144
+ `src/hooks/where.mjs` is new and is the single answer to "where are this repo's
145
+ hooks". `hooksstate.mjs` and `hooks status` no longer hold separate
146
+ implementations — the three duplicated functions in `hooks.mjs` are gone, and
147
+ `hooksstatus` reads through the shared module.
148
+
149
+ Two guard tests are recorded in `test/compare.test.mjs` because the JSON surface
150
+ had already lost a field once: the `summary` block in `compare --json` was a
151
+ hand-listed copy of `summarise()`'s output, and `withOwnHooks` reached the text
152
+ render while silently vanishing from the machine surface. A copied list of names is
153
+ a list that rots, so one test now asserts the two agree and another asserts every
154
+ row carries `notes` and `foreignHooks` even when empty.
155
+
156
+ Measured across the estate after the fix, six repositories run hooks simt-dev did
157
+ not write — `simt-space` (4088 B), `simt-app` (four hooks), `simt-social` (two),
158
+ `simt-social-api` (two), `simt-landing` (one), and the `.wt-wear-redesign`
159
+ worktree (five, including the `git-lfs` `pre-push`). Every one of them would have
160
+ been silently taken over.
161
+
162
+ ## Rejected
163
+
164
+ - **Treat an unset `core.hooksPath` as "no hooks", and document it.** This is the
165
+ bug. The documentation would have been accurate about the code and wrong about
166
+ git.
167
+ - **Ask the user before overwriting, and leave `hooks status` as it was.** The
168
+ question was never asked, because the tool did not know a hook existed. A guard
169
+ that cannot see the thing it guards is decoration.
170
+ - **Move a foreign hook aside instead of blocking.** Blocking is right; the file is
171
+ left on disk untouched either way, so the outcome already is "move it aside".
172
+ - **Chain every foreign hook, gate-type ones included.** Rejected when ruling 7 was
173
+ written, and the reason is the difference between a verdict and a side effect. A
174
+ `pre-commit` hook chained behind the dispatcher runs *after* the gates have already
175
+ passed, so its failure is reported by git as a hook failure with no gate output
176
+ above it — an author cannot tell which of the two things said no. The hook's real
177
+ place is `gates.commands`, where it runs as a gate and appears in the report next
178
+ to the others.
179
+ - **Install the shims anyway and warn.** The gate list was empty. Warning about a
180
+ replacement that enforces less than what it replaces is the wrong direction.
181
+ - **Put foreign hooks in `compare`'s `problems`.** Six of sixteen repositories, all
182
+ of them unadopted, on the day the dashboard is introduced.
183
+ - **Use `git rev-parse --git-path hooks`.** Returns `./` for a linked worktree.
@@ -0,0 +1,110 @@
1
+ # A declared command is the runner; the remedy must be one the reader can take
2
+
3
+ Status: **accepted** · 2026-10-07
4
+
5
+ Affects: `simt-dev init`, `simt-dev check`, `simt-dev hooks status`, `simt-dev hooks run`,
6
+ `src/hooks/capability.mjs`, `src/hooks/resolve.mjs`, `src/projection/manifest.mjs`
7
+
8
+ ## Context
9
+
10
+ ADR-017 made `simt-dev init` declare only gates a repository can actually run, and it shipped
11
+ green because the first adopter — `simt-social-media` — is a Node repository whose scripts
12
+ and binaries are exactly what the filter understood. It was verified by running that
13
+ repository's own tests.
14
+
15
+ `simt-app`, the third pilot, is a Gradle repository. It has **no `package.json` at all**. The
16
+ dry run dropped all five proposed gates, and the remedy for each one was an instruction the
17
+ reader cannot follow. The output as it stood, before this record was written:
18
+
19
+ ```
20
+ $ simt-dev init --root ../simt-app
21
+ dropped gate test: no "test" script in package.json
22
+ remedy: add a "test" script to package.json, then re-run: simt-dev init --yes
23
+ ```
24
+
25
+ Ruling 4 changes both lines. `simt-app` will never have a `package.json`. "Install prettier as
26
+ a dev dependency" is equally impossible. The filter was right about the facts and wrong about
27
+ what followed from them, and the reader's only honest reaction to that remedy is to stop
28
+ trusting the next one.
29
+
30
+ Then the repo declared what it does have, using the escape hatch the remedy for command gates
31
+ had always advertised:
32
+
33
+ ```json
34
+ "gates": { "preCommit": ["i18n-completeness"], "prePush": ["test"],
35
+ "commands": { "i18n-completeness": "scripts/check_string_translations.sh",
36
+ "test": "./gradlew check" } }
37
+ ```
38
+
39
+ and three surfaces answered three different things about it:
40
+
41
+ - `simt-dev hooks run pre-commit` — **PASSED**, running the declared script.
42
+ - `simt-dev hooks run pre-push` — would have demanded an npm script that does not exist,
43
+ because `resolveGate` only consulted `gates.commands` for gates of kind `command`.
44
+ - `simt-dev check` — **UNRUNNABLE** on both, because `readCapability` never read
45
+ `simt-dev.json` at all and so always reported an empty command map.
46
+
47
+ One manifest. Three answers. The one I would have shipped if I had only run `check`.
48
+
49
+ ## Rulings
50
+
51
+ 1. **A gate name is a requirement, not a mechanism.** The repository declares "prove `test`
52
+ somehow"; `gates.commands[test]` is how it says how. Once declared, that wins for **every**
53
+ gate kind — `script`, `staged` and `command` alike — because a repository naming its own
54
+ program is a more specific statement than any table this tool can ship.
55
+ 2. **`gates.commands` is part of what a repository can run.** `readCapability` reads
56
+ `simt-dev.json` alongside `package.json` and `node_modules/.bin`. This is the one
57
+ capability source that is a declaration rather than an observation, and that asymmetry is
58
+ the point: the only evidence that can prove a command gate is the declaration.
59
+ 3. **The dispatcher, `check` and `hooks status` read one manifest once and answer identically.**
60
+ Two surfaces must never disagree about what a gate will do; the disagreement is what made
61
+ this a defect rather than a rough edge.
62
+ 4. **A remedy names an action the reader can take.** Where npm is the runner, the npm remedy
63
+ stands. Where it demonstrably is not, the remedy is to name the program under
64
+ `gates.commands` — the same instruction already given for command gates.
65
+ 5. **An empty declared command is a failure, not a silent pass.** It would resolve to an empty
66
+ argv and report `PASSED` while proving nothing, which is the worst outcome a gate can have.
67
+ 6. **`'none'` is an answer; absent is not.** A capability that does not report its runner (one
68
+ built by hand, or by an older caller) is treated as npm. Claiming a repository has no
69
+ `package.json` on the strength of a missing field is a much worse guess than assuming it.
70
+ 7. **Runner detection reads files, and must know the difference.** The marker for Gradle is
71
+ the `gradlew` file. My first cut used `readdir` to test existence, which answers "no" for
72
+ every file (`ENOTDIR`) and classified every Gradle repository in the estate as having no
73
+ runner at all — a wrong answer from an evidence reader, which is worse than no answer
74
+ because the caller cannot tell them apart.
75
+
76
+ ## Consequences
77
+
78
+ - `simt-app` declares `i18n-completeness: scripts/check_string_translations.sh` on
79
+ pre-commit — the gate its own hand-written pre-commit hook already runs — and
80
+ `test: ./gradlew check` on pre-push.
81
+ - The `simt-social-media` and `simt-space` manifests are unaffected: they declare no commands.
82
+ - A Node repository that wants to override a gate now can, without abandoning the gate name it
83
+ shares with every other repository. `gates.commands.format` is a supported thing to write.
84
+ - `readCapability` reads one more file per invocation, and it reads it in a `try` that cannot
85
+ throw: a repository with no manifest simply has no declared commands.
86
+
87
+ ## Rejected
88
+
89
+ - **Add a `gradle` gate kind, and a `python` one, and a `cargo` one.** Each would need its own
90
+ dispatcher branch, its own remedy, its own tests, and its own entry in `GATES`. One rule —
91
+ a declared command wins — covers every ecosystem including the next one, which is the reason
92
+ the `command` kind exists at all.
93
+ - **Let the reader name the gate.** A repository could declare a gate called `gradle-check`
94
+ rather than `test`. Then `test` is dropped for lack of a script while a second, unrelated
95
+ gate appears, and the two estates of gate names stop being comparable.
96
+ - **Guess the Gradle task.** Printing `./gradlew test` as the remedy for a Gradle repository
97
+ would be shorter and would be wrong in exactly the repositories where it is printed — plenty
98
+ of Gradle projects have no `test` task and a `spotlessCheck` that is not `spotlessFormat`.
99
+ - **Treat an unreadable marker file as a runner.** A permission error is not evidence that a
100
+ repository runs Gradle; it is evidence that this tool cannot see. Absent is the safe reading.
101
+ - **Leave `check` reporting the gate unrunnable and let `hooks run` be the truth.** Then the
102
+ gate a reader is told to fix is not the gate that fails, and the two disagree on the same
103
+ manifest — this defect, with the direction reversed.
104
+
105
+ ## Evidence
106
+
107
+ `simt-app` is the first adopter with a non-npm runner and the first to declare a command for a
108
+ gate that is not of kind `command`. Every earlier adopter was a Node repository, which is why
109
+ ADR-017's own verification passed. The estate's three runners — npm, Gradle and Python — were
110
+ all in the repository set the whole time; only the pilot order had not reached them.
@@ -0,0 +1,100 @@
1
+ # A rule is written per surface, and pointed at — never copied
2
+
3
+ Status: **accepted** · 2026-10-07
4
+
5
+ Affects: `simt-dev init`, `simt-dev sync`, `simt-dev check`, `simt-dev compare`,
6
+ `src/projection/surfaces.mjs`, `src/projection/project.mjs`, `src/compare/row.mjs`
7
+
8
+ ## Context
9
+
10
+ `simt-dev` writes rules into a repository. Until this record it wrote them into **two** places:
11
+ the `AGENTS.md` generated region, and `.cursor/rules/*.mdc`. So the estate's three
12
+ always-on process rules — `test-gate`, `intake-questions`, `preview-postflight` — reached
13
+ **Cursor users and nobody else**. Every other editor got whatever happened to be in
14
+ `AGENTS.md`, by the accident that those editors read it.
15
+
16
+ That accident is not support. Claude Code reads `CLAUDE.md` by default and only falls back to
17
+ `AGENTS.md` when no `CLAUDE.md` exists anywhere above the working directory. Copilot's editor
18
+ and code-review surfaces read `.github/copilot-instructions.md` and
19
+ `.github/instructions/*.instructions.md`, which this tool wrote nowhere — while its conflict
20
+ scanner already *scanned* `.github/instructions/`, because a detector that reads a surface is
21
+ half a promise to write it. Antigravity's native surface is `.agents/rules/*.md` with a
22
+ required `trigger:` key it will silently discard if it does not recognise. So the three
23
+ repositories in the pilot had three different answers to "what rules apply here", and only
24
+ one of the three was written down.
25
+
26
+ The conventions were not guessed. Each was read from that vendor's own documentation, and
27
+ `src/projection/surfaces.mjs` records the document beside every row.
28
+
29
+ ## Rulings
30
+
31
+ 1. **A surface is named data.** Every editor is a row: its id, its label, the readers it
32
+ serves, its rule-file path template, its frontmatter, and **the document it was verified
33
+ against**. A row that cannot name that document does not belong in the table.
34
+
35
+ 2. **A pointer is not a copy.** `CLAUDE.md` and `GEMINI.md` are symlinks to `AGENTS.md`.
36
+ Both vendors document the symlink. The estate contains a byte-identical `CLAUDE.md` and
37
+ `AGENTS.md` pair, which is the one true content duplicate in the corpus and the reason this
38
+ tool exists. DeepSeek's harness documents the failure from the other side: candidates that
39
+ are byte-identical after trimming collapse, so a symlink renders once — while **a real copy
40
+ that has drifted loads in full alongside** `AGENTS.md`. A copy is not a second copy of the
41
+ rule; it is a second rule that nobody reviews.
42
+
43
+ 3. **Per-rule files are generated, not linked.** `rulePath()` writes the same body to each
44
+ surface under that surface's own naming. Symlink support is documented for
45
+ `.claude/rules/`; writing a symlink into `.agents/rules/` or `.github/instructions/` would
46
+ be an unverified guess carrying a mode bit. A machine-written file derived from one
47
+ canonical source and verified by `simt-dev check` is a build output, not duplication —
48
+ nobody can hand-edit one and survive a sync, which is the test the estate's own review
49
+ agent applies to duplicated policy text.
50
+
51
+ 4. **An always-on rule emits no scoping frontmatter.** Claude documents that a rule file with
52
+ no `paths:` loads eagerly at session start, and carries a documented parser defect: quoted
53
+ values and YAML sequences in `paths:` silently fail to lazy-load, and its own documented
54
+ examples are not valid YAML. Emitting a scope we do not need, on an editor where a
55
+ malformed scope fails silently, buys nothing.
56
+
57
+ 5. **The default is the global standard, and the set is always reported.** Every rule surface
58
+ unless the repository narrows it with `"surfaces"` in `simt-dev.json`; `--surfaces` wins
59
+ over the manifest. An empty list is a real choice meaning `AGENTS.md` only, not an
60
+ accident. Every command prints the set and **where it came from** — an invisible
61
+ configuration is one nobody can debug, which is the same ruling ADR-014 made about the
62
+ workspace.
63
+
64
+ 6. **A surface row states its own failure mode.** An unrecognised frontmatter key is inert;
65
+ an unrecognised *value* can make an editor discard the rule with no error. So each row
66
+ carries `documentedKeys`, and `undocumentedKeys()` — used by the test suite and by any
67
+ consumer that wants the same guarantee — fails when the table emits a key its own vendor
68
+ does not document.
69
+
70
+ ## Consequences
71
+
72
+ `simt-dev init`, `sync` and `check` write and verify up to four rule files per rule plus two
73
+ pointers. `compare` reports `rules N/M current` per repository, because a repository with a
74
+ current `AGENTS.md` and three stale rule files is now a state that can exist, and reporting
75
+ only the `AGENTS.md` finding would hide it.
76
+
77
+ A missing pointer is **not** a failing check. A repository that has never been adopted has no
78
+ `CLAUDE.md` and no `simt-dev.json`, and reddening every commit for that is the adoption
79
+ problem, not a defect — the same reasoning ADR-018 applied to a repository's own hooks. A
80
+ *mispointed* pointer is likewise not failing; a pointer simt-dev did not write, which means a
81
+ human's `CLAUDE.md` is in the way, **is** reported, because overwriting it silently would
82
+ destroy prose nobody backed up.
83
+
84
+ ## Rejected
85
+
86
+ - **Keep two surfaces and call it support.** The rules a reader most needs are the ones that
87
+ never reached three of the four editors that read this repository's rules.
88
+ - **Copy `AGENTS.md` to `CLAUDE.md`.** The one true duplicate in the corpus. DeepSeek's
89
+ harness loads a drifted copy in full; Claude's own docs steer away from it.
90
+ - **Symlink every rule file to a canonical directory.** Symlink support is documented only for
91
+ `.claude/rules/`. A symlink into `.agents/rules/` or `.github/instructions/` would be a
92
+ guess with a mode bit attached.
93
+ - **Emit `paths:`/`globs:` on an always-on rule** and rely on it. Claude's parser defect makes
94
+ it a silent-failure surface for zero benefit; Antigravity discards a rule whose `trigger`
95
+ it does not recognise.
96
+ - **Guessing Antigravity's or DeepSeek's convention and moving on.** Both were read from the
97
+ vendors' documentation before anything was written, and the document is recorded per row so
98
+ a future maintainer knows what a claim rests on.
99
+ - **Making the default a single surface** because it is the one most repositories use. A
100
+ default that leaves three editors uninstructed is the defect this record exists to close.
@@ -0,0 +1,94 @@
1
+ # ADR-021 — One surface per repo, chosen or defaulted
2
+
3
+ Status: accepted · 2026-10-07 · ruling 3 superseded by ADR-046
4
+ **Affects:** `simt-dev init`, `sync`, `check`, `compare`, `skills install`, `src/projection/surfaces.mjs`, `src/projection/rules.mjs`
5
+
6
+ > **Ruling 3 superseded by ADR-046.** It rested on a factual claim about Cursor — that a Cursor
7
+ > reader getting `.mdc` files "does not serve them", i.e. that Cursor cannot read `AGENTS.md` — and
8
+ > Cursor's own documentation says it can. Five repositories were loading 28,101 bytes of rule text
9
+ > twice. The claim had been sitting on the wrong row of simt-dev's own surface table all along.
10
+ > Every other ruling here stands; ruling 6 in particular is what makes ADR-046's orphan handling a
11
+ > note rather than a deletion.
12
+
13
+ ## Context
14
+
15
+ ADR-020 answered a question that had been answered wrongly for months. `simt-dev init`
16
+ wrote `AGENTS.md` and `.cursor/rules/*.mdc` and reported that it supported the
17
+ estate's editors. It did not: the three always-on process rules lived only in a
18
+ directory only Cursor reads, so a Claude Code, Copilot or Antigravity user received
19
+ the core and stack rules and none of the process rules.
20
+
21
+ ADR-020 fixed that by writing one file per verified editor — four rule directories
22
+ and two pointer symlinks. That was correct and it was too much. A repo whose team
23
+ lives in one editor now acquires three rule directories and two symlinks it will
24
+ never open, every rule change lands four times, and `check` must be told which
25
+ surface to ask about or it answers a different question from the one `sync` was
26
+ asked.
27
+
28
+ "Support every known editor" is satisfied by knowing how to write each one and
29
+ writing the one that was asked for. It is not satisfied by writing them all.
30
+
31
+ ## Rulings
32
+
33
+ 1. **One surface per repo by default; a repo may name another.** `--surfaces` takes a
34
+ comma-separated list, so one, several, or none are all reachable. An empty list is
35
+ a real choice and means the primary region and nothing else.
36
+ 2. **The default is opencode**, because it is the agent this tool is developed
37
+ against and its paths — `AGENTS.md` and `.agents/skills/` — are already this
38
+ workspace's. It is also the least private choice available: seven of the eight
39
+ readers on that row read `AGENTS.md` natively, so the default file is the one most
40
+ editors already read even when they were not asked for it.
41
+ 3. **A folding surface carries its rules inside the region.** opencode documents no
42
+ per-rule directory, so its surface folds every rule into the one generated region.
43
+ Folding is `some`, not `all`: a repo that also opts into Cursor still folds, because
44
+ an opencode reader's only channel is `AGENTS.md` and a Cursor reader getting `.mdc`
45
+ files does not serve them.
46
+ 4. **Skills follow the same rule.** One canonical skills directory per repo, `.agents/skills`,
47
+ which opencode, Claude Code and Antigravity all document and which the estate
48
+ already uses. Not four copies of every skill.
49
+ 5. **The choice is always visible.** Every command prints the surface set and its
50
+ origin — flag, manifest, or global standard — because an invisible configuration is
51
+ one nobody can debug (ADR-014).
52
+ 6. **A written file is not removed when a surface is dropped.** Narrowing the surface
53
+ set stops new files appearing; `sync` never deletes a file it once generated,
54
+ because a deletion is not something a projection should do silently. Removing them
55
+ is a human decision with a visible diff.
56
+ 7. **A pointer belongs to the surface that needs it.** ADR-020 wrote `CLAUDE.md` and
57
+ `GEMINI.md` unconditionally, so narrowing the surface set left both behind — the
58
+ same over-writing ruling 1 exists to stop, one file down. Each pointer now names the
59
+ surface that requires it (`claude-md` → `claude`, `gemini-md` → `antigravity`) and
60
+ is planned only when that surface was selected. On the default surface simt-dev
61
+ writes exactly one file.
62
+
63
+ *Added after ADR-021 was first implemented: this was found by running the command
64
+ against the four adopted repos rather than by reading it, which is the third time
65
+ that has been the only way to find anything.*
66
+
67
+ ## Consequences
68
+
69
+ - **Which surface a repo already has is the strongest signal of which it wants.**
70
+ Measured across the four pilot repos: three track `.cursor/rules/*.mdc` (3, 3 and 12
71
+ files) and one tracks no rules layer at all. Those three adopt `opencode,cursor`
72
+ because they already use Cursor; the fourth takes the bare default. A repo's own
73
+ tracked files answer this better than any default could.
74
+
75
+ - `simt-dev init` on a fresh repo writes one file: `AGENTS.md`.
76
+ - `check` and `sync` must be given the same surfaces or they ask different questions.
77
+ The manifest records the choice, which is why `init` and `upgrade` write it.
78
+ - The four repos adopted under ADR-020 carry rule files for surfaces they did not ask
79
+ for. Narrowing stops the next sync writing them; removing the existing ones is
80
+ Phase 6 work with a visible diff.
81
+
82
+ ## Rejected
83
+
84
+ - **Write every verified surface by default.** Rejected: gives a single-editor repo
85
+ three unread directories, and makes every rule change a four-file change.
86
+ - **Make the default no surface at all.** Rejected: that is what the tool did before
87
+ ADR-020 and it is how three editors received no process rules.
88
+ - **Delete generated files for a dropped surface automatically.** Rejected: a
89
+ projection that deletes files is a projection that can lose work, and it does it
90
+ without a diff anyone reads.
91
+ - **One surface per rule, chosen by rule.** Rejected: the same rule then exists in two
92
+ places, which is the estate's original problem restated.
93
+ - **Ask on every run.** Rejected: a prompt is a hang for an agent, and the default
94
+ exists precisely so nothing has to be asked.
@@ -0,0 +1,98 @@
1
+ # ADR-022 — A key simt-dev reads is prefixed `simt-`
2
+
3
+ Status: accepted · 2026-10-08
4
+ **Affects:** `rules/*.md`, `rules/mdc/*.mdc`, `src/projection/mdc.mjs`, `src/projection/rules.mjs`, `test/shipped.test.mjs`. Supersedes the *spelling* of the keys named in ADR-013 and ADR-016; their rulings stand.
5
+
6
+ ## Context
7
+
8
+ A canonical `.mdc` frontmatter block carries two vocabularies in one place:
9
+
10
+ ```
11
+ id: test-gate
12
+ target: .cursor/rules/test-gate.mdc
13
+ description: ...
14
+ alwaysApply: true
15
+ applies: always
16
+ ```
17
+
18
+ `description` and `alwaysApply` are Cursor's, cited in the `cursor` row of
19
+ `src/projection/surfaces.mjs` and re-emitted per surface. `id`, `target`, `applies`,
20
+ `optIn` and `substitutions` are simt-dev's own and appear in no vendor's
21
+ documentation. In the file above, nothing says which is which: `alwaysApply: true` and
22
+ `applies: always` sit four lines apart, both read as "always", and both are consumed by
23
+ something. The question was asked of this repository and could only be answered by
24
+ reading the source rather than the file.
25
+
26
+ That is a legibility failure with teeth behind it. ADR-016 records finding S11: a rule
27
+ shipped, was tested for its presence on disk, and reached no repository, because
28
+ `applies` was absent from its frontmatter. A missing simt-owned key is silent. A bare
29
+ key that looks like the key beside it is one typo away from the same silence, and this
30
+ block is precisely where a reader editing one key is looking at the other.
31
+
32
+ The prefix does not exist to make the files tidy. It exists so that a bare key is
33
+ either an editor's key or a defect, and never a third thing.
34
+
35
+ ## Rulings
36
+
37
+ 1. **Every key simt-dev reads out of a canonical rule file carries `simt-`.**
38
+ `simt-id`, `simt-target`, `simt-title`, `simt-applies`, `simt-opt-in`,
39
+ `simt-substitutions`, `simt-fallback`.
40
+
41
+ | was | is |
42
+ |---|---|
43
+ | `id`, `target`, `title` (`.mdc`) | `simt-id`, `simt-target`, `simt-title` |
44
+ | `applies`, `optIn`, `substitutions` (`.mdc`) | `simt-applies`, `simt-opt-in`, `simt-substitutions` |
45
+ | `id`, `title`, `applies`, `fallback` (`rules/*.md`, `.simt-dev/profiles/`) | `simt-id`, `simt-title`, `simt-applies`, `simt-fallback` |
46
+
47
+ 2. **An editor's key keeps the vendor's spelling, unprefixed.** `description` and
48
+ `alwaysApply` are the only two keys in the estate with two owners: simt-dev reads
49
+ them and re-emits them mapped per surface (`alwaysApply` becomes Antigravity's
50
+ `trigger: always_on`). Prefixing them would be a lie in both directions — Cursor
51
+ would ignore `simt-alwaysApply`, and the projector would have to strip the prefix
52
+ before emitting. A key is prefixed when this tool is its only reader.
53
+
54
+ 3. **An unrecognised key is a defect, not an ignored line.** `applies:` in a file that
55
+ should say `simt-applies:` reports as a problem naming the prefixed spelling.
56
+ This is the ruling that turns the prefix from a convention into an invariant: the
57
+ failure mode ADR-016 hit is now loud, and loud before the rule reaches nobody.
58
+
59
+ 4. **The prefix binds settings read from files, not identifiers in code.** `rule.applies`
60
+ is still `rule.applies` in `src/`. Renaming the property would satisfy the sentence
61
+ and change nothing a reader of a rule file sees, which is the only reader the prefix
62
+ is for.
63
+
64
+ 5. **Inside `simt-substitutions:` the keys stay `key`, `default` and `describe`.** The
65
+ parent already namespaces them; `simt-key` reads as though the block were not there.
66
+
67
+ 6. **Out of scope, deliberately: `simt-dev.json` and `graph/skills.graph.json`.** Both
68
+ are namespaced by filename, neither contains a key any editor or agent reads, and
69
+ both are read by this tool's own commands rather than by a person opening a rule
70
+ file. The receiving repo's manifest is the one file a human edits by hand — to
71
+ declare a gate command or a substitution — and `simt-gates` there is noise in the
72
+ place noise costs the most.
73
+
74
+ ## Consequences
75
+
76
+ - **No receiving repository is affected.** These keys are stripped before projection:
77
+ `.md` frontmatter is dropped by `stripFrontmatter`, and a projected `.mdc` gets
78
+ frontmatter rebuilt from the surface row (`description` + `alwaysApply`) or preserves
79
+ whatever an adopted file already had, verbatim and unparsed. The change is
80
+ confined to the files in this repository, which is why touching shipped rule text is
81
+ cheap here and expensive in `rules/core.md`'s prose.
82
+ - A rule file that still says `applies:` now fails `check` with the prefixed spelling in
83
+ the message. That is the intended break: it fails at the file, loudly, on first run.
84
+ - ADR-013 and ADR-016 name the old spellings in their examples. They are left verbatim
85
+ — they are the record of what was ruled — and this record is the lookup from the old
86
+ spelling to the new one.
87
+ - `rules list` keeps an `applies` column. It labels a value, not a key, and the value
88
+ vocabulary (`always`, a tag list) is unchanged.
89
+
90
+ ## Rejected
91
+
92
+ - **Prefix the manifest keys too.** Rejected: `simt-dev.json` is already namespaced by
93
+ filename, and the keys a person edits by hand are the ones a prefix would obscure.
94
+ - **Prefix in code as well, for symmetry.** Rejected under ruling 4: it is a rename of
95
+ variables that satisfies the wording and defeats the purpose.
96
+ - **Accept both spellings for one release.** Rejected: a key that may be written two
97
+ ways is exactly the ambiguity this record exists to remove, and the failure it hides
98
+ is the unreachable rule ADR-016 already shipped once.