simt-dev 0.1.1 → 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 +36 -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 +9 -9
  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 +1 -1
  35. package/src/commands/hooks.mjs +10 -10
  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
@@ -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.1",
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",
@@ -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';
@@ -176,7 +176,7 @@ async function installHooks(root, flags) {
176
176
  // the hook runs as a gate and its verdict appears in the report beside the others,
177
177
  // rather than after the gates passed where an author cannot see it.
178
178
  //
179
- // Keying this on the event vocabulary instead of on the manifest cost simt-app its
179
+ // Keying this on the event vocabulary instead of on the manifest cost a repository
180
180
  // git-lfs hook: pre-push is a gated event in general, but this repo declares no
181
181
  // pre-push gate, so nothing replaces its hook and routing would have dropped it.
182
182
  const chained = [
@@ -188,7 +188,7 @@ async function installHooks(root, flags) {
188
188
  // Foreign hooks on events THIS repo has no gate for.
189
189
  //
190
190
  // Per repository, not per event vocabulary - and the difference is not academic.
191
- // simt-app declares no pre-push gate, and its pre-push is git-lfs: routing without a
191
+ // declares no pre-push gate, and its pre-push is git-lfs: routing without a
192
192
  // shim for that event means git finds no pre-push hook at all and LFS objects stop
193
193
  // being uploaded, silently, with nothing to notice it. An event this repo gates is
194
194
  // different: the dispatcher replaces the hook there by design, and the hook's real
@@ -228,9 +228,9 @@ async function installHooks(root, flags) {
228
228
 
229
229
  // Refuse to take over routing git is already using without explicit consent.
230
230
  // Two cases, and the second one is the one that bites: core.hooksPath already
231
- // pointing elsewhere (simt-app points at a directory whose pre-push is 0 bytes),
232
- // and core.hooksPath being unset while a hook sits in the default location
233
- // (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
234
234
  // "no hooks" is what let install replace a working gate with no warning.
235
235
  const takeover = (current !== null && current !== wants) || state.foreign.length > 0;
236
236
  if (takeover && !flags.yes) {
@@ -29,6 +29,15 @@ import { readHooksState } from '../compare/hooksstate.mjs';
29
29
  import { planWorkflow, renderWorkflow, WORKFLOWS_DIR } from '../ci/workflow.mjs';
30
30
  import { planDependabot, renderDependabot } from '../ci/dependabot.mjs';
31
31
  import { planTsconfigBase, renderTsconfigBase, TSCONFIG_BASE_FILE } from '../tsconfig.mjs';
32
+ import {
33
+ IDENTITY_FILE,
34
+ INCLUDE_KEY,
35
+ ignoreIdentity,
36
+ includeOwnIdentity,
37
+ includesOwnIdentity,
38
+ renderIdentityFile,
39
+ resolveIdentity,
40
+ } from '../identity.mjs';
32
41
  import { pointerState, pointerTargets } from '../projection/project.mjs';
33
42
  import { surfacesLine } from '../projection/surfaces.mjs';
34
43
  import { plan, provenanceDigest, PRIMARY_TARGET, LOCAL_PROFILE_DIR } from '../projection/project.mjs';
@@ -58,7 +67,7 @@ export async function initCommand(positional, flags) {
58
67
 
59
68
  // What this repo can demonstrably run, not what its stack suggests. Without this the
60
69
  // stack table proposes gates the repo has no way to execute — the adoption of
61
- // simt-social-media declared five and could run two, which would have blocked every
70
+ // a repository declared five and could run two, which would have blocked every
62
71
  // commit once hooks were installed.
63
72
  const capability = await readCapability(root);
64
73
  const { dropped } = planGates(detected.stack, capability);
@@ -98,6 +107,26 @@ export async function initCommand(positional, flags) {
98
107
  const ci = await planWorkflow(root);
99
108
  const dependabot = await planDependabot(root);
100
109
  const tsconfigBase = await planTsconfigBase(root);
110
+ // Per-developer, and planned with the rest so the operator sees it in the same
111
+ // preview that lists everything else. `exists` short-circuits the write: a
112
+ // repository that already has one keeps it exactly as it is.
113
+ const identity = await resolveIdentity(root);
114
+ const identityPlan = {
115
+ file: IDENTITY_FILE,
116
+ action: identity.exists ? 'unchanged' : identity.from === null ? 'skip' : 'created',
117
+ from: identity.from,
118
+ name: identity.name,
119
+ email: identity.email,
120
+ reason: identity.exists
121
+ ? `${IDENTITY_FILE} already exists and is left exactly as it is`
122
+ : identity.from === null
123
+ ? 'no identity is configured anywhere, and this tool does not invent one'
124
+ : null,
125
+ // Only worth fixing when this repository is the one being routed: an include
126
+ // pointing at another repository's identity file works here and breaks for the
127
+ // next person who clones without that sibling checkout.
128
+ include: await includesOwnIdentity(root),
129
+ };
101
130
 
102
131
  const payload = {
103
132
  ok: summary.conflicts === 0 || flags.acceptConflicts === true,
@@ -117,6 +146,7 @@ export async function initCommand(positional, flags) {
117
146
  pointers: pointerPlan.map((p) => ({ file: p.file, to: p.to, readers: p.readers, state: p.state })),
118
147
  pointersBlocked: pointerBlocked,
119
148
  ci: { file: ci.file, action: ci.action, bytes: ci.bytes, own: ci.own, reason: ci.reason },
149
+ identity: identityPlan,
120
150
  dependabot: {
121
151
  file: dependabot.file,
122
152
  action: dependabot.action,
@@ -239,6 +269,30 @@ export async function initCommand(positional, flags) {
239
269
  await writeManifest(root, manifest);
240
270
  note(`wrote ${MANIFEST_FILE} (${manifestAction})`);
241
271
 
272
+ // Identity last, and for the same reason the gate is written after the manifest:
273
+ // the things that change what this repository does come first, and the one that
274
+ // only records who is doing them comes once there is something to be committing.
275
+ //
276
+ // Three separate refusals, each of which is the correct answer rather than a
277
+ // failure to try harder. An existing file is not this tool's to rewrite. An
278
+ // unconfigured identity is not one to invent - a placeholder is an identity git
279
+ // would use. And the include is only repointed when it is missing or wrong,
280
+ // because replacing it when it is already right would be a no-op that reads as
281
+ // a change.
282
+ if (payload.identity.action === 'created') {
283
+ await writeFile(join(root, IDENTITY_FILE), renderIdentityFile(identity));
284
+ await ignoreIdentity(root);
285
+ note(`wrote ${IDENTITY_FILE} (${identity.from === 'last-commit' ? 'from the last commit in this repository' : 'from the identity configured on this machine'}); gitignored, because it is yours and not the repository's`);
286
+ } else if (payload.identity.action === 'unchanged') {
287
+ note(`kept ${IDENTITY_FILE} (${payload.identity.reason})`);
288
+ } else {
289
+ note(`skipped ${IDENTITY_FILE} ${payload.identity.reason}`);
290
+ }
291
+ if (!payload.identity.include) {
292
+ await includeOwnIdentity(root);
293
+ note(`set ${INCLUDE_KEY} = ../${IDENTITY_FILE} this repository's own identity file`);
294
+ }
295
+
242
296
  // Written after the manifest so the gate exists in the same adoption that declares
243
297
  // what the gates are. Skipped with its reason printed rather than silently: a repo
244
298
  // with no `.github/workflows` has no CI surface to attach to, and inventing one
@@ -444,6 +498,18 @@ function render(payload, manifest) {
444
498
  `coverage=${manifest.limits.coverage ?? 'undeclared (ADR-006)'} ` +
445
499
  `fileLines=${manifest.limits.fileLines.warn}/${manifest.limits.fileLines.error} (ADR-005)`,
446
500
  );
501
+ // The identity line, with its source named. "Created" is a statement about somebody's
502
+ // name landing in a file, so where the name came from is not a detail of the line.
503
+ const id = payload.identity;
504
+ if (id) {
505
+ const verb = id.action === 'skip' ? 'skip' : id.action;
506
+ const detail = id.action === 'unchanged'
507
+ ? id.reason
508
+ : id.action === 'skip'
509
+ ? id.reason
510
+ : `${id.name} <${id.email}> from ${id.from === 'last-commit' ? "this repository's last commit" : 'the identity configured on this machine'}`;
511
+ lines.push(` ${verb.padEnd(9)} ${id.file}${detail ? `\n ${detail}` : ''}`);
512
+ }
447
513
  // Every gate the stack suggested and this repo cannot run is named, with the reason.
448
514
  // A silently shorter gate list reads as "this repo needs no formatting check", when
449
515
  // the truth is "declaring one would block every commit". The remedy is stated because
@@ -39,7 +39,7 @@ import { MANIFEST_FILE } from '../name.mjs';
39
39
  /**
40
40
  * Where skills live in this repo. The estate already has a convention - a
41
41
  * canonical directory plus symlink fan-out - and it is not the same directory in
42
- * every repo (`.agents/skills` in five, `.cursor/skills` in simt-app). So the
42
+ * every repo (`.agents/skills` in some, `.cursor/skills` in others). So the
43
43
  * existing layout is respected and only invented when there is none.
44
44
  */
45
45
  export function skillsDir(root, entries) {
@@ -12,7 +12,7 @@
12
12
  // so it must be attributed to its parent or a projection drift gets reported
13
13
  // against the wrong directory.
14
14
  // 3. Directories that are not repos are not candidates, but they are still
15
- // COUNTED. `simt-space-review` is 25 PNG screenshots; silently dropping it
15
+ // COUNTED. A directory of 25 PNG screenshots is not a rule source; silently dropping it
16
16
  // hides the fact that a directory in the workspace is unmanaged, which is a
17
17
  // real finding during a migration.
18
18
  //