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.
- package/CHANGELOG.md +36 -0
- package/README.md +31 -4
- package/docs/decisions/ADR-001-push-requires-approval.md +5 -5
- package/docs/decisions/ADR-002-refactor-blast-radius.md +1 -1
- package/docs/decisions/ADR-003-test-breadth.md +4 -4
- package/docs/decisions/ADR-004-hook-owns-format.md +5 -5
- package/docs/decisions/ADR-005-file-length.md +5 -5
- package/docs/decisions/ADR-006-coverage-bar.md +3 -3
- package/docs/decisions/ADR-007-section-skeleton.md +5 -5
- package/docs/decisions/ADR-008-verdict-vocabulary.md +1 -1
- package/docs/decisions/ADR-009-skill-layout.md +1 -1
- package/docs/decisions/ADR-010-runner-invocation.md +1 -1
- package/docs/decisions/ADR-012-drop-windows-support.md +2 -2
- package/docs/decisions/ADR-015-a-source-is-content-not-a-location.md +2 -2
- package/docs/decisions/ADR-016-a-divergent-rule-is-retired-not-merged.md +13 -13
- package/docs/decisions/ADR-017-a-declared-gate-must-be-runnable.md +2 -2
- package/docs/decisions/ADR-018-an-unset-hooks-path-is-not-an-absent-hook.md +9 -9
- package/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +7 -7
- package/docs/decisions/ADR-023-the-gate-ships-as-a-file-the-repo-owns.md +2 -2
- package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +5 -5
- package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +2 -2
- package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
- package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +2 -2
- package/docs/decisions/ADR-031-the-suite-stages-the-environment-it-needs.md +3 -3
- package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +4 -4
- package/docs/decisions/ADR-041-the-suite-installs-the-package-in-order-to-test-it.md +2 -2
- package/docs/decisions/ADR-045-a-dashboard-that-counts-a-defect.md +3 -3
- package/docs/decisions/ADR-046-one-rule-one-copy.md +5 -5
- package/docs/decisions/ADR-047-a-profile-selects-on-what-a-repository-runs-on.md +4 -4
- package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +5 -5
- package/docs/decisions/ADR-057-an-identity-is-per-developer-and-a-repository-does-not-own-it.md +115 -0
- package/docs/decisions/ADR-058-one-tool-three-channels-and-the-order-is-forced.md +148 -0
- package/docs/decisions/README.md +3 -1
- package/package.json +1 -1
- package/src/commands/hooks.mjs +10 -10
- package/src/commands/init.mjs +67 -1
- package/src/commands/skillsinstall.mjs +1 -1
- package/src/compare/discover.mjs +1 -1
- package/src/compare/row.mjs +2 -2
- package/src/decisions/index.mjs +30 -6
- package/src/graph/load.mjs +1 -1
- package/src/hooks/capability.mjs +1 -1
- package/src/hooks/dispatcher.mjs +1 -1
- package/src/hooks/gates.mjs +1 -1
- package/src/hooks/where.mjs +1 -1
- package/src/identity.mjs +218 -0
- package/src/projection/baselines.mjs +1 -1
- package/src/projection/conflicts.mjs +1 -1
- package/src/projection/manifest.mjs +1 -1
- package/src/projection/markers.mjs +3 -3
- package/src/projection/mdc.mjs +2 -2
- package/src/projection/surfaces.mjs +2 -2
- package/src/resolve/fingerprint.mjs +3 -3
- package/src/resolve/source.mjs +1 -1
- 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
|
-
`
|
|
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 `
|
|
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 `/
|
|
20
|
-
install tests copied skill bodies out of it — `
|
|
21
|
-
and `singleRepoClone()` out of `
|
|
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 `
|
|
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 `
|
|
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
|
|
78
|
-
|
|
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 `../
|
|
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 `../
|
|
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
|
-
|
|
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 — `
|
|
25
|
-
of 3 current, `
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
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
|
-
| `
|
|
82
|
-
| `
|
|
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; `
|
|
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
|
-
| `
|
|
67
|
-
| `
|
|
68
|
-
| `
|
|
69
|
-
| `
|
|
70
|
-
| `
|
|
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
|
package/docs/decisions/ADR-057-an-identity-is-per-developer-and-a-repository-does-not-own-it.md
ADDED
|
@@ -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.
|
package/docs/decisions/README.md
CHANGED
|
@@ -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 `
|
|
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.
|
|
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",
|
package/src/commands/hooks.mjs
CHANGED
|
@@ -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.
|
|
10
|
-
//
|
|
11
|
-
// be the worst available outcome.
|
|
12
|
-
// location, which core.hooksPath being unset does not reveal, so a
|
|
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
|
|
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
|
-
//
|
|
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 (
|
|
232
|
-
//
|
|
233
|
-
//
|
|
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) {
|
package/src/commands/init.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
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) {
|
package/src/compare/discover.mjs
CHANGED
|
@@ -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.
|
|
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
|
//
|