simt-dev 0.1.0 → 0.2.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 (55) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +31 -4
  3. package/docs/decisions/ADR-001-push-requires-approval.md +5 -5
  4. package/docs/decisions/ADR-002-refactor-blast-radius.md +1 -1
  5. package/docs/decisions/ADR-003-test-breadth.md +4 -4
  6. package/docs/decisions/ADR-004-hook-owns-format.md +5 -5
  7. package/docs/decisions/ADR-005-file-length.md +5 -5
  8. package/docs/decisions/ADR-006-coverage-bar.md +3 -3
  9. package/docs/decisions/ADR-007-section-skeleton.md +5 -5
  10. package/docs/decisions/ADR-008-verdict-vocabulary.md +1 -1
  11. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  12. package/docs/decisions/ADR-010-runner-invocation.md +1 -1
  13. package/docs/decisions/ADR-012-drop-windows-support.md +2 -2
  14. package/docs/decisions/ADR-015-a-source-is-content-not-a-location.md +2 -2
  15. package/docs/decisions/ADR-016-a-divergent-rule-is-retired-not-merged.md +13 -13
  16. package/docs/decisions/ADR-017-a-declared-gate-must-be-runnable.md +2 -2
  17. package/docs/decisions/ADR-018-an-unset-hooks-path-is-not-an-absent-hook.md +24 -14
  18. package/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +7 -7
  19. package/docs/decisions/ADR-023-the-gate-ships-as-a-file-the-repo-owns.md +2 -2
  20. package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +5 -5
  21. package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +2 -2
  22. package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
  23. package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +2 -2
  24. package/docs/decisions/ADR-031-the-suite-stages-the-environment-it-needs.md +3 -3
  25. package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +4 -4
  26. package/docs/decisions/ADR-041-the-suite-installs-the-package-in-order-to-test-it.md +2 -2
  27. package/docs/decisions/ADR-045-a-dashboard-that-counts-a-defect.md +3 -3
  28. package/docs/decisions/ADR-046-one-rule-one-copy.md +5 -5
  29. package/docs/decisions/ADR-047-a-profile-selects-on-what-a-repository-runs-on.md +4 -4
  30. package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +5 -5
  31. package/docs/decisions/ADR-057-an-identity-is-per-developer-and-a-repository-does-not-own-it.md +115 -0
  32. package/docs/decisions/ADR-058-one-tool-three-channels-and-the-order-is-forced.md +148 -0
  33. package/docs/decisions/README.md +3 -1
  34. package/package.json +3 -5
  35. package/src/commands/hooks.mjs +27 -13
  36. package/src/commands/init.mjs +67 -1
  37. package/src/commands/skillsinstall.mjs +1 -1
  38. package/src/compare/discover.mjs +1 -1
  39. package/src/compare/row.mjs +2 -2
  40. package/src/decisions/index.mjs +30 -6
  41. package/src/graph/load.mjs +1 -1
  42. package/src/hooks/capability.mjs +1 -1
  43. package/src/hooks/dispatcher.mjs +1 -1
  44. package/src/hooks/gates.mjs +1 -1
  45. package/src/hooks/where.mjs +1 -1
  46. package/src/identity.mjs +218 -0
  47. package/src/projection/baselines.mjs +1 -1
  48. package/src/projection/conflicts.mjs +1 -1
  49. package/src/projection/manifest.mjs +1 -1
  50. package/src/projection/markers.mjs +3 -3
  51. package/src/projection/mdc.mjs +2 -2
  52. package/src/projection/surfaces.mjs +2 -2
  53. package/src/resolve/fingerprint.mjs +3 -3
  54. package/src/resolve/source.mjs +1 -1
  55. package/src/tsconfig.mjs +1 -1
@@ -19,8 +19,8 @@ empty", and deferred it to a record of its own.
19
19
 
20
20
  The estate confirms the first half and not the conclusion. Thirteen of sixteen repositories
21
21
  have no `.gitattributes` at all. The three that do disagree about almost everything:
22
- `simt-app` declares only LFS filters for `.tif` and `.zip`; `simt-garmin` declares line
23
- endings plus ConnectIQ binary markers; `simt-social-api` declares exactly one whitespace
22
+ `repo-05` declares only LFS filters for `.tif` and `.zip`; `repo-07` declares line
23
+ endings plus ConnectIQ binary markers; `repo-11` declares exactly one whitespace
24
24
  suppression for hash-locked vendored content. There is no divergence here of the kind a
25
25
  baseline fixes by being consistent. There is a file missing.
26
26
 
@@ -48,7 +48,7 @@ to be the line ruling 7 sent this file to make.
48
48
  because nothing in the pattern says those are binary. `text=auto` is a detection: git
49
49
  sniffs each path and applies normalisation only to files it concludes are text. This is
50
50
  the most common way a repository corrupts its own binaries, and it is invisible until
