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,119 @@
1
+ # ADR-045 — A dashboard that counts a defect and will not name it is worse than one that does not count it
2
+
3
+ Status: **accepted** · 2026-10-08 · amends ADR-016 · affects what `compare` reports and exits on
4
+
5
+ ## Context
6
+
7
+ Review 0002 §S25, marked P1, reported two defects in one row. Both measured against the estate
8
+ before anything was changed, and both confirmed exactly as written.
9
+
10
+ **One.** `src/compare/row.mjs` gated the stale-rule-file problem on `row.adoption === 'adopted'`.
11
+ `adoption` was initialised at line 78 and only assigned at lines 127–131, so the gate ran before
12
+ anything settled it and compared against the initialiser's `'unmanaged'`. It could never fire.
13
+
14
+ Measured over the 16 repositories at `~/code/simt`:
15
+
16
+ ```
17
+ staleRuleFiles: 17
18
+ repos naming stale rule files in problems: 0
19
+ simt-app problems:
20
+ "the generated region was hand-edited; restore it with: simt-dev sync --yes"
21
+ "core.hooksPath is \"hooks\", not the shared dispatcher; run: simt-dev hooks install --yes"
22
+ ```
23
+
24
+ Five adopted repositories have stale rule files — `simt-app`, `simt-blog` and `simt-social` at 0
25
+ of 3 current, `simt-social-api` and `simt-social-media` at 0 of 4 — and not one of them said so in
26
+ its problems list. The summary already said `staleRuleFiles: 17`, because `summarise` has always
27
+ counted them. So the dashboard was reporting a count of a defect it refused to call a defect.
28
+
29
+ **Two.** `profiles` was documented at `row.mjs:33` as "profile ids that would be selected",
30
+ initialised to `[]` at line 83, and never assigned anywhere in the file's life. Every row of every
31
+ run reported no profiles. `plan()` — the thing `sync` renders — returns them.
32
+
33
+ ## Ruling
34
+
35
+ **One.** A row's problems list names every stale rule file, and the exit code follows. The gate
36
+ reads `adoption` after it is settled, which means the ordering is part of the contract: **a gate
37
+ that reads a field the line above has not set yet is not a gate.** That sentence is the whole fix.
38
+
39
+ **Two.** The gate stays. A repo that has never been adopted has its own files, and reporting them
40
+ as "not current" turns the normal mid-migration state into a red estate — ADR-018's reasoning for
41
+ hooks, applied here to rule files. There is a test for the negative half specifically, because
42
+ fixing the gate is exactly the kind of change that turns this on.
43
+
44
+ **Three.** `profiles` is filled from the same `select()` call, on the same inputs, that
45
+ `desiredRegion` makes. Not from a copy of the rule table and not from the manifest's declared
46
+ stack: a dashboard that disagrees with the tool about what it would write is worse than one that
47
+ says nothing, because it is believed.
48
+
49
+ **Four.** A field documented as an answer must be either answered or absent. `profiles` was neither:
50
+ it was present, empty, and described as populated in the type it was documented with.
51
+
52
+ ## Consequences
53
+
54
+ The estate now names all 17: five repositories carry the problem line, and `compare` exits non-zero
55
+ on them. Nothing else about the detection changed — the count was 17 before the fix and is 17 after
56
+ it. This slice changed reporting, not measurement, and the test asserts that by requiring the
57
+ fixture to start current before the edit.
58
+
59
+ ### The ruling this amends
60
+
61
+ ADR-016 ruled that "`compare` continues to report one row per repo using `AGENTS.md` only, because a
62
+ dashboard row cannot represent four files. The gate uses the full finding list, so the dashboard's
63
+ coarser view is not inherited by the thing that must not be wrong."
64
+
65
+ That ruling is about the row's **shape** — which files it displays — and it still holds. The row
66
+ already carried `ruleFiles.total`, `ruleFiles.current` and `ruleFiles.stale` before this change, so
67
+ ADR-016's coarser display was never the problem.
68
+
69
+ What was missing is that ADR-016 reasoned about the *display* and the *problems list* together, and
70
+ they came apart. The dashboard was allowed to be coarse about which files it shows, and it was also
71
+ allowed to be silent about whether an agent reading them would get correct rules. Those are
72
+ different permissions, and only the first was granted on purpose. The problems list is not a
73
+ display; it is the exit code.
74
+
75
+ So: ADR-016 is amended, not overruled. The row stays coarse; the problems list does not.
76
+
77
+ ### Three mutations, each caught by exactly its own test
78
+
79
+ | Mutation | Caught by |
80
+ | --- | --- |
81
+ | the stale-rule-file gate cannot fire | `a stale rule file on an adopted repo is a problem` |
82
+ | `profiles` back to its initialiser | `compare reports the profiles sync would project` |
83
+ | `profiles` computed from the declared stack instead of the detected one | `compare reports the profiles sync would project` |
84
+
85
+ The third is the one that matters. It is the shape this defect actually takes when it recurs: not an
86
+ empty array but a plausible array computed from the wrong input. A test asserting a hardcoded
87
+ expected list would have caught the second mutation and passed the third; asserting
88
+ `row.profiles` equals `plan(root).profiles` catches both, because it is the relationship rather
89
+ than the value that is the contract.
90
+
91
+ ### A fixture that asserted nothing
92
+
93
+ The first version of the stale-file test appended a line after the `END` marker. That is hand-owned
94
+ content by design — the containment guarantee another test in the same file already asserts — so the
95
+ file was correctly not stale and the test failed. The fixture was making an edit the test claimed
96
+ wasn't being made. It now edits inside the region and asserts that the edit landed by checking the
97
+ recorded SHA marker is present first.
98
+
99
+ A first attempt at the mutation was also too coarse: re-deriving `adoption` in the wrong place broke
100
+ ten tests at once, which told me the ordering is load-bearing but not which gate depended on it. The
101
+ isolated mutation is `if (false && …)` on the one gate.
102
+
103
+ ## Rejected
104
+
105
+ **Reporting per-file drift in the row's display, so the dashboard shows all four files.** ADR-016
106
+ already rejected this and its reason has not changed. A row is read across sixteen repositories at
107
+ once; four filenames per row is noise at that scale.
108
+
109
+ **Making `compare` defer entirely to `check` and drop the problems list.** The dashboard is the thing
110
+ a reader runs when they want to know where the estate stands; a command that says "run the other one"
111
+ has moved the problem rather than answered it.
112
+
113
+ **Exiting non-zero on an unmanaged repo's stray rule files.** The gate exists for the reason in
114
+ ruling two, and there is a test that asserts the negative so the fix cannot quietly widen it.
115
+
116
+ **Filling `profiles` from the manifest's declared stack.** It is cheaper and it is wrong: the
117
+ manifest is a declaration, `fingerprint` is a measurement, and `sync` selects on the measurement.
118
+ A dashboard reporting the declaration would be consistent with itself and inconsistent with the
119
+ tool. Mutation three is what that looks like when it happens.
@@ -0,0 +1,140 @@
1
+ # ADR-046 — One rule, one copy: a reader that reads AGENTS.md must not also get the file
2
+
3
+ Status: **accepted** · 2026-10-08 · supersedes ADR-021 ruling 3 · settles P7's context-budget question
4
+
5
+ ## Context
6
+
7
+ Only ADR-021 **ruling 3** is superseded. Its other rulings — one surface per repo, a pointer is not
8
+ a copy, a written file is not removed, skills follow the same rule — stand, and ruling 6 is load
9
+ bearing *here*. So the status line deliberately does not name it in the form the index parses
10
+ (`supersedes [ADR-NNN]`): marking the whole record superseded would be false, exactly as it would
11
+ be for a narrowing.
12
+
13
+ Review 0001 §S12, marked P1 and "decision". Measured across the estate before anything changed —
14
+ the five repositories selecting `opencode,cursor`:
15
+
16
+ ```
17
+ simt-app folded=3 .mdc=3 0/3 current
18
+ simt-blog folded=3 .mdc=3 0/3 current
19
+ simt-social folded=3 .mdc=3 0/3 current
20
+ simt-social-api folded=4 .mdc=4 0/4 current
21
+ simt-social-media folded=4 .mdc=4 0/4 current
22
+ ```
23
+
24
+ Seventeen generated rule files. Every one of them byte-identical to the section already folded into
25
+ the same repository's `AGENTS.md`, verified by extracting both bodies and diffing: the only
26
+ difference was a trailing marker line in my first extraction, and the second extraction made 14 of
27
+ the 17 identical and the remaining three identical apart from that marker. **28,101 bytes of context
28
+ loaded twice per session**, in five repositories, forever.
29
+
30
+ ADR-021 ruling 3 decided this deliberately: "Folding is `some`, not `all`: a repo that also opts into
31
+ Cursor still folds, because an opencode reader's only channel is `AGENTS.md` **and a Cursor reader
32
+ getting `.mdc` files does not serve them**."
33
+
34
+ The bolded clause is the whole dispute, and it is a factual claim about a vendor. It is false.
35
+ Cursor's own documentation says `AGENTS.md` is "a simple markdown file for defining agent
36
+ instructions... an alternative to `.cursor/rules`", and that "Cursor supports AGENTS.md in the
37
+ project root and subdirectories." Cursor reads `AGENTS.md`.
38
+
39
+ Note where the original claim came from: the `readers` string on **simt-dev's own opencode row**
40
+ already said "AGENTS.md is also read natively by Codex, Cursor, Windsurf, Zed, Amp, Jules, Copilot
41
+ CLI and the DeepSeek harness". The table knew. The decision that depended on the fact was written
42
+ without checking the table, which is the shape of error this repository keeps having.
43
+
44
+ ## Ruling
45
+
46
+ **One.** A surface declares `readsPrimary: true` when its own vendor documents that its reader loads
47
+ `AGENTS.md`. When any selected surface folds, `ruleTargets` skips per-rule files for those surfaces
48
+ and says so in the plan: the note names the file and the reason.
49
+
50
+ **Two.** It is per-surface, not global, because Copilot is the counter-example and the row says so
51
+ already: its CLI reads `AGENTS.md`; its editor and code review do not. A per-rule file there is the
52
+ only channel two of its three readers have, and dropping it would remove the process rules from a
53
+ chat that had nowhere else to get them.
54
+
55
+ **Three.** The skip depends on folding. With no folding surface selected there is no region to
56
+ duplicate, so a repo on `cursor` alone still gets its files — asserted, because a flag that reads
57
+ as "cursor never gets files" would be a different and much larger change.
58
+
59
+ **Four.** `sync` never deletes a file it wrote (ADR-021 ruling 6), so a file that becomes orphaned
60
+ cannot be repaired by any command. It is therefore a **note with a remedy, never a problem**, and it
61
+ never fails the gate. A gate nothing can satisfy is worse than a gate that is merely noisy.
62
+
63
+ **Five.** The tool must not stop looking at it. `orphanedRuleFiles` scans the rule directories of
64
+ every `readsPrimary` surface — **whether or not the repo still selects it** — for files carrying a
65
+ simt-dev region, and reports them. A file without the marker is hand-owned and is never mentioned.
66
+
67
+ ## Consequences
68
+
69
+ `sync --surfaces opencode,cursor` now writes `AGENTS.md` and no `.cursor/rules/*.mdc`. The three
70
+ always-on rules still arrive, folded, and the plan says why each file was not written.
71
+
72
+ The estate reports all 17 as notes with a delete command. Nothing was deleted: removal is a human
73
+ decision with a visible diff, and this record does not authorise a tool to make it.
74
+
75
+ ### The invisibility that ruling five exists to prevent
76
+
77
+ The skip landed first and the estate run showed `staleRuleFiles` go from **17 to 0**. Every one of
78
+ those files was still on disk. A tool that stops looking at a file it generated has not cleaned up —
79
+ it has lost track of it — and the symptom is a dashboard reporting a clean estate while seventeen
80
+ files are being read twice.
81
+
82
+ The first version of the scan then made the same mistake in a subtler form: it iterated the
83
+ *selected* surfaces. A file becomes orphaned precisely when a repo **drops** the surface that wrote
84
+ it, so scanning the selected set finds nothing at all — the one repo that needs the note is the one
85
+ that dropped the surface. Both defects were found by running the command against the estate, not by
86
+ reading the code. The estate run is worth more than either unit test.
87
+
88
+ ### Six mutations, three of which caught nothing at first
89
+
90
+ | Mutation | Caught by |
91
+ | --- | --- |
92
+ | the skip never fires | `opting into a rule surface ... writes one file, not two` |
93
+ | `readsPrimary` applied without the folding condition | three tests, including `cursor alone still gets its own files` |
94
+ | the orphan scan deleted | **nothing** — found, then fixed |
95
+ | `orphaned` made blocking | **nothing** — found, then fixed |
96
+ | `orphaned` counted as a stale problem | **nothing** — found, then fixed |
97
+ | orphan scan restricted to selected surfaces | `compare reports an orphaned rule file as a note` |
98
+
99
+ The middle three passed all 627 tests, which is worth recording plainly: the half of this slice with
100
+ the most dangerous failure mode — a gate nothing can satisfy — shipped with no test at all, and only
101
+ a deliberate mutation found it. Four tests were written afterwards, including a negative control
102
+ ("a repo's own rule files are never reported as orphaned"), because a scan that always returned an
103
+ empty array satisfies every positive test in the file.
104
+
105
+ ### Two tests that encoded the old ruling
106
+
107
+ `test/surfaces.test.mjs` asserted the three `.mdc` files beside the folded region, with the comment
108
+ "One canonical source, two destinations" — a fair description of the behaviour, and the behaviour
109
+ was wrong. `test/upgrade.test.mjs` used `--surfaces opencode,cursor` to produce the generated file
110
+ whose wholesale repair it tests. Both now use **copilot**, whose reader does not read the primary, so
111
+ the properties under test are unchanged while their fixtures remain honest. A test fixture built on
112
+ a behaviour a new ADR reverses has to be moved deliberately, and the reason recorded, or the next
113
+ reader moves it back.
114
+
115
+ `test/compare.test.mjs`'s S25 fixture also moved to copilot for the same reason — and its helper
116
+ selected rule files by the `.mdc` suffix, which silently matched nothing and reported "the fixture
117
+ must project at least one rule file" rather than "the fixture is broken".
118
+
119
+ ## Rejected
120
+
121
+ **Deleting the orphaned files automatically.** ADR-021 ruling 6, and the reason is sound: `sync` is a
122
+ projection, and a projection that deletes is a second, silent writer. The file is reported with the
123
+ command that removes it.
124
+
125
+ **Making `readsPrimary` global.** It would drop Copilot's only channel for its editor and code
126
+ review. The flag is per-surface for that reason and the Copilot row carries the note that says why.
127
+
128
+ **Keeping the `.mdc` files and not folding into the region when both surfaces are selected** — the
129
+ alternative review 0001 named. It gives opencode users nothing in mixed repos, which is ADR-021's
130
+ original objection to one surface per repo and still true.
131
+
132
+ **Removing Cursor support entirely, on the grounds that it reads `AGENTS.md`.** That would be true
133
+ and would lose the `.mdc` channel for anyone whose Cursor does not surface `AGENTS.md`, and it would
134
+ make a narrowing into a removal — which is not this tool's habit. The rules still reach a Cursor
135
+ reader; they just reach them once.
136
+
137
+ **The worktree-agent caveat becoming a gate.** A Cursor bug report records `AGENTS.md` not being read
138
+ in worktree agents by default, fixed in 3.9. That is a fact about a product and it is recorded on
139
+ the row, but gating on a version number would make a rule's presence depend on a vendor's release
140
+ cadence — and the alternative for those readers is a duplicated 5 KB, which is the cheaper error.
@@ -0,0 +1,141 @@
1
+ # ADR-047 — A profile selects on what a repository runs on, not on what it is written in
2
+
3
+ Status: **accepted** · 2026-10-09 · refines ADR-013 ruling 1
4
+
5
+ ## Context
6
+
7
+ Review 0002 §S28, P1, marked "decision" (proposal P3). Four profiles selected on a language or a
8
+ build tool while their bodies advised on a platform. Measured across the estate before anything
9
+ changed — the review's four rows, plus two smaller defects in the same table:
10
+
11
+ | Repo shape | Profile it received | Why that is wrong |
12
+ | --- | --- | --- |
13
+ | any TypeScript library | `cloudflare-workers` (D1, Durable Objects, wrangler) | applied on `typescript` |
14
+ | Spring or Ktor backend on Gradle | `jvm-android` (Compose, Wear OS, overlay policy) | applied on `gradle`, `kotlin` |
15
+ | React Native / Expo app | `react-web` (CSS logical properties, WCAG reflow, a browser matrix) | applied on `react`, and `DEP_TAGS` mapped `react` and `react-dom` to the same tag |
16
+ | Poetry project | a `python` body that says `uv run -m`, `uv.lock` | the body was uv-specific, the selection was any Python marker |
17
+
18
+ Two smaller ones in the same table: `hono` also keyed `cloudflare-workers`, and Hono runs on
19
+ Node, Bun and Deno as happily as on Workers — a library tag read as platform evidence. And
20
+ `wrangler.json` was missing from `MARKERS`, so a Worker configured with the plain JSON file and
21
+ no `wrangler` dependency had no evidence of being a Worker at all.
22
+
23
+ ADR-013 ruling 1 made platform tags and facts interchangeable in `applies`, and its consequences
24
+ promised that "every tag a profile may name is one the engine emits" would hold for facts too,
25
+ with "the registry validator and the profile loader share one derived vocabulary so they cannot
26
+ desynchronise." Half of that was implemented: `graph lint` checked a skill's `stack` array
27
+ against the vocabulary, and nothing checked a profile's `applies`. The four rows above are what
28
+ that half produced — not a typo, but selections the vocabulary permitted and no check objected to.
29
+
30
+ ## Ruling
31
+
32
+ **One.** A profile's `applies` names the evidence of what its body advises on. Where platform
33
+ evidence exists, a bare language or build-tool tag is not that evidence:
34
+
35
+ | Profile | Before | After | What selects it |
36
+ | --- | --- | --- | --- |
37
+ | `cloudflare-workers` | `cloudflare-workers, hono, typescript` | `cloudflare-workers` | a wrangler config or the `wrangler` dependency |
38
+ | `jvm-android`, split into `jvm` | `gradle, kotlin, wear-os` | `jvm`: `gradle, has-maven-pom, kotlin, java` | a JVM build |
39
+ | `jvm-android`, split into `android` | (the same file) | `android`: `has-android-manifest` | a module with an `AndroidManifest.xml` |
40
+ | `react-web` | `react, react-router, maplibre, firebase` | `react-dom, react-router, maplibre, firebase` | the browser renderer |
41
+ | `react-native`, new | — | `react-native` | a `react-native` or `expo` dependency |
42
+ | `python`, split into `python` and `uv` | `python` (with a uv-specific body) | `python`: `python`; `uv`: `has-uv-lock` | the language / the lockfile |
43
+
44
+ The exception is where a language *is* the finest fact available: `python`'s body advises
45
+ invocation hygiene that holds in every Python repository, so the `python` marker is the right
46
+ key there. The test that separates those two cases is the pair of rows whose shared stack is
47
+ exactly `node, react` and which differ only by the renderer tag: one receives `react-web`, the
48
+ other `react-native`, and neither receives both.
49
+
50
+ **Two.** The Android and uv facts name a path at an unknown depth, which the two existing kinds
51
+ (`file`, `dir`) cannot express. A third kind, `glob`, walks at most `GLOB_DEPTH` directory levels
52
+ below the root. The bound is a convention, not a preference: the Android Gradle Plugin puts a
53
+ module's manifest at `<module>/src/main/AndroidManifest.xml`, and simt-app's sits at
54
+ `app/src/main/AndroidManifest.xml`. A shallower bound makes `has-android-manifest` the shape S07
55
+ found — a fact no repository could have. A deeper one is a scan rather than a probe, and
56
+ ADR-013's promise is that adding an ecosystem is data, so a fact must not cost a search.
57
+
58
+ The walk skips dot-directories and `node_modules` for the reason the extension sample skips them;
59
+ the matcher is the registry's `matchesGlob` (ADR-042), so a fact's shape and a skill's `path:`
60
+ are one language rather than two; and the evidence for a `glob` fact is the matched path, never
61
+ the pattern — `doctor` answers "why did you think that?", and a shape is not an answer.
62
+
63
+ **Three.** `wrangler.json` and `wrangler.jsonc` join the Workers markers beside `wrangler.toml`.
64
+
65
+ **Four.** The vocabulary check now covers profiles as well as skills. `describeDefect` rejects a
66
+ `simt-applies` entry that `fingerprint()` cannot emit — `simt-applies names "pyton", which
67
+ fingerprint() never emits, so this profile could never be selected` — for central and local
68
+ profiles alike. A tag the engine cannot emit is a profile that can never be selected, and the
69
+ defect lives in the file that names it, not in whatever repository happens to load it. This is
70
+ the half of ADR-013's shared-vocabulary consequence that was unimplemented, and it is what makes
71
+ ruling one enforceable rather than aspirational: a future profile that re-keys on a language tag
72
+ spelling no detector emits fails at load time, in the file's own defect list.
73
+
74
+ ## Consequences
75
+
76
+ `compare` over the estate before and after — 16 repositories, **4 move, 12 unchanged**, and no
77
+ repository loses coverage:
78
+
79
+ | Repo | Before | After |
80
+ | --- | --- | --- |
81
+ | `app-ui`, `simt-app`, `wakt-app` | `core, jvm-android` | `core, android, jvm` |
82
+ | `bunnyland-server` | `container, core, python` | `container, core, python, uv` |
83
+
84
+ The three Android repositories gain the profile whose body they actually needed and keep the JVM
85
+ build rules; `simt-app` gaining `android` was the row's acceptance criterion. `bunnyland-server`
86
+ carries a committed `uv.lock`, so its gain is the defect repaired, not a new selection.
87
+
88
+ The rename is breaking in one narrow way: the id `jvm-android` no longer exists. A consumer that
89
+ pinned it — a manifest's `profiles` array, a local profile shadowing by id, a document that names
90
+ it — must rename to `jvm` and `android`. Nothing in the estate pinned it, which is why the table
91
+ above is complete; a consumer outside it learns from this record.
92
+
93
+ The provenance digest moves `47160e9e…` to `0cfea39f…` because files under `rules/stacks/` were
94
+ added and removed. Every adopted repository re-stamps on its next `sync` or `upgrade`.
95
+
96
+ The fixture table in `test/genericity.test.mjs` asserts each row's `stack` **and** its exact
97
+ profile set, in order — `core` first because it applies always, the rest in profile-file read
98
+ order. Asserting the set alone would still pass if a fixture stopped being the thing its row
99
+ claims (a "TypeScript library" that is no longer TypeScript), which is the weaker claim a
100
+ mutation produces. The hand-maintained `PROFILES` list in `test/shipped.test.mjs` is deleted:
101
+ S28's rename broke it, and it guarded nothing — the leak check already iterates `loadRules()`.
102
+
103
+ Seven mutations, all caught:
104
+
105
+ | Mutation | Caught by |
106
+ | --- | --- |
107
+ | `react-web` re-keyed on `react-dom, react` | the exact-set row test |
108
+ | the `glob` probe reduced to a root-presence check | four tests: row, evidence, bound, root shape |
109
+ | `GLOB_DEPTH` 3 → 2 | three tests: row, evidence, bound |
110
+ | `wrangler.json` dropped from the markers | the row test |
111
+ | `hono` restored to the Workers applies | the row test |
112
+ | the vocabulary check removed from `describeDefect` | the local-profile probe: an error where the message belongs |
113
+ | the android fact made a shallow `dir` probe (S07's shape) | three exact-set rows |
114
+
115
+ ## Rejected
116
+
117
+ **Selecting on the language and excluding with an extra condition** — `cloudflare-workers`
118
+ applies on `typescript`, but not when …. Selection by subtraction needs its own detector for
119
+ every new shape of repository, and each exclusion is a hole until it is written. The positive
120
+ form asks one question — *is this a Worker?* — and the answer is a file or a dependency, which is
121
+ what a fact is for.
122
+
123
+ **The build-plugin half of `has-android-manifest`.** The review proposed the manifest *or* the
124
+ `com.android.application` / `com.android.library` plugin in a build file. The plugin has two
125
+ spellings (Groovy `apply plugin:` and the Kotlin DSL `id(...)`), moves with the plugin's
126
+ versioning, and a repository can carry the plugin without the manifest while it is mid-migration.
127
+ The manifest is the one path shape that makes a module buildable as Android, and it is what
128
+ `doctor` can point at. The plugin half is recorded here rather than implemented, so the next
129
+ reader does not discover it as a gap.
130
+
131
+ **An unbounded glob walk.** It would find the manifest, and it would also make detection a scan
132
+ of every file below the root, which is what ADR-013's promise rules out: adding an ecosystem is a
133
+ profile file, not a search. The bound is the convention the fact exists to see.
134
+
135
+ **Keeping `jvm-android` and documenting the mis-selection.** That ships a profile whose body the
136
+ review measured as wrong for a whole class of repositories, with a comment where a fix belongs.
137
+
138
+ **A per-renderer tag for every framework.** `react-dom` is the renderer, and it is what
139
+ distinguishes a browser from every other surface. The other keys on `react-web` — `react-router`,
140
+ `maplibre`, `firebase` — were already there as discriminators, and they stay: each names a
141
+ library whose presence implies a browser deployment the same way `wrangler` implies Workers.
@@ -0,0 +1,150 @@
1
+ # ADR-048 — a baseline carries only keys a reader honours
2
+
3
+ Status: accepted · 2026-10-09 · amends ADR-025 (withdraws ruling 8, corrects its context)
4
+ **Affects:** `src/projection/baselines.mjs`, `test/baselines.test.mjs`,
5
+ `docs/plans/0001-centralise-agent-rules.md`. Closes S30 of
6
+ `docs/reviews/0002-full-review-adoption-and-public-release.md`.
7
+
8
+ ## Context
9
+
10
+ ADR-025 ruling 8 put `[prettier] singleQuote = true` in the shared `.editorconfig` and
11
+ called it *"the only comment-capable home prettier reads"*. Both halves of that sentence
12
+ are false, for one normative reason and one vendor reason, and neither is visible from
13
+ inside this repository.
14
+
15
+ **A section header is a glob.** The EditorConfig specification (v0.17.2, *File Format*)
16
+ is explicit: a "Section Header … define[s] globs", and *Glob Expressions* lists `*`,
17
+ `**`, `?`, `[seq]`, `[!seq]` and `{s1,s2,s3}` as the characters that do the matching.
18
+ `[prettier]` therefore matches a file named `prettier`, in a directory of the
19
+ repository or below it. No repository has one. The block matched no file, ever.
20
+
21
+ **`singleQuote` is not a key anything honours.** The same specification's *Supported
22
+ Pairs* section defines nine keys — `indent_style`, `indent_size`, `tab_width`,
23
+ `end_of_line`, `charset`, `spelling_language`, `trim_trailing_whitespace`,
24
+ `insert_final_newline`, `root` — and `singleQuote` is not among them. That section also
25
+ settles what an undefined key does, and it settles it in the worst possible direction:
26
+ cores "shall accept and report" syntactically valid pairs even when the key is not
27
+ defined, while plugins "shall ignore unrecognized keys". An unknown key is not an error
28
+ to be reported; it is a line that parses and does nothing.
29
+
30
+ Prettier's own mapping (`src/config/editorconfig/editorconfig-to-prettier.js` on `main`)
31
+ reads exactly six properties: `indent_style`, `indent_size`, `tab_width`,
32
+ `max_line_length`, `quote_type` and `end_of_line`. `singleQuote` is not one of them, and
33
+ neither `quote_type` nor `max_line_length` is a key the specification defines — the
34
+ source labels `quote_type` an "Undocumented feature". Its published documentation
35
+ mentions only `max_line_length`, `indent_size`, `tab_width`, `indent_style` and
36
+ `end_of_line` as the editorconfig properties it reads.
37
+
38
+ So the region asserted, in a comment, that it was the file that settled the estate's
39
+ `singleQuote` disagreement; the value was in a section that matched nothing, under a key
40
+ that no reader honours. That is the "decoration that misdescribes the artefact" failure
41
+ this repository's own `AGENTS.md` names, and no gate here could see it: `check` compares
42
+ bytes, and the bytes were current.
43
+
44
+ **This record cites the specification and the vendor's source rather than a local run.**
45
+ That is a change from the review's evidence, which measured Prettier 3.8.3, and it is
46
+ deliberate. ADR-041 rules this package's suite against reaching for a formatter, and the
47
+ npm registry this was written against refuses the fetch (`403`), so a reproduced run was
48
+ not available. The two citations are stronger for the purpose anyway: one is normative and
49
+ one is the mapping Prettier actually runs, whereas a version-pinned run would date the
50
+ moment the question was asked.
51
+
52
+ ## The survey S30 asked for
53
+
54
+ S30's fix was split in two on purpose: delete the block **now**, and add `quote_type =
55
+ single` **only with an estate survey**. The survey is this section, and it is the reason
56
+ the second half is not taken.
57
+
58
+ Five of the sixteen repositories run Prettier. Prettier resolves `.editorconfig` first and
59
+ merges the repository's own config over it (`resolve-config.js`: `{ ...editorConfigured,
60
+ ...mergeOverrides(...) }`), so a repository that states `singleQuote` is unaffected by
61
+ anything this baseline says, and a repository that states nothing is decided entirely by
62
+ it.
63
+
64
+ | Repository | Prettier config | States `singleQuote` | Adopting `quote_type = single` |
65
+ | --- | --- | --- | --- |
66
+ | `simt-api` | `.prettierrc` | `true` | no change |
67
+ | `simt-blog` | `.prettierrc.json` | `true` | no change |
68
+ | `simt-space` | `.prettierrc.json` | `true` | no change |
69
+ | `simt-social-api` | `.prettierrc.json` | absent | **every source file reformatted** |
70
+ | `simt-social` | `.prettierrc.json` | absent | **every source file reformatted** |
71
+
72
+ Two repositories would have every source file rewritten on the day they adopted the
73
+ tool, in a commit whose message says it adopted a rules projection. The measurement is
74
+ the answer to the question S30 deferred: the second half is not taken, and the reason is
75
+ recorded here so the next person does not run the survey again to reach it.
76
+
77
+ ## Rulings
78
+
79
+ 1. **`[prettier] singleQuote = true` is withdrawn.** ADR-025 ruling 8 does not stand; its
80
+ `printWidth` half is unaffected and was never implemented. This changes no behaviour,
81
+ because the block was inert — which is why two reviews and the whole suite read past
82
+ it: the gate compares bytes, and the bytes were what the source said they were.
83
+
84
+ 2. **A baseline section header is a glob over paths, never a name.** A header with no
85
+ special character matches one filename, and the only filenames a file every
86
+ repository receives may name are the ones every repository has. A literal travels
87
+ inside a brace group with the globs it belongs with — `[{*.go,*.proto}]`, which this
88
+ baseline already does for exactly this reason. `test/baselines.test.mjs` asserts it,
89
+ because the defect it prevents is a header that parses.
90
+
91
+ 3. **Every key in a baseline is one a reader honours.** The nine keys the specification
92
+ defines are the whole vocabulary, and the test names them with the section they come
93
+ from. This is what rules `singleQuote` out by construction rather than by deletion:
94
+ `quote_type` is excluded by the same test, and the reason it is excluded is written
95
+ next to it.
96
+
97
+ 4. **`quote_type = single` is not adopted, and the estate survey above is its record.**
98
+ Not rejected in principle: rejected for now, on a measurement — two of five
99
+ repositories would be wholly reformatted, and this tool does not reformat a
100
+ repository on the day it adopts one. The disagreement it would settle is real and is
101
+ still open (ADR-025's consequences already recorded `singleQuote` as unsettled
102
+ everywhere else), and it belongs in each repository's own Prettier config, which is
103
+ the only place that can change a repository's bytes.
104
+
105
+ ## Consequences
106
+
107
+ - `.editorconfig` loses eleven lines, one of which was the only claim in the file about a
108
+ tool. Every adopting repository sees a real `.editorconfig` change on its next
109
+ `sync --yes`, and `check` reports it: the baseline target list is the one `check` and
110
+ `upgrade` both read, so the repository is correctly classified as behind. What `upgrade`
111
+ will *not* do is name the cause correctly. Measured on a scratch repository carrying the
112
+ body this record withdraws and every other byte current:
113
+
114
+ ```
115
+ DRIFT .editorconfig region is updated; run: simt-dev sync --yes
116
+ pin 0cfea39f…6671 (unchanged)
117
+ region restamped; the pin was already current (the rules changed)
118
+ ```
119
+
120
+ No rule changed. The provenance digest covers `rules/` and `graph/` and deliberately
121
+ excludes `src/` — the rationale is that "a change that moves a consumer's projection"
122
+ must move the stamp, and the baseline bodies are content that leaves the package while
123
+ living in `src/`. `check` is unaffected because it compares bytes, and `upgrade` is
124
+ affected because it compares the stamp. ADR-032 found this exact false cause once
125
+ already, for a different reason, and the fix there was to identify the projection by its
126
+ content rather than by a commit; the same gap survives for the bytes that are not in
127
+ `rules/`. It is filed as **S53** with this measurement attached rather than fixed here,
128
+ because widening the digest changes what the stamp means and that is ADR-032's subject
129
+ (ADR-002).
130
+
131
+ - Plan 0001 claims the contradiction is settled "in `.editorconfig`'s `[prettier]`
132
+ section" in two places. Both were true of the file and false of its meaning, so both
133
+ are amended to point here. Leaving them would leave the plan asserting a mechanism this
134
+ record has just withdrawn, which is ADR-036's failure one document up.
135
+
136
+ - `test/baselines.test.mjs` grew a guard rather than an assertion about today's string,
137
+ because a string assertion passes the day somebody pastes the block back.
138
+
139
+ ## Rejected
140
+
141
+ - **Keeping the block and correcting only its comment.** Rejected: it would leave a
142
+ section matching nothing, and the next reader would have to rediscover that a comment
143
+ admitting it is inert is still inert.
144
+ - **`quote_type = single` under the JS/TS glob, as S30's second half proposed.**
145
+ Rejected on the survey above. It is recorded as a measurement, not as a plan, because
146
+ the cost lands on two repositories that did not ask for it.
147
+ - **Widening the digest to cover baseline bodies in this slice.** Rejected under
148
+ ADR-002: it is a change to what the stamp means, which is ADR-032's subject and
149
+ deserves its own record. It is filed as S53 with this measurement attached, so the
150
+ next reader has the evidence rather than the conclusion.