simt-dev 0.0.0-stage → 0.1.0

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 +103 -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 +173 -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 +42 -3
  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 +457 -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,153 @@
1
+ # ADR-049 — a ruling that a later record moved says so where it is printed
2
+
3
+ Status: accepted · 2026-10-09
4
+ **Affects:** `src/decisions/index.mjs`, `src/commands/rules.mjs`, `src/mcp/tools.mjs`,
5
+ `test/rules.test.mjs`. Closes S31 of
6
+ `docs/reviews/0002-full-review-adoption-and-public-release.md`.
7
+
8
+ ## Context
9
+
10
+ Review 0002 §5 filed S31 with a measurement: `rules why ADR-023` printed the status
11
+ `accepted · 2026-10-06 · ruling 2 superseded by` and then printed ruling 2 — *"It is written
12
+ once and never rewritten"* — as a ruling in force. ADR-024 had superseded exactly that
13
+ ruling. The module header names this as its own failure mode: *"the failure mode of a
14
+ provenance feature that only proves provenance, not currency."*
15
+
16
+ Two more defects were in the same reader and neither was in the finding.
17
+
18
+ **The status is a paragraph, and it was read as a line.** `loadDecisions` matched
19
+ `/^Status:\s*(.*)$/m`, which stops at the newline. Four of forty-eight records wrap their
20
+ status over more than one line, and the one that matters is ADR-023: its second and third
21
+ lines are the whole claim — which record superseded ruling 2, and that rulings 3 and 4 were
22
+ narrowed by the same record. The parser saw `ruling 2 superseded by` and stopped, which is
23
+ a sentence with no object.
24
+
25
+ **A partial supersession could not become a whole one, and the obvious fix makes it do
26
+ exactly that.** `supersededBy` was `/superseded by \[ADR-(\d{3})\]/i` over the status.
27
+ Once the status is the whole paragraph, that pattern matches ADR-023's *"ruling 2
28
+ superseded by [ADR-024](…)"* — and `supersededBy` is the field that makes `rules why`
29
+ answer a record by printing its replacement's rulings and suppressing its own. Five
30
+ rulings in ADR-023 that the status line itself says stand would have been dropped, by the
31
+ field that exists to stop precisely that. Anchoring the match to the start of the status is
32
+ not a detail of the fix; it is the fix.
33
+
34
+ **Rulings are numbered five different ways and one of them was read.** This is the finding
35
+ that made S31's stated fix impossible as written, and it was not visible until the ruling
36
+ text was measured rather than the status line:
37
+
38
+ | Family | Records | Example |
39
+ | --- | --- | --- |
40
+ | `1. **bold**` | 17 | ADR-021 |
41
+ | `**1. prose**` | 8 | ADR-034–040 |
42
+ | `**One.**` | 7 | ADR-041–047 |
43
+ | `### 1. Title` | 4 | ADR-013–016 |
44
+ | a bare bullet list | 1 | ADR-009 |
45
+ | unnumbered paragraphs | 11 | ADR-001–012 |
46
+
47
+ Only the first was recognised. The consequences are not stylistic. ADR-045 declares six
48
+ `**One.**` rulings and printed **one** merged blob of them. ADR-013 printed the literal
49
+ string `### 2.` to an agent as though the record had ruled with it. ADR-015 lists four
50
+ resolution strategies inside `### 2.`, and the first version of the fix read three of them
51
+ as rulings numbered 1, 2 and 3. And a supersession says *"ruling 3"*: a number that cannot
52
+ be located cannot be reported as no longer in force, so annotating a ruling depends on
53
+ knowing what one is.
54
+
55
+ **`accepted` was one record's spelling, not the document's.** The check was
56
+ `status.includes('**accepted**')`. ADR-001 to ADR-020 write `**accepted**`; ADR-021 onwards
57
+ write `accepted`. Fourteen accepted records read as not accepted. This is S19's finding and
58
+ S31 names the fix; it is the sixth instance in this repository of a check that reads the
59
+ author's habits rather than the artefact.
60
+
61
+ **A section ended at a heading inside a code block.** `section()` ended a section at the
62
+ next `^## `, and ADR-007's ruling is a document skeleton in a fence whose headings are
63
+ `##`. The record kept only the lead-in — which was still longer than the minimum length,
64
+ so the test asking whether every record yields a ruling passed on a fragment.
65
+
66
+ ## Rulings
67
+
68
+ 1. **`Status:` is a paragraph.** It is read to the first blank line or the first sibling
69
+ field, whichever comes first, and flattened. A sibling field is a capitalised label and
70
+ a colon, optionally in emphasis: `Affects:` and `**Affects:**` both end it, and no
71
+ continuation line in the corpus does. Getting the boundary wrong is not cosmetic in
72
+ either direction — stopping early truncates the sentence that says what was overturned,
73
+ and reading on swallows `Affects:`, whose value is a list of paths in a file read inside
74
+ repositories that own none of them.
75
+
76
+ 2. **Every field is read off the flattened, markdown-free status.** The link form and the
77
+ bare form are then one spelling, so a record that writes `ADR-046` and one that writes
78
+ `[ADR-046](…)` are read by the same line rather than by two patterns kept in step.
79
+
80
+ 3. **A record is superseded only when its own status says so first.** `supersededBy` is
81
+ anchored to the start of the status. See the Context: the unanchored form turns a
82
+ partial supersession into a total one.
83
+
84
+ 4. **`accepted` is read from the status.** `/^accepted\b/i` on the flattened paragraph, so
85
+ the record's own wording is what counts and no record's habits are.
86
+
87
+ 5. **A partial supersession is data, not a sentence to be parsed out of prose at print
88
+ time.** `partial: [{ruling, kind, by}]`, one entry per ruling a later record moved, in
89
+ three kinds the corpus uses — `superseded`, `narrowed`, `withdrawn`. A ruling a later
90
+ record names is printed with the fact attached, in place: *(superseded by ADR-024)*.
91
+ `ruling: null` is a record that says "one ruling" without saying which — a fact about the
92
+ record, carried rather than guessed at — and `by: null` is "the same record" in a status
93
+ that has not named one. Neither is resolvable, and neither is silently dropped.
94
+
95
+ 6. **"The same record" resolves to the last record this status named.** ADR-023 uses it for
96
+ rulings 3 and 4 immediately after naming ADR-024 for ruling 2. Resolving it to nothing
97
+ would drop two of the three supersessions on the one record this slice is about.
98
+
99
+ 7. **A ruling's identity is its number, and a section has one family.** The family is read
100
+ off the first line that looks like any of the six, and that family alone opens a ruling
101
+ for the whole section. One family per section is what keeps a list *inside* a ruling
102
+ inside it, which is ADR-015's four resolution strategies and ADR-013's four bullets.
103
+ A blank line does not close a ruling, because a record's parenthetical about its own
104
+ rulings sits between them. The heading marker and the bullet are structure and do not
105
+ survive into the text.
106
+
107
+ 8. **A section ends at the next heading, but not at one inside a fenced block.** The fence
108
+ marker is structure; its contents are not, and ADR-007's ruling is entirely inside one.
109
+
110
+ 9. **`summary.rulingNumbers` is exported.** It is the join between a supersession and the
111
+ ruling it moves, and nothing else observes it. The `**One.**` ordinal was dropped in
112
+ testing — one line of code — and **every test passed**, because no record in the estate
113
+ has both a word-numbered ruling and a partial supersession. That is ADR-042's failure
114
+ (a declared value nobody executes) inside a fix whose subject is a declared value
115
+ nobody executes, and the field is exported so the next such mutation has something to
116
+ fail against.
117
+
118
+ ## Consequences
119
+
120
+ - `rules why` prints a `partial` line naming every ruling the record lost, because two
121
+ rulings are printed and a record may have six: ADR-021's overturned ruling is the third,
122
+ so the annotation cannot reach it and the status line is all that is left. The same fact
123
+ appears twice by design — once as a line, once on the ruling — because the two print
124
+ different sets, and one copy could not cover both.
125
+ - The JSON payload gains `partial` and `summary.rulingNumbers`, and `rules_why` over MCP
126
+ carries `partial`. Both are additive; `ruled` stays a list of strings, because three
127
+ surfaces read it and the annotation is attached to the text rather than beside it.
128
+ - `supersedes` no longer reports ADR-046 as superseding ADR-021. It supersedes *ruling 3*
129
+ of ADR-021, which is `partial` on the other record. A whole-record supersession is the
130
+ rarer case and the one that has always been reported.
131
+ - `accepted` is now true for fourteen records that were false. Nothing in this repository
132
+ reads the field outside `rules why --json`, where a caller asking "is this in force?" was
133
+ being told no about records that are.
134
+
135
+ ## Rejected
136
+
137
+ - **Model the backward direction too — `supersedes ADR-021 ruling 3`,
138
+ `amends ADR-017 ruling 1`, `refines ADR-013 ruling 1`.** Rejected under ADR-002, with
139
+ the evidence attached: the same edge appears in both records, the passive side is the one
140
+ that must not print a ruling as in force, and parsing only the passive side is what makes
141
+ the anchoring in ruling 3 cheap. The active side is a reader convenience, not a safety
142
+ property, and it would need the `Affects:` paragraph as well as the status because
143
+ ADR-024, ADR-026, ADR-027 and ADR-028 all state it there.
144
+ - **Rewrite the forty-eight records to one spelling.** Rejected: the parser reading five
145
+ shapes is one function; reformatting every record is forty-eight edits to evidence, and
146
+ the `**accepted**` half of the problem was a one-line fix in the reader.
147
+ - **Number the rulings in ADR-016 so `ruling: null` disappears.** Rejected: ADR-045 says
148
+ what it narrows, and ADR-016's narrowing targets a bullet in its `Consequences` section
149
+ rather than one of its six numbered rulings. There is no number to write.
150
+ - **Print every ruling rather than two.** Rejected under ADR-002: it changes the output size
151
+ of every `rules why` in every repository, and no measured defect needs it. A ruling past
152
+ the excerpt cannot mislead a reader who is not shown it; the `partial` line is what makes
153
+ it knowable.
@@ -0,0 +1,102 @@
1
+ # ADR-050 — a byte count is not a region, and `updated` is not a change of rules
2
+
3
+ Status: accepted · 2026-10-09
4
+ **Affects:** `src/upgrade/plan.mjs`, `src/projection/markers.mjs`,
5
+ `src/projection/project.mjs`, `src/projection/targets.mjs`, `src/projection/baselines.mjs`,
6
+ `test/upgrade.test.mjs`, `test/markers.test.mjs`. Closes S26 of
7
+ `docs/reviews/0002-full-review-adoption-and-public-release.md`.
8
+
9
+ ## Context
10
+
11
+ S26's finding was one line of code: the no-region guard in `upgrade` was
12
+ `list.every((t) => t.action === 'added' && !t.previousBytes)`, and a repository whose
13
+ `AGENTS.md` is hand-written prose has `previousBytes > 0`. Reproduced on a repository built
14
+ the way the estate builds one — `init`, then `AGENTS.md` replaced by hand — and `upgrade`
15
+ wrote a 9 KB region into a repository that had never had one, reporting
16
+ *"pin moved and region restamped (the rules changed)"*. No rule had changed; no region had
17
+ existed.
18
+
19
+ Two further defects were in the same command, and neither was in the finding.
20
+
21
+ **The guard as written was not the fix it looked like.** Replacing `previousBytes` with
22
+ "is every region absent" reproduces the same defect, because the estate's version of this
23
+ state leaves the **baselines'** regions in place: `.editorconfig` and `.gitattributes` were
24
+ written by the same `init` and are untouched by the hand-written `AGENTS.md`. A guard
25
+ asking about every target sees two regions, concludes the repository is adopted, and writes
26
+ one. The guard has to ask about the **primary** target, which is the rule `compare` already
27
+ applies — its `region` field is the primary document's, and its problem line for this state
28
+ is *"adopted but AGENTS.md has no generated region"*. Two commands answering "is this
29
+ repository adopted?" differently is the defect S26 and the reason it was easy to write.
30
+
31
+ **`updated` means two things, and this command reads it to explain itself.** ADR-037 made a
32
+ correct region body behind a wrong seam report as `updated`, so `sync` would repair it. That
33
+ was right, and it gave `action` a second meaning: the body moved, *or* the bytes around it
34
+ did. `upgrade`'s whole purpose is to say why a repository is behind, so with
35
+ `action !== 'unchanged'` as the evidence it reports a formatting repair as a change of
36
+ policy — and sends the reader to diff rules that did not move. Measured, with the pin
37
+ already current in all three:
38
+
39
+ | State | Reported |
40
+ | --- | --- |
41
+ | body edited by hand | `the rules changed` — correct |
42
+ | seam wrong, body correct | `the rules changed` — **false** |
43
+ | stamp moved, body identical | `the rules were already correct` — right, but only by accident of ordering |
44
+
45
+ The third row is the second half of the trap. Comparing a whole target — bytes, header and
46
+ all — makes **every** pin bump claim the rules changed, because the region header carries
47
+ the stamp by design.
48
+
49
+ ## Rulings
50
+
51
+ 1. **A region is present or absent; a file is not "small".** `inject` and `diff` return
52
+ `region` (`absent` | `ok` | `malformed`) and `previousBody` alongside `action`, and every
53
+ target carries both. `parse` has always known this; `action` cannot express it and
54
+ `previousBytes` cannot answer it.
55
+
56
+ 2. **Adoption is a question about the primary document.** `upgrade` skips when
57
+ `AGENTS.md` has no region, whatever the baselines hold. This is `compare`'s rule and
58
+ `ADR-016`'s rule about display versus gate, applied to a command that was about to write.
59
+
60
+ 3. **The remedy is `simt-dev sync --yes`,** because `sync` does the thing the skip refused
61
+ to do and nothing else. S26 proposed `simt-dev init`; rejected — `init` re-runs
62
+ adoption, tags and branches, and the remedy would be doing more than the command it is
63
+ the remedy for. `compare` already names `sync --yes` here, and two commands must not
64
+ answer the same question with different instructions.
65
+
66
+ 4. **"The rules changed" is answered by comparing region bodies,** not by reading `action`
67
+ and not by comparing targets. A target is a change to the rules when its previous body
68
+ and the body this simt-dev would write differ, ignoring the leading and trailing
69
+ newlines the region may pick up from the document — the same normalisation `inject`
70
+ uses to decide `unchanged`.
71
+
72
+ 5. **A repair is named as a repair.** `region repaired; the pin and the rules were already
73
+ current` distinguishes a seam fix from a change of policy, because the reader's next
74
+ action differs: one is a formatting commit, the other is a review of what the rules now
75
+ say.
76
+
77
+ ## Consequences
78
+
79
+ - `upgrade` exits non-zero on a region-less repository, and `check` already did. They now
80
+ agree, and the test asserts the exit code rather than tolerating it: a command that names
81
+ the problem and exits 0 is the ADR-023 failure, and this command was about to start doing
82
+ it in a new place.
83
+ - Six mutations were applied and reverted, each caught by its own test. Two of them are
84
+ the finding and its tempting half-measure: falling back to `previousBytes`, and guarding
85
+ on every target instead of the primary one. Both pass the whole suite if the fixture is a
86
+ repository whose `AGENTS.md` is *absent* rather than hand-written — which is why the
87
+ fixture builds the estate's shape instead.
88
+ - `inject`'s and `diff`'s return shape grows two fields. Both are derived from a `parse`
89
+ that already ran, and no caller reconstructs them.
90
+
91
+ ## Rejected
92
+
93
+ - **Repairing the region in `upgrade` rather than skipping.** Rejected: `upgrade` moves a
94
+ pin. Adoption, and the repair of a repository that lost its region, are `init` and `sync`.
95
+ A command that silently performs them during a version bump is a command nobody can
96
+ predict — this module's own header says so.
97
+ - **Comparing `previousBody` to `body` including the header,** which would make the stamp a
98
+ rule change on every pin bump. Rejected: ruling 4's normalisation, and the reason the
99
+ comparison is body-to-body rather than target-to-target.
100
+ - **Making `region` a field on the plan rather than the target.** Rejected under ADR-002:
101
+ the target is what every caller already receives, and a second place computing the same
102
+ fact is how `compare`'s `profiles` ended up declared and never assigned (ADR-045).
@@ -0,0 +1,134 @@
1
+ # ADR-051 — a pointer is the reader's only route, so it is not a convenience
2
+
3
+ Status: accepted · 2026-10-09
4
+ **Affects:** `src/projection/project.mjs`, `src/projection/targets.mjs`,
5
+ `src/commands/sync.mjs`, `test/surfaces.test.mjs`. Closes S09 of
6
+ `docs/reviews/0002-full-review-adoption-and-public-release.md`.
7
+
8
+ ## Context
9
+
10
+ `pointer-drift` was in `NON_BLOCKING`, with a comment saying a misaimed `CLAUDE.md` is *"a
11
+ smaller problem than a red gate on every commit: the editors that matter already read
12
+ AGENTS.md, and the pointer is a convenience."* The first half is true and the second half
13
+ is the defect. Reproduced on a repository that had adopted and then had its pointer moved
14
+ to `README.md`:
15
+
16
+ ```
17
+ check exit 0, three files CURRENT
18
+ compare problems 0 repos
19
+ CLAUDE.md # readme
20
+ AGENTS.md 13 rule citations, none of them read
21
+ ```
22
+
23
+ Claude Code's own documentation, quoted in this repository's `POINTERS[0].verified`, says
24
+ **"Claude reads CLAUDE.md by default and AGENTS.md only as a fallback."** On a repository
25
+ that selected the `claude` surface, a pointer aimed elsewhere means the reader for whom the
26
+ rules were written is reading something else — and the gate said nothing. This is the same
27
+ shape as ADR-018's *"an unset hooks path is not an absent hook"*: the file exists, so the
28
+ thing that reads it is satisfied, and nothing reports that it is reading the wrong bytes.
29
+
30
+ The comment's reasoning came from a real observation about a **different** surface.
31
+ DeepSeek's loader renders a drifted *copy* in full beside its twin, which is duplication.
32
+ A *symlink* aimed elsewhere is not duplication — it is a reader looking at the wrong file.
33
+ The old test named DeepSeek and asserted the asymmetry, so the belief was written down and
34
+ the finding it produced was, by construction, unable to see.
35
+
36
+ A second defect, not in the finding: `pointerState` classified a path as a symlink, a
37
+ regular file, or **absent** — three answers, and a directory matched the third. A directory
38
+ at `CLAUDE.md` is not hypothetical: it is where Claude Code keeps per-project instructions.
39
+ Measured:
40
+
41
+ ```
42
+ check says nothing at all
43
+ sync ERROR 1 EEXIST: file already exists, symlink 'AGENTS.md' -> 'CLAUDE.md'
44
+ REMEDY run: simt-dev doctor
45
+ ```
46
+
47
+ `EEXIST` fires *after* every other target is written, so the run left a half-migrated
48
+ repository, and the remedy it offered reports nothing about a directory. The reason it was
49
+ invisible for as long as it was: the note that would have said so printed
50
+ `POINTER CLAUDE.md: a directory, not a pointer (undefined bytes)`, and `undefined` in a
51
+ report reads as nothing at a glance.
52
+
53
+ ## Rulings
54
+
55
+ 1. **A misaimed pointer blocks `check` when its reader is a surface this repository
56
+ serves.** The gate is where the exit code lives and where CI runs. A pointer whose
57
+ surface was not selected is not reported at all: the reader it serves is not reading
58
+ this repository, and a gate that reddens for it is a gate people learn to ignore.
59
+
60
+ 2. **The finding names the reader.** The remedy is `simt-dev sync --yes (Claude Code reads
61
+ CLAUDE.md, not AGENTS.md)` — because "run the command" does not tell a reader why this
62
+ matters, and a warning that has been learned to skip is worse than no warning.
63
+
64
+ 3. **`pointer-unread`, not `pointer-drift`.** The name is the reader's word for it: the
65
+ pointer is present and unreadable *as the thing it is for*. `pointer-drift` named the
66
+ defect as staleness, which is a file's problem, and that is how a reader-blindness
67
+ arrived at a file-scoped non-blocking rule.
68
+
69
+ 4. **`sync` does not block on it and `check` does, and they are two sets.** The remedy is
70
+ `sync --yes`; a `sync` that refused on this finding would be a gate whose remedy is the
71
+ command being run, which ADR-025 ruling 6 calls worse than a noisy gate. `check` is the
72
+ gate CI runs and the one whose exit code a repository acts on, and there the finding is
73
+ the defect.
74
+
75
+ This was the finding of the slice, and it was found by rendering the output rather than
76
+ by reading the code — the same acceptance `AGENTS.md` sets for rule text. **The first
77
+ version of this fix kept one `NON_BLOCKING` set** shared by both commands, on the
78
+ reasoning that the writer's rule was the correct one. So `check` exited 0 on the exact
79
+ repository this record exists to fix, and a test asserting `check` exited 1 **passed** —
80
+ on a bare scratch directory that was red for a missing `AGENTS.md`. One set covering both
81
+ commands is how the defect survived the first time, which is the sentence worth keeping:
82
+ the rule was written to keep `sync` quiet and quietly quieted `check`'s exit code too.
83
+
84
+ Two sets, both asserted, and the relationship between them asserted as well: everything
85
+ `check` tolerates, `sync` tolerates. The gate is never the laxer one.
86
+
87
+ 5. **A directory is `foreign`, and its measure is entries.** `stat` distinguishes a
88
+ directory from a file before anything is read, because reading a directory as utf8
89
+ throws `EISDIR` and the old catch reported that as *absent* — the one answer that hides
90
+ it. The size reported is what would be lost: bytes for a file, entries for a directory.
91
+
92
+ 6. **`sync` repairs a misaimed pointer, because it is the remedy `check` prints.** The
93
+ pointer is this tool's own output, it is a symlink so it carries no content, and
94
+ `symlink()` at an existing path is `EEXIST` — which is what happened, after every other
95
+ target was written, with `simt-dev doctor` named as the remedy and `doctor` reporting
96
+ nothing about it. A command whose own remedy crashes on the state it names has no
97
+ remedy at all. Only a symlink is unlinked; a regular file or a directory never entered
98
+ the plan, so the repair is keyed on the pointer's **state** and not on the path
99
+ existing.
100
+
101
+ ## Consequences
102
+
103
+ - `SYNC_NON_BLOCKING` and `CHECK_NON_BLOCKING` are two exported sets, and both are asserted
104
+ as sets *and* as a relationship. Three mutations passed the whole suite before that: `check`
105
+ keeping the finding non-blocking (the defect), `sync` dropping it (the ADR-025 hazard), and
106
+ the gate reading the writer's set. The text output is identical in all three, which is
107
+ ADR-035's sentence about promises about a *set* of commands, applied to a set of statuses.
108
+ - Two tests asserted a gate that was red for the wrong reason, and both are fixed the same
109
+ way: "a pointer this repository does not serve stays a note" and "check blocks a mispointed
110
+ pointer" checked `check` on a bare scratch directory, which is red for a missing
111
+ `AGENTS.md`. Both now sync first, so the only thing that can redden the gate is the
112
+ pointer. ADR-031's shape, one level down — and the second instance is the one that matters,
113
+ because it was the test written *for this fix* that passed against the defect unfixed.
114
+ - `sync` keeps its note-and-continue for a foreign pointer, and now says what kind of thing
115
+ is in the way. It repairs a *misaimed* one, and the test asserts that the file the bad
116
+ link pointed at survives — because the repair is a deletion, and a test that only checked
117
+ the link now points where it should would pass if `unlink` had removed the wrong thing.
118
+ - The corpus test that encoded the old belief was **replaced, not amended**, and its
119
+ comment now records what it got wrong and why the observation it was built on does not
120
+ transfer.
121
+
122
+ ## Rejected
123
+
124
+ - **Blocking on a misaimed pointer for every repository.** Rejected: `pointerTargets`
125
+ filters on the selected surfaces, and a repository on `opencode` that has a stray
126
+ `CLAUDE.md` gains nothing. The scoping is not a compromise; it is the same rule the
127
+ pointer table already encodes (ADR-021 ruling 1: a pointer belongs to the surface that
128
+ needs it).
129
+ - **Writing the pointer anyway when it is misaimed.** Rejected: `symlink()` over an
130
+ existing path is `EEXIST`, and unlinking a path someone may have pointed deliberately is
131
+ a deletion this tool does not do (ADR-021 ruling 6).
132
+ - **Treating a directory as `pointer-blocked` with a byte count of zero.** Rejected: the
133
+ count would be a number the reader cannot use, and the report says what is in the way so
134
+ a person can decide.
@@ -0,0 +1,101 @@
1
+ # ADR-052 — a dependency is counted against the budget, and there is one budget
2
+
3
+ Status: accepted · 2026-10-09
4
+ **Affects:** `src/resolve/resolve.mjs`, `src/commands/skills.mjs`, `src/mcp/tools.mjs`,
5
+ `test/resolve.test.mjs`, `test/mcp.test.mjs`. Closes S06 of
6
+ `docs/reviews/0002-full-review-adoption-and-public-release.md`.
7
+
8
+ ## Context
9
+
10
+ S06 filed two defects. Both reproduced; one of them is a different defect now.
11
+
12
+ **A dependency overran the budget by 6.7×.** Both budget loops tested
13
+ `entry.via.size > 0` and, if the entry was a dependency, inlined it regardless of size:
14
+
15
+ ```js
16
+ const isDep = entry.via.size > 0;
17
+ if (flags.all || used + skill.bytes <= budget || isDep) {
18
+ ```
19
+
20
+ Measured against the shipped registry:
21
+
22
+ ```
23
+ $ simt-dev skills brief --skill parallel-feature-team --budget 2000
24
+ resolved: parallel-feature-team (1131), slice-commit (5048), complete-testing (7324)
25
+ = 13,503 bytes against a 2,000-byte budget
26
+ ```
27
+
28
+ Nothing in the output says the budget was exceeded, because the condition that enforced it
29
+ had already been short-circuited. The code's own comment gives the reason — *"a skill that
30
+ is useless without its dependency is worse than a large context, because it silently
31
+ misleads"* — and the premise is correct while the conclusion does not follow from it. A
32
+ missing dependency is fixed by a line in the output; an unbounded budget is not a fix for
33
+ anything.
34
+
35
+ **The two copies had already drifted.** This is the part that had become worse than filed,
36
+ and it is the shape ADR-035 named: a promise about a *set* of readers holds only while
37
+ something checks the set, and here the set was two loops. `skills brief` tested `deferred`
38
+ first and never inlined one; the MCP tool tested the budget first and then had a nested
39
+ `if (isDep || !entry.skill.deferred)`, which **did** inline a deferred dependency. One
40
+ command inlined a 384 KB body, the other did not, and neither could see the other.
41
+
42
+ The review's probe #14 evidence is stale and the record should say so: it measured
43
+ `summary:the` selecting four estate skills, which stopped being true when ADR-043 and
44
+ ADR-044 landed. What is *not* stale is the defect — the `isDep` bypass is in the shipped
45
+ code and 6.7× over budget is measured against today's registry, on a path the review never
46
+ used.
47
+
48
+ ## Rulings
49
+
50
+ 1. **Dependencies are counted against the budget.** No entry gets in because of what it
51
+ is. This is the whole behavioural change and it is one condition.
52
+
53
+ 2. **A group that does not fit is deferred together.** An entry reached through `requires`
54
+ that misses the budget takes its parents with it, to a fixpoint — because a parent shown
55
+ without the thing it requires is precisely the misleading state ruling 1's comment was
56
+ written to avoid. It is now avoided by dropping the parent rather than by ignoring the
57
+ budget. The direction matters and is the whole of it: a small parent does not earn the
58
+ right to appear without its dependency.
59
+
60
+ 3. **`deferred` is a pointer, not a budget failure, so it does not propagate.**
61
+ `deferred` is derived from `deferredThreshold` and means "fetch this with
62
+ `skills show`". `--all` means take everything, and the pointer to a deferred dependency
63
+ is what a pointer is for. Only *budget* pressure moves a parent. Conflating the two is
64
+ how a 384 KB body would end up suppressing an unrelated 300-byte skill.
65
+
66
+ 4. **`selectWithinBudget()` is the whole arithmetic, and there is one of it.** It returns
67
+ `{ picked, deferred, used }`, and `used` is recomputed from what was actually picked
68
+ rather than accumulated during the walk — a parent pulled out by ruling 2 must stop
69
+ counting, because a total that includes a dropped skill reports bytes nobody is paying
70
+ and the header line prints it.
71
+
72
+ 5. **The two surfaces are compared to each other in a test.** Not "each is correct" —
73
+ each asserted against the other's output at two budgets. A parity test is the only thing
74
+ that catches a copy drifting from a copy, and the review's probe for this row caught the
75
+ drift by reading both.
76
+
77
+ ## Consequences
78
+
79
+ - `--skill parallel-feature-team --budget 2000` now resolves nothing and names all four
80
+ deferred skills with `over budget`. At `--budget 20000` it resolves the group whole. The
81
+ reader can raise the budget or ask for one skill; before, it got 13 KB and no explanation.
82
+ - `skills_brief` over MCP and `skills brief` are now the same function, and the test drives
83
+ both at 2,000 and 20,000 bytes and compares selected, deferred and bytes.
84
+ - Six mutations were applied and reverted, each caught: dependencies bypassing the budget,
85
+ the group rule dropped, `deferred` no longer excluded, `deferred` counted against the
86
+ budget, the total no longer being the bytes paid, and the MCP tool not calling the shared
87
+ function.
88
+
89
+ ## Rejected
90
+
91
+ - **Keep the bypass and report the overrun.** Rejected: the output already has a `deferred`
92
+ list with reasons, and a budget that means "unless" is not a budget. If the estate ever
93
+ decides a dependency is worth any number of bytes, that is a flag with a name saying so.
94
+ - **Defer only the dependency and leave its parent.** Rejected: that is the state the old
95
+ comment called *silently misleading*, and it is what ruling 2 exists to stop.
96
+ - **Propagate `deferred` to parents too, for symmetry.** Rejected under ADR-013: the two are
97
+ different signals from different fields — `bytes` against a budget the caller set, and
98
+ `deferred` against a threshold the registry declares — and collapsing them makes a size
99
+ threshold suppress a selection.
100
+ - **Change the default budget or the deferred threshold.** Rejected: nothing here measured a
101
+ case where the budget, rather than its enforcement, was the problem.
@@ -0,0 +1,49 @@
1
+ # ADR-053 — The Node floor is 22.20.0
2
+
3
+ Status: **accepted** · 2026-10-10
4
+ **Affects:** `package.json`, `src/ci/workflow.mjs`, `src/resolve/resolve.mjs`.
5
+
6
+ ## Context
7
+
8
+ Node 20 reached end of life on 2026-04-30. `engines.node` still said `>=20`, both workflow
9
+ templates pinned setup-node to `20`, and `matchesGlob` was a hand-written translation because
10
+ `path.matchesGlob` is stable from Node 22.20.0 (and 24.8). ADR-042 recorded that translation
11
+ and said ruling 1 collapses into the built-in when the floor moves. Review 0002 S47 settles
12
+ the floor at `>=22.20.0` and says the own CI runs 22, 24 and 26.
13
+
14
+ ## Ruling
15
+
16
+ **One.** `engines.node` is `>=22.20.0`. Both workflow templates pin `actions/setup-node` to the
17
+ version that range names, read from `package.json`, so a template cannot name a version the
18
+ package says it does not run on. This repository's own gate also runs Node 24 and Node 26. A
19
+ floor that is only executed at the minimum is a floor in name.
20
+
21
+ **Two.** `matchesGlob` is `path.matchesGlob`. A trailing double star also matches the directory
22
+ itself: the built-in requires a child, so `migrations` misses `migrations/**`, and that empty
23
+ path is the one `graph lint` builds (ADR-042, ruling 3). A glob containing `?`, `{` or `[`
24
+ matches nothing, so ruling 4 still holds. Ruling 1 of ADR-042 is superseded by this record.
25
+ Rulings 2, 3 and 4 of ADR-042 stand.
26
+
27
+ **Three.** The runtime line is the same sentence in `package.json`, both templates, this
28
+ record, plan 0002's live Runtime line, and `README.md`. The README states the floor and
29
+ nothing else; the packaging README is S18 and does not get to pick a different floor.
30
+
31
+ ## Rejected
32
+
33
+ **Waiting for S18's README before raising the floor.** The floor is what CI and the matcher
34
+ run on. A missing README was not a reason to keep declaring a runtime that is end of life.
35
+
36
+ **Pinning both templates at Node 24 in this record.** S48 owns "templates use 24", the SHA
37
+ pins, and the macOS matrix. This record's adopting template uses the floor so it agrees with
38
+ `engines`. The own gate's extra versions are coverage, not a second floor.
39
+
40
+ **Letting `path.matchesGlob` implement `?` and braces.** It does, and that is how `src/a?.ts`
41
+ matches `src/aq.ts`. ADR-042 ruling 4 refused that reading because the registry does not use
42
+ it and a glob that matches the wrong paths is worse than a glob lint rejects.
43
+
44
+ ## Consequences
45
+
46
+ - `compileGlob` is gone. The cases ADR-042's tests lock (`**/*.ts`, a star that does not cross
47
+ `/`, a bare `**`) are now the built-in's, and the `?` case still fails lint.
48
+ - SHA-pinning `actions/checkout` and `actions/setup-node`, and the macOS runner, stay with S48.
49
+ - The adopting workflow's install step stays with S35.
@@ -0,0 +1,100 @@
1
+ # ADR-054 — The digest covers every byte that leaves the package, and a cause is read off the body
2
+
3
+ Status: **accepted** · 2026-10-10 · amends [ADR-032](ADR-032-provenance-is-a-content-digest-not-a-commit.md)
4
+ **Affects:** `src/projection/rules.mjs`, `src/upgrade/plan.mjs`.
5
+
6
+ ## Context
7
+
8
+ ADR-032 made the stamp a SHA-256 digest of `rules/` and `graph/`, and said why `src/` is
9
+ excluded: a refactor that changes no rule and no registry entry projects byte-identical
10
+ output, and stamping those consumers would report drift where there is none.
11
+
12
+ That reasoning is right and the boundary was drawn one level too low. ADR-032 excluded a
13
+ *directory*; the shared `.editorconfig` and `.gitattributes` blocks are not in a directory,
14
+ they are two string constants in `src/projection/baselines.mjs` — and every adopting
15
+ repository receives them. By ADR-015's own rule, a source is identified by its content rather
16
+ than by the file it happens to live in, so the body defined in `src/` is a source like any
17
+ other, and excluding `src/` as a path excluded content that leaves the package with it.
18
+
19
+ The finding arrived sideways, which is the only way this class gets found. Removing the
20
+ inert `[prettier]` block (S30) deleted seven lines from a shipped baseline — the first change
21
+ in this repository that moves bytes out of `src/` without moving the provenance digest.
22
+ Measured on a scratch repository with **only** that baseline body changed:
23
+
24
+ - `check` reported `DRIFT .editorconfig`, which is correct: the gate compares bytes, and the
25
+ bytes were wrong.
26
+ - `upgrade` reported the pin as `(unchanged)` and named the cause as
27
+ *"region restamped; the pin was already current (the rules changed)"*.
28
+
29
+ Both halves of that sentence are false. The pin was unchanged because the stamp could not see
30
+ the change, and the rules did not change — seven lines of an indentation setting did. The
31
+ first false statement made the second one reachable: with the stamp silent, the only cause
32
+ left to report was the one that was least true.
33
+
34
+ The severity is the estate's own: a report that names a cause which did not happen sends the
35
+ reader to diff `rules/` when the change is in `.editorconfig`. This is ADR-050's mistake one
36
+ layer up — a field read for the thing it was not — and ADR-036's, where an instruction that
37
+ is false in every repository is worse than one that is missing.
38
+
39
+ ## Ruling
40
+
41
+ **One. The digest covers every byte string that leaves the package: `rules/` and `graph/` on
42
+ disk, plus the rendered baseline bodies.** The baselines enter keyed by the file each becomes
43
+ in the adopting repository (`baselines/.editorconfig`), not by the module that defines it,
44
+ because the stamp answers what content produced those bytes and `.editorconfig` is the name
45
+ a reader of a generated region recognises. `src/` remains excluded *as a directory*; the
46
+ ruling narrows ADR-032's exclusion from a path to a rule about content. Ruling 1 of ADR-032
47
+ and its "what the digest covers" section are superseded; ADR-032's framing, its framing
48
+ discipline and its other two consequences stand.
49
+
50
+ **Two. `provenanceInputs()` is exported, and `provenanceDigest()` hashes what it returns.**
51
+ "The digest covers the right things" is a claim about a set, and a set assembled only inside
52
+ the function that hashes it cannot be asserted from outside — the test would have to mutate
53
+ the package to get an answer. Reading the list and hashing it in one place is ADR-042's
54
+ ruling 3 applied to the stamp: the rendering is shared, so narrowing it narrows the test with
55
+ it, and the test passed with the rendering sabotaged is not evidence.
56
+
57
+ **Three. `upgrade`'s cause is read off the projected body, and the two kinds of content are
58
+ counted apart.** A baseline region is not a rule: `.editorconfig` and `.gitattributes` are
59
+ settings the repository owns. `rulesChanged` and `baselineChanged` are computed separately
60
+ and the sentence names one, both, or neither. The comparison is body to body rather than
61
+ byte to byte, because the region header carries the stamp and folding it in would make every
62
+ pin bump claim a change of policy — which is ADR-050's ruling, and it is why a target whose
63
+ seam moved reads as `updated` with no cause.
64
+
65
+ **Four. "Neither" is a real answer and is not collapsed into the nearest cause.** A seam
66
+ repair, a stamp-only move and a generated-field move are three different problems that one
67
+ command fixes, and the sentence distinguishing them is the whole reason the command exists.
68
+
69
+ ## Consequences
70
+
71
+ - **Every adopted repo's stamp moves once more.** A repo pinned at a digest computed without
72
+ the baselines reports `behind` the first time it runs this version and `simt-dev upgrade`
73
+ fixes it. That is a pin doing its job, not a regression — the same reading as ADR-032's own
74
+ first consequence.
75
+ - **`upgrade` can no longer report a cause it cannot see.** The two defects were independent
76
+ and are now separately guarded: the digest test asserts the baselines are in the hashed set
77
+ and that changing one changes the value; the planner test asserts a baseline body move
78
+ produces "a baseline changed" and not "the rules changed".
79
+ - **The planner needed a `kind` it did not have.** Baseline targets already carried
80
+ `kind: 'baseline'`; rule and primary targets carry none. That is the discriminator, and it
81
+ is read from the targets `plan` produced rather than recomputed from the file list, so the
82
+ two cannot disagree about which file is a baseline.
83
+ - **A `src/` refactor still moves nobody.** The exclusion was narrowed from a path to a rule
84
+ about content, and the test asserts no `src/` entry appears in the hashed set — so the
85
+ narrowing cannot be re-widened silently by a future import.
86
+
87
+ ## Rejected
88
+
89
+ - **Hash `src/` as a directory.** The smallest diff and the wrong one: it puts every
90
+ refactor, comment change and test edit into every consumer's stamp, which is precisely the
91
+ false report ADR-032 ruled out.
92
+ - **Leave the stamp alone and fix only the wording.** The wording was the visible half. With
93
+ the pin reporting `(unchanged)` beside a restamped region, `upgrade` would still be
94
+ describing a move that did not happen — and the reader has no way to tell which line of
95
+ the output to disbelieve.
96
+ - **Give baselines their own stamp field.** A second identity per repository, and the reader
97
+ would have to know which of two values to compare against. One stamp, covering everything
98
+ it stamps.
99
+ - **Call the baseline a "rule" in the message.** Cheaper than a discriminator, and it sends
100
+ the reader to `rules/` — which is the defect.