51
- someone diffs an image. `simt-garmin` had independently arrived at `text=auto eol=lf` on
51
+ someone diffs an image. `repo-07` had independently arrived at `text=auto eol=lf` on
52
52
  its first line, which is the strongest available evidence that the estate would pick the
53
53
  cautious form if asked — this ruling does not ask, it supplies.
54
54
 
@@ -96,8 +96,8 @@ what any file is". A future baseline for a per-stack attribute would have to arr
96
96
  different row with its own record, which is what §3.2's keep-it-local rule asks for anyway.
97
97
 
98
98
  The repositories with an existing `.gitattributes` are the interesting case and they are not
99
- in conflict: `simt-app`'s LFS filters, `simt-garmin`'s binary markers and
100
- `simt-social-api`'s whitespace suppression all stay beneath the region and all keep winning,
99
+ in conflict: `repo-05`'s LFS filters, `repo-07`'s binary markers and
100
+ `repo-11`'s whitespace suppression all stay beneath the region and all keep winning,
101
101
  because git takes the last match. No repository loses a line it wrote.
102
102
 
103
103
  This repository adopts neither baseline for itself, and the reason is the same one that keeps
@@ -10,7 +10,7 @@ a copied byte range. Settles the `tsconfig.base.json` item of §3.1 row 5 and ro
10
10
  ## Context
11
11
 
12
12
  Row 5 promised eight baselines and named two problems among them that are not about a file
13
- being missing: simt-goto's seven absent strictness flags, and `moduleResolution` spelled
13
+ being missing: repo-08's seven absent strictness flags, and `moduleResolution` spelled
14
14
  inconsistently across the workspace. ADR-025 ruled `tsconfig.base.json` out of the region
15
15
  mechanism because JSON has no comments, and its consequences promised that "the next fix is
16
16
  three more JSON files and therefore a different mechanism". ADR-026 supplied that mechanism
@@ -95,7 +95,7 @@ one an external document uses.
95
95
 
96
96
  A TypeScript repository now gets twelve flags it did not have, and one of them — the floor
97
97
  arriving through `extends` — is a behaviour change that can surface real type errors in
98
- `simt-goto`, which is the point of the record and also the reason adoption is one commit
98
+ `repo-08`, which is the point of the record and also the reason adoption is one commit
99
99
  with a `tsc` run rather than a silent upgrade. A base file is a floor, and a repository
100
100
  that had not been standing on one is the case where raising it finds something.
101
101
 
@@ -104,7 +104,7 @@ nobody has been asked, and it is the record to read next for either.
104
104
 
105
105
  - **A shipped `.nvmrc` or an `engines` value in a shared baseline.** Rejected as
106
106
  unrulable: the value is per-repository and the tool can only guess it. Shipping `22` would
107
- break `simt-social`, which pins 24.
107
+ break `repo-14`, which pins 24.
108
108
  - **Reporting cross-repository node divergence as a finding.** Rejected by the Context. The
109
109
  plan's premise was wrong and the correction is recorded here so it is not re-derived from
110
110
  the plan text.
@@ -56,7 +56,7 @@ correcting at all.
56
56
  detectable by reading the file. Corrected in the row.
57
57
 
58
58
  5. **Two of the four configurations are plain JavaScript with no TypeScript in them,**
59
- `simt-api` and `simt-blog`. A "shared 6-rule ESLint block" cannot describe all four,
59
+ `repo-04` and `repo-06`. A "shared 6-rule ESLint block" cannot describe all four,
60
60
  because two of them have no rules to share — they differ in `ignores` and per-file
61
61
  `globals`, which are per-repository facts about paths.
62
62
 
@@ -67,7 +67,7 @@ correcting at all.
67
67
  may make on its own.
68
68
 
69
69
  7. **The fix for the two conflicts is one dependency each,** and it is not a shared block.
70
- Installing `eslint-config-prettier` in `simt-social` and `simt-social-api` and spreading
70
+ Installing `eslint-config-prettier` in `repo-14` and `repo-11` and spreading
71
71
  it last in the config array resolves both. That is a command per repository, run by the
72
72
  repositories, and simt-dev has no business writing it.
73
73
 
@@ -16,9 +16,9 @@ landed. Not intermittently: 22 of 543 tests failed on runs `37476671363` through
16
16
  Three separate causes, none of them recent:
17
17
 
18
18
  1. **`test/install.test.mjs:17` read the estate off the author's disk.**
19
- `const WORKSPACE = resolve(HERE, '..', '..')` is `/Users/said.elimam/code/simt`, and the
20
- install tests copied skill bodies out of it — `simt-social-api/.agents/skills/d1-safe-change`,
21
- and `singleRepoClone()` out of `simt-social`. On a runner the checkout is
19
+ `const WORKSPACE = resolve(HERE, '..', '..')` is `/home/developer/code/simt`, and the
20
+ install tests copied skill bodies out of it — `repo-11/.agents/skills/d1-safe-change`,
21
+ and `singleRepoClone()` out of `repo-14`. On a runner the checkout is
22
22
  `/home/runner/work/simt-dev/simt-dev`, so `../..` is `/home/runner/work/simt-dev`,
23
23
  which contains this repository and nothing else. The bodies were not there,
24
24
  `skills install` exited 3, and nineteen tests asserted on a refusal they never asked for.
@@ -25,7 +25,7 @@ Phase 6 asked to "collapse the 7 drifted Social skill twins to canonical + stub"
25
25
  answers the row directly. All three resolvers select `role === 'canonical'` and nothing else —
26
26
  `src/commands/skills.mjs` twice, `src/commands/skillsinstall.mjs:291`, `src/mcp/tools.mjs:192` — so
27
27
  a copy declared `duplicate` or `variant` is never installed. The one pair that genuinely had to be
28
- collapsed, `add-country-galaxy`, is already a stub in `simt-social` pointing at the API repo.
28
+ collapsed, `add-country-galaxy`, is already a stub in `repo-14` pointing at the API repo.
29
29
 
30
30
  Phase 7 asked for a sweep of hardcoded absolute paths. The sweep found 17 tracked files. Six were
31
31
  fixed. The two Android `local.properties` files were not, and were nearly fixed by reflex.
@@ -47,7 +47,7 @@ fixed. The two Android `local.properties` files were not, and were nearly fixed
47
47
  because resolution never read those bytes. Phase 6's twin collapse is therefore closed as
48
48
  already answered, at `a9983c8`.
49
49
 
50
- 3. **`local.properties` stays tracked.** Both `simt-app` and `wakt-app` commit it, and both
50
+ 3. **`local.properties` stays tracked.** Both `repo-05` and `repo-03` commit it, and both
51
51
  `.gitignore` files have the conventional `# local.properties` line commented out at lines 22 and
52
52
  24 — a deliberate override of Android's own convention, not an oversight. Untracking them would
53
53
  be a house-style change made while closing a plan, and a build machine that checks out without
@@ -74,8 +74,8 @@ unmade decision is not a gap to fill by invention.
74
74
 
75
75
  **Collapsing `graphify` and `parallel-feature-team`.** Both are declared `duplicate` with matching
76
76
  byte counts, so they look like the obvious candidates. `graphify` is `vendored: true` — an
77
- 84 534-byte, ten-file, version-pinned tool present in both Social repos on purpose. Deleting
78
- simt-social's copy would leave nothing to detect the loss, because resolution only ever read the
77
+ 84 534-byte, ten-file, version-pinned tool present in both one repository on purpose. Deleting
78
+ repo-14's copy would leave nothing to detect the loss, because resolution only ever read the
79
79
  canonical copy.
80
80
 
81
81
  **Untracking `local.properties`.** An Android SDK path is machine state, and two repos chose to
@@ -21,7 +21,7 @@ Two facts about this repository make that gap harder to notice than it looks. `t
21
21
  reach outside the checkout (ADR-031), and this package has no dependencies (ADR-012). Together
22
22
  those mean the suite has no installed copy of itself to test, and the natural workaround —
23
23
  borrowing a formatter from a neighbouring checkout — is the one ADR-031 rules out. Prettier 3.8.1
24
- is sitting in `../simt-blog/node_modules/.bin` and is not available here.
24
+ is sitting in `../repo-06/node_modules/.bin` and is not available here.
25
25
 
26
26
  ## Ruling
27
27
 
@@ -87,6 +87,6 @@ node, git and a POSIX shell; npm is in the same class. A conditional skip conver
87
87
  into an unverified one, which is the failure this file was written to stop.
88
88
 
89
89
  **Copying Prettier out of a sibling checkout.** It exists, it works, and ADR-031 rules it out for
90
- the same reason it rules out reading skill bodies from `../simt-social`: a suite that passes on
90
+ the same reason it rules out reading skill bodies from `../repo-14`: a suite that passes on
91
91
  the author's machine and fails on a runner with only this checkout is evidence about the author's
92
92
  directory.
@@ -16,13 +16,13 @@ Measured over the 16 repositories at `~/code/simt`:
16
16
  ```
17
17
  staleRuleFiles: 17
18
18
  repos naming stale rule files in problems: 0
19
- simt-app problems:
19
+ repo-05 problems:
20
20
  "the generated region was hand-edited; restore it with: simt-dev sync --yes"
21
21
  "core.hooksPath is \"hooks\", not the shared dispatcher; run: simt-dev hooks install --yes"
22
22
  ```
23
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
24
+ Five adopted repositories have stale rule files — `repo-05`, `repo-06` and `repo-14` at 0
25
+ of 3 current, `repo-11` and `repo-13` at 0 of 4 — and not one of them said so in
26
26
  its problems list. The summary already said `staleRuleFiles: 17`, because `summarise` has always
27
27
  counted them. So the dashboard was reporting a count of a defect it refused to call a defect.
28
28
 
@@ -14,11 +14,11 @@ Review 0001 §S12, marked P1 and "decision". Measured across the estate before a
14
14
  the five repositories selecting `opencode,cursor`:
15
15
 
16
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
17
+ repo-05 folded=3 .mdc=3 0/3 current
18
+ repo-06 folded=3 .mdc=3 0/3 current
19
+ repo-14 folded=3 .mdc=3 0/3 current
20
+ repo-11 folded=4 .mdc=4 0/4 current
21
+ repo-13 folded=4 .mdc=4 0/4 current
22
22
  ```
23
23
 
24
24
  Seventeen generated rule files. Every one of them byte-identical to the section already folded into
@@ -50,7 +50,7 @@ other `react-native`, and neither receives both.
50
50
  **Two.** The Android and uv facts name a path at an unknown depth, which the two existing kinds
51
51
  (`file`, `dir`) cannot express. A third kind, `glob`, walks at most `GLOB_DEPTH` directory levels
52
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
53
+ module's manifest at `<module>/src/main/AndroidManifest.xml`, and repo-05's sits at
54
54
  `app/src/main/AndroidManifest.xml`. A shallower bound makes `has-android-manifest` the shape S07
55
55
  found — a fact no repository could have. A deeper one is a scan rather than a probe, and
56
56
  ADR-013's promise is that adding an ecosystem is data, so a fact must not cost a search.
@@ -78,11 +78,11 @@ repository loses coverage:
78
78
 
79
79
  | Repo | Before | After |
80
80
  | --- | --- | --- |
81
- | `app-ui`, `simt-app`, `wakt-app` | `core, jvm-android` | `core, android, jvm` |
82
- | `bunnyland-server` | `container, core, python` | `container, core, python, uv` |
81
+ | `repo-01`, `repo-05`, `repo-03` | `core, jvm-android` | `core, android, jvm` |
82
+ | `repo-02` | `container, core, python` | `container, core, python, uv` |
83
83
 
84
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`
85
+ build rules; `repo-05` gaining `android` was the row's acceptance criterion. `repo-02`
86
86
  carries a committed `uv.lock`, so its gain is the defect repaired, not a new selection.
87
87
 
88
88
  The rename is breaking in one narrow way: the id `jvm-android` no longer exists. A consumer that
@@ -63,11 +63,11 @@ it.
63
63
 
64
64
  | Repository | Prettier config | States `singleQuote` | Adopting `quote_type = single` |
65
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** |
66
+ | `repo-04` | `.prettierrc` | `true` | no change |
67
+ | `repo-06` | `.prettierrc.json` | `true` | no change |
68
+ | `repo-15` | `.prettierrc.json` | `true` | no change |
69
+ | `repo-11` | `.prettierrc.json` | absent | **every source file reformatted** |
70
+ | `repo-14` | `.prettierrc.json` | absent | **every source file reformatted** |
71
71
 
72
72
  Two repositories would have every source file rewritten on the day they adopted the
73
73
  tool, in a commit whose message says it adopted a rules projection. The measurement is
@@ -0,0 +1,115 @@
1
+ # An identity is per-developer, and a repository does not own it
2
+
3
+ Status: **accepted** · 2026-10-11
4
+
5
+ Affects: `simt init`, `src/identity.mjs`, `test/identity.test.mjs`
6
+
7
+ ## Context
8
+
9
+ Two repositories in the estate carried a `git.config` file in their tracked files.
10
+ It holds a name and an email address:
11
+
12
+ ```ini
13
+ [user]
14
+ name = <one developer's name>
15
+ email = <that developer's email address>
16
+ ```
17
+
18
+ Both had a local `include.path` pointing at it, and both worked. Both were also
19
+ wrong in ways that had nothing to do with each other.
20
+
21
+ **It was a tracked file.** A repository has more than one person in it. Committed,
22
+ one developer's name and email is published to every other developer and to
23
+ everybody who can read the repository. This repository's own release review had
24
+ already said so, in the table of findings, as: *"`git.config` tracked at the root
25
+ with a personal name and email — Identity file in a public tree, read by nothing
26
+ — Remove it from the tree."* The finding was recorded and the file stayed.
27
+
28
+ **It pointed somewhere else.** Both `include.path` values named
29
+ `../../simt-dev/git.config` — another repository's identity file, resolved through
30
+ the git directory rather than the working tree. Including the git config's own
31
+ file from a relative path three directories up means one repository's commits
32
+ depend on a sibling checkout existing at an expected path, which fails the moment
33
+ anybody clones without it. It went unnoticed because every developer here has a
34
+ global identity configured, so the include resolved to the same person the
35
+ include would have skipped to, and the file it never read was the reason nothing
36
+ broke.
37
+
38
+ The interesting part is that these are opposite mistakes with the same root.
39
+ `git.config` is not the repository's to own, and treating it as the repository's
40
+ produced both: a file that should have been ignored got committed, and an
41
+ `include` that should have named the repository's own file named a stranger's.
42
+
43
+ ## Ruling
44
+
45
+ 1. **`simt init` writes `git.config` when the repository has none.** It is planned
46
+ like everything else `init` writes, so it appears in the same preview that lists
47
+ the rest, and its source is named on the line — writing somebody's name is a
48
+ statement about a person and the plan says where the name came from.
49
+
50
+ 2. **An existing `git.config` is left exactly as it is.** Not updated, not merged,
51
+ not reformatted. It may have been written by a person, by an older version of
52
+ this tool, or by hand years ago, and none of those are this tool's to
53
+ second-guess.
54
+
55
+ 3. **The identity is derived, in this order, and the first answer wins:**
56
+ the repository's **last commit author**, then the identity **git would use on
57
+ this machine anyway**, then nothing.
58
+
59
+ Last commit first because it is a fact rather than a default: it is who has
60
+ actually been working here. It is read from `git log`, not from
61
+ `git config user.email`, because at that moment the configured value is what the
62
+ file is about to be derived from, and the step would be circular.
63
+
64
+ The second is `--get` rather than `--global`, so it resolves through git's own
65
+ precedence and returns what a commit would have used had this file never
66
+ existed. That is the fallback's whole point: it must not be a second opinion
67
+ about what identity means.
68
+
69
+ 4. **When nothing is available, no file is written.** A placeholder is an identity
70
+ git would use on the next commit, and an adopting command is the last thing that
71
+ should invent one. The refusal is printed with its reason rather than swallowed.
72
+
73
+ 5. **Only the keys that were found are written.** A partial identity is written as a
74
+ partial identity, for the same reason.
75
+
76
+ 6. **`init` adds `git.config` to `.gitignore`** when it creates one, and points
77
+ `include.path` at `../git.config` — the repository's own file.
78
+
79
+ `--replace-all`, not `--add`: a value naming *another* repository's identity file
80
+ is not an addition to keep, it is a link to break.
81
+
82
+ A repository that already *tracks* `git.config` is not untracked by `init`.
83
+ Removing a tracked file is not something an adopting command should do silently,
84
+ and doing it would be a change to that repository's history rather than to its
85
+ working tree.
86
+
87
+ ## Consequences
88
+
89
+ `src/identity.mjs` is new and is the single answer to "whose identity is this, and
90
+ may we write it". `init` plans it, writes it, gitignores it and repoints the
91
+ include; the four refusals are asserted in `test/identity.test.mjs` rather than
92
+ described, because each of them is the correct answer rather than a failure to try
93
+ harder.
94
+
95
+ One test scrubs `HOME` and `GIT_CONFIG_GLOBAL` before asserting the "nothing
96
+ anywhere" case. That is not tidiness: a developer with a global `user.email` — which
97
+ is most of them — makes the identity resolvable, so without scrubbing the test
98
+ would pass for the wrong reason on that machine and nowhere else. ADR-031 is about
99
+ a result depending on the author's environment; this test would have been the next
100
+ instance of it.
101
+
102
+ ## Rejected
103
+
104
+ - **Write `git.config` into the tracked tree, as a shared record of who works
105
+ here.** This is what the two repositories did, and it is a publication of a
106
+ person's email address with a commit attached.
107
+ - **Take the identity from the OS (the login name, the home directory).** It
108
+ produces `<login-name>@example.com` and similar: an identity nobody configured,
109
+ used for authorship. Ruling 4's refusal covers it.
110
+ - **Overwrite an existing `git.config` when it differs from the derived one.**
111
+ Difference is not error. A file that has been there for years and disagrees with
112
+ the last commit author is a decision somebody made.
113
+ - **Fix the `include` only when it is wrong.** The include is only written when it
114
+ is missing or does not name this repository's own file. Rewriting it when it is
115
+ already correct would be a no-op that reads as a change.
@@ -0,0 +1,148 @@
1
+ # One tool, three channels, and the order is forced
2
+
3
+ Status: **accepted** · 2026-10-11
4
+
5
+ Affects: `pyproject.toml`, `python/simt_dev/`, `Formula/simt-dev.rb`,
6
+ `.github/workflows/publish.yml`, `scripts/release-formula.mjs`,
7
+ `scripts/verify-wheel.mjs`, `test/packaging.test.mjs`
8
+
9
+ ## Context
10
+
11
+ simt-dev is a Node program. It is also a *repository* tool: somebody adopting a
12
+ repository runs `simt init` inside it, and the places people run that from are
13
+ not all Node projects. A python developer in a repository that has no
14
+ `package.json` has no reason to install a package manager to get a command that
15
+ adopts their repository, and a Homebrew user has no reason to install Node to
16
+ install a tool that needs Node.
17
+
18
+ So the tool is published to three channels:
19
+
20
+ | Channel | What it installs |
21
+ | --- | --- |
22
+ | npm | a tarball of `.mjs` files |
23
+ | PyPI | a wheel carrying those same files, plus a `simt` entry point |
24
+ | Homebrew | a formula that downloads the npm tarball at install time |
25
+
26
+ The design constraint that decided all of it: the tool has **zero runtime
27
+ dependencies and no build step**. The whole thing is ~1.3 MB of text. That makes
28
+ the "packaging for a second language" problem a *copying* problem rather than a
29
+ build one, and copying is a problem with a right answer.
30
+
31
+ ## Ruling
32
+
33
+ 1. **The Python wheel carries the tool's own source, byte for byte.** `files` in
34
+ `package.json` and `force-include` in `pyproject.toml` name the same paths,
35
+ and `scripts/verify-wheel.mjs` builds both artefacts and hashes every file in
36
+ both, failing the release when a single byte differs.
37
+
38
+ The wheel is not a wrapper around `npm install`. A wrapper would fetch code
39
+ at install time - unpinned, unsigned by anything the installer can check, and
40
+ absent the moment somebody runs `pip install` on a train.
41
+
42
+ 2. **The version is one number, read rather than written.**
43
+ `pyproject.toml` declares `dynamic = ["version"]` and reads it from
44
+ `package.json` with a regex. A second literal would drift, and the drift
45
+ would be invisible until somebody ran `pip show` beside `simt --version` and
46
+ got two answers about which release they are running.
47
+
48
+ 3. **The Python launcher is standard library only, and has no required
49
+ dependencies.** The `[node]` extra is optional and imports `nodejs_wheel`
50
+ *inside a `try`, at the point of use* - never at module level - so the
51
+ default install cannot fail on a package it never asked for.
52
+
53
+ ADR-012 rules out runtime dependencies for the npm package. The reasoning
54
+ transfers without modification: a dependency is a runtime this package cannot
55
+ verify and its owner cannot patch, and this package exists to be the thing
56
+ people run in a repository rather than the thing they have to curate.
57
+
58
+ 4. **`simt` is the one command, on every channel.** `bin` in `package.json` and
59
+ `[project.scripts]` in `pyproject.toml` are asserted equal, and the formula's
60
+ `test do` block runs it. A second name under the package name would be a
61
+ convenience that forks the documentation the first time somebody documents
62
+ one channel and not the other.
63
+
64
+ 5. **Homebrew installs the npm tarball, and does not use `std_npm_args`.**
65
+ `depends_on "node"` declares the runtime; the entry point's shebang is
66
+ rewritten to that node rather than left at `#!/usr/bin/env node`, so the
67
+ command works from a cron job and from a shell that has moved node out of
68
+ the way, both of which look identical to a broken install.
69
+
70
+ `std_npm_args` invokes npm at build time, which needs the registry at build
71
+ time. This package has nothing to install, so that would add a network fetch
72
+ to installing the one thing in the estate that provably needs none.
73
+
74
+ 6. **`Formula/simt-dev.rb` is the source of truth, and `scripts/release-formula.mjs`
75
+ patches two lines in it.** It does not regenerate the formula from a
76
+ template: a template would mean the bytes a reader sees in this repository
77
+ are not the bytes a tap serves, which is the defect ADR-023 ruled out for the
78
+ gate file. The two lines are matched by content, not by line number, because
79
+ a line number would make the formula a file that cannot be edited - adding a
80
+ comment above the `sha256` line would silently write the checksum to the
81
+ wrong place.
82
+
83
+ 7. **The npm version must be live before `publish-pypi` and `publish-brew`
84
+ succeed, and both jobs say so when it is not.** The order is not a
85
+ preference. Homebrew downloads from the npm registry at install time, so a
86
+ formula naming a version that is still in npm's staging area installs a
87
+ 404 - on somebody else's machine, at a tap, from a release this workflow
88
+ already declared done.
89
+
90
+ Those two jobs are the ones a maintainer runs again after
91
+ `npm stage approve`. "Re-run failed jobs" carries the tag context, which a
92
+ `workflow_dispatch` does not: on a dispatch, `github.ref_name` is the branch,
93
+ and the version guard would compare a tag against a branch.
94
+
95
+ ## Consequences
96
+
97
+ The invariants split in two, and the split is deliberate.
98
+
99
+ `test/packaging.test.mjs` holds the **static** ones: that `files` and
100
+ `force-include` name the same paths, that the version is not written twice, that
101
+ the command is one name, that the launcher imports nothing outside the standard
102
+ library, that no second build system sits at the repository root. Every one of
103
+ those is checkable from this checkout with no network and no interpreter, which
104
+ is what lets it run in `npm test`.
105
+
106
+ `scripts/verify-wheel.mjs` holds the one that is not: that the paths, once
107
+ built, contain the same bytes. It runs in the release workflow, where a Python
108
+ and a registry are available by construction.
109
+
110
+ That split is ADR-031 applied. A test that needed `pip` would not run for the
111
+ next person who clones this repository, and would run differently on the
112
+ machine that wrote it - which is the failure that suite is about.
113
+
114
+ Two findings from building it are recorded because they are the kind that only
115
+ appear when a check is real:
116
+
117
+ - The first run of `verify-wheel.mjs` reported `LICENSE` and `README.md` as
118
+ differences. They are not defects: npm adds both to every tarball, and the
119
+ wheel carries them as *distribution metadata* rather than as files. The
120
+ comparison is now scoped to what `files` names, which is the content the tool
121
+ reads.
122
+ - The first run of that same comparison reported every one of `bin`, `src`,
123
+ `graph` and `rules` as missing from both, because `files` names **directories**
124
+ and npm includes them recursively. The expansion happens in the script now,
125
+ with the reason stated where the code is.
126
+
127
+ The check was also tampered with deliberately - a wheel-only `README.md` was
128
+ injected into `force-include` and it failed - because a parity check that has
129
+ never failed is not evidence that it works.
130
+
131
+ ## Rejected
132
+
133
+ - **A Python wrapper that runs `npm install -g simt-dev` on first use.** This is
134
+ what the unofficial `pyright` package does, and it has the properties it is
135
+ reputed to have: a download on first run, unpinned, uncacheable, and a failure
136
+ mode that reads as a broken package rather than as a missing runtime.
137
+ - **Requiring node, with no bundled fallback.** The reader who installs from
138
+ PyPI is frequently the reader with no node, which is the whole reason the
139
+ channel exists. The failure message therefore names the `[node]` extra first,
140
+ because it is the one remedy that is the same command on every platform.
141
+ - **Guessing where node is.** A hardcoded list of `/opt/homebrew/bin/node`,
142
+ `/usr/local/bin/node` and `~/.nvm/...` is three guesses and a surprise: it
143
+ prefers a guess over the reader's own `PATH`, and it is already out of date
144
+ for anyone who installs node differently. `shutil.which` asks the question
145
+ instead of answering it.
146
+ - **`std_npm_args`.** See ruling 5.
147
+ - **Publishing all three channels simultaneously.** Rejected by the ordering in
148
+ ruling 7 - Homebrew cannot name a version npm has not released.
@@ -64,6 +64,8 @@ record's status line — never by editing the ruling in place.
64
64
  | [054](ADR-054-the-digest-covers-every-byte-that-leaves-the-package.md) | The digest covers every byte that leaves the package, and a cause is read off the body | accepted · amends ADR-032 |
65
65
  | [055](ADR-055-a-status-line-travels-so-it-may-not-name-a-location.md) | A status line travels, so it may not name a location; prose is the record's own argument | accepted |
66
66
  | [056](ADR-056-three-names-one-of-which-is-cheap-to-move.md) | The command, the package and the project are three names, and only one of them is cheap to move | accepted · amends ADR-023 |
67
+ | [057](ADR-057-an-identity-is-per-developer-and-a-repository-does-not-own-it.md) | An identity is per-developer, and a repository does not own it | accepted |
68
+ | [058](ADR-058-one-tool-three-channels-and-the-order-is-forced.md) | One tool, three channels, and the order is forced | accepted |
67
69
 
68
70
  ADR-001 to ADR-010 resolve the contradictions found across the 16 repos in the 2026-10-05
69
71
  audit; each record names the repos whose text it replaces. ADR-011 and ADR-012 are the
@@ -418,7 +420,7 @@ ADR-018's guard could not see the default hooks directory because it looked only
418
420
  key, and `hooks status` carried its own copy of the read logic the shared module already had.
419
421
 
420
422
  A fourth joins them. ADR-019 is the same shape one layer over: `simt init` dropped all
421
- five gates on `simt-app` and told the reader to add a script to a `package.json` the
423
+ five gates on `repo-05` and told the reader to add a script to a `package.json` the
422
424
  repository will never have. The facts were right, the remedy was not followable, and once
423
425
  the repository declared what it does have, the dispatcher, `check` and `hooks status`
424
426
  gave three different answers about one manifest. Every one of these shipped green because
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "simt-dev",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Shared agent rules, skills and git hooks for a repository: one command adopts a repo, and every gate it declares is probed before it is written.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Said Elimam",
@@ -39,7 +39,5 @@
39
39
  "scripts": {
40
40
  "test": "node --test test/*.test.mjs",
41
41
  "lint": "node bin/simt-dev.mjs doctor"
42
- },
43
- "dependencies": {},
44
- "devDependencies": {}
45
- }
42
+ }
43
+ }
@@ -6,11 +6,11 @@
6
6
  //
7
7
  // Installing is the only mutating command, it is idempotent, it previews unless
8
8
  // told to write, and it refuses to clobber an existing core.hooksPath without
9
- // --yes. The estate has four incompatible conventions and one of them (simt-app)
10
- // points at a directory whose pre-push is 0 bytes, so overwriting silently would
11
- // be the worst available outcome. simt-space keeps its hook in the DEFAULT
12
- // location, which core.hooksPath being unset does not reveal, so a foreign hook
13
- // is found by asking git rather than by reading config.
9
+ // --yes. Repositories in the wild use incompatible conventions, including a
10
+ // hooksPath pointing at a directory whose pre-push is 0 bytes, so overwriting
11
+ // silently would be the worst available outcome. Others keep their hook in the
12
+ // DEFAULT location, which core.hooksPath being unset does not reveal, so a
13
+ // foreign hook is found by asking git rather than by reading config.
14
14
 
15
15
  import { execFile } from 'node:child_process';
16
16
  import { chmod, mkdir, readFile, realpath, writeFile } from 'node:fs/promises';
@@ -169,19 +169,33 @@ async function installHooks(root, flags) {
169
169
  // stop running - and they stop running silently, which is why they are planned here
170
170
  // rather than left to the operator to notice (ADR-018, as amended).
171
171
  //
172
- // A gate-type foreign hook is deliberately NOT chained: it belongs in the manifest
173
- // as a declared command, so the dispatcher runs it and its verdict is visible in the
174
- // gate output. Chaining it would run it after a gate passed, outside the report.
172
+ // "Does not gate" is a fact about THIS repository: `gateCount` asks what its manifest
173
+ // declares for that event, so an event outside the known vocabulary (post-commit) and
174
+ // an event the repo simply leaves empty (a pre-push with no gates) are both chained,
175
+ // while an event the repo does gate is left to the manifest's gates.commands - there
176
+ // the hook runs as a gate and its verdict appears in the report beside the others,
177
+ // rather than after the gates passed where an author cannot see it.
178
+ //
179
+ // Keying this on the event vocabulary instead of on the manifest cost a repository
180
+ // git-lfs hook: pre-push is a gated event in general, but this repo declares no
181
+ // pre-push gate, so nothing replaces its hook and routing would have dropped it.
175
182
  const chained = [
176
183
  // Already chained by an earlier install, read back off disk: re-rendered from the
177
184
  // file's own contents so the bytes cannot drift on a second run.
178
185
  ...(await Promise.all(
179
186
  state.chained.map(async (c) => ({ event: c.event, target: await relativeTarget(root, c.target) })),
180
187
  )),
181
- // Newly found foreign hooks on events this tool does not gate.
188
+ // Foreign hooks on events THIS repo has no gate for.
189
+ //
190
+ // Per repository, not per event vocabulary - and the difference is not academic.
191
+ // declares no pre-push gate, and its pre-push is git-lfs: routing without a
192
+ // shim for that event means git finds no pre-push hook at all and LFS objects stop
193
+ // being uploaded, silently, with nothing to notice it. An event this repo gates is
194
+ // different: the dispatcher replaces the hook there by design, and the hook's real
195
+ // place is gates.commands.
182
196
  ...(await Promise.all(
183
197
  state.foreign
184
- .filter((f) => !EVENTS.includes(f.event))
198
+ .filter((f) => gateCount(manifest, f.event) === 0)
185
199
  .map(async (f) => ({ event: f.event, target: await relativeTarget(root, f.path) })),
186
200
  )),
187
201
  ].reduce((all, c) => (all.some((x) => x.event === c.event) ? all : [...all, c]), []);
@@ -214,9 +228,9 @@ async function installHooks(root, flags) {
214
228
 
215
229
  // Refuse to take over routing git is already using without explicit consent.
216
230
  // Two cases, and the second one is the one that bites: core.hooksPath already
217
- // pointing elsewhere (simt-app points at a directory whose pre-push is 0 bytes),
218
- // and core.hooksPath being unset while a hook sits in the default location
219
- // (simt-space, 4 KB of real logic in .git/hooks/pre-commit). Reading unset as
231
+ // pointing elsewhere (a hooksPath whose pre-push is 0 bytes), and core.hooksPath
232
+ // being unset while a hook sits in the default location (4 KB of real logic in
233
+ // .git/hooks/pre-commit). Reading unset as
220
234
  // "no hooks" is what let install replace a working gate with no warning.
221
235
  const takeover = (current !== null && current !== wants) || state.foreign.length > 0;
222
236
  if (takeover && !flags.yes) {