simt-dev 0.2.0__py3-none-any.whl

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 (138) hide show
  1. python/simt_dev/__init__.py +32 -0
  2. python/simt_dev/cli.py +225 -0
  3. simt_dev/__init__.py +32 -0
  4. simt_dev/cli.py +225 -0
  5. simt_dev/vendored/CHANGELOG.md +166 -0
  6. simt_dev/vendored/bin/simt-dev.mjs +28 -0
  7. simt_dev/vendored/docs/decisions/ADR-001-push-requires-approval.md +42 -0
  8. simt_dev/vendored/docs/decisions/ADR-002-refactor-blast-radius.md +45 -0
  9. simt_dev/vendored/docs/decisions/ADR-003-test-breadth.md +46 -0
  10. simt_dev/vendored/docs/decisions/ADR-004-hook-owns-format.md +45 -0
  11. simt_dev/vendored/docs/decisions/ADR-005-file-length.md +42 -0
  12. simt_dev/vendored/docs/decisions/ADR-006-coverage-bar.md +59 -0
  13. simt_dev/vendored/docs/decisions/ADR-007-section-skeleton.md +54 -0
  14. simt_dev/vendored/docs/decisions/ADR-008-verdict-vocabulary.md +59 -0
  15. simt_dev/vendored/docs/decisions/ADR-009-skill-layout.md +48 -0
  16. simt_dev/vendored/docs/decisions/ADR-010-runner-invocation.md +56 -0
  17. simt_dev/vendored/docs/decisions/ADR-011-windows-support.md +52 -0
  18. simt_dev/vendored/docs/decisions/ADR-012-drop-windows-support.md +59 -0
  19. simt_dev/vendored/docs/decisions/ADR-013-genericity-by-facts-and-local-profiles.md +114 -0
  20. simt_dev/vendored/docs/decisions/ADR-014-workspace-is-configuration.md +211 -0
  21. simt_dev/vendored/docs/decisions/ADR-015-a-source-is-content-not-a-location.md +117 -0
  22. simt_dev/vendored/docs/decisions/ADR-016-a-divergent-rule-is-retired-not-merged.md +180 -0
  23. simt_dev/vendored/docs/decisions/ADR-017-a-declared-gate-must-be-runnable.md +89 -0
  24. simt_dev/vendored/docs/decisions/ADR-018-an-unset-hooks-path-is-not-an-absent-hook.md +183 -0
  25. simt_dev/vendored/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +110 -0
  26. simt_dev/vendored/docs/decisions/ADR-020-a-rule-is-written-per-surface-and-pointed-not-copied.md +100 -0
  27. simt_dev/vendored/docs/decisions/ADR-021-one-surface-per-repo.md +94 -0
  28. simt_dev/vendored/docs/decisions/ADR-022-a-key-simt-dev-reads-is-prefixed.md +98 -0
  29. simt_dev/vendored/docs/decisions/ADR-023-the-gate-ships-as-a-file-the-repo-owns.md +106 -0
  30. simt_dev/vendored/docs/decisions/ADR-024-simt-dev-overwrites-its-own-gate-file.md +111 -0
  31. simt_dev/vendored/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +153 -0
  32. simt_dev/vendored/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +117 -0
  33. simt_dev/vendored/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +130 -0
  34. simt_dev/vendored/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +131 -0
  35. simt_dev/vendored/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +120 -0
  36. simt_dev/vendored/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +114 -0
  37. simt_dev/vendored/docs/decisions/ADR-031-the-suite-stages-the-environment-it-needs.md +129 -0
  38. simt_dev/vendored/docs/decisions/ADR-032-provenance-is-a-content-digest-not-a-commit.md +141 -0
  39. simt_dev/vendored/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +82 -0
  40. simt_dev/vendored/docs/decisions/ADR-034-a-staged-tool-refuses-a-partially-staged-file.md +91 -0
  41. simt_dev/vendored/docs/decisions/ADR-035-one-write-decision-and-one-json-document.md +119 -0
  42. simt_dev/vendored/docs/decisions/ADR-036-a-writer-that-writes-says-so-and-the-instructions-naming-it-move-with-it.md +103 -0
  43. simt_dev/vendored/docs/decisions/ADR-037-unchanged-means-writing-would-not-change-a-byte.md +100 -0
  44. simt_dev/vendored/docs/decisions/ADR-038-the-gate-filter-belongs-to-every-manifest-writer.md +95 -0
  45. simt_dev/vendored/docs/decisions/ADR-039-a-ci-step-runs-a-form-that-can-fail.md +101 -0
  46. simt_dev/vendored/docs/decisions/ADR-040-the-last-writer-that-wrote-by-default-was-not-the-one-named.md +158 -0
  47. simt_dev/vendored/docs/decisions/ADR-041-the-suite-installs-the-package-in-order-to-test-it.md +92 -0
  48. simt_dev/vendored/docs/decisions/ADR-042-a-declared-glob-must-match-a-path-built-from-it.md +94 -0
  49. simt_dev/vendored/docs/decisions/ADR-043-a-keyword-is-a-phrase-and-two-letters-is-a-word.md +90 -0
  50. simt_dev/vendored/docs/decisions/ADR-044-compatibility-is-not-evidence.md +116 -0
  51. simt_dev/vendored/docs/decisions/ADR-045-a-dashboard-that-counts-a-defect.md +119 -0
  52. simt_dev/vendored/docs/decisions/ADR-046-one-rule-one-copy.md +140 -0
  53. simt_dev/vendored/docs/decisions/ADR-047-a-profile-selects-on-what-a-repository-runs-on.md +141 -0
  54. simt_dev/vendored/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +150 -0
  55. simt_dev/vendored/docs/decisions/ADR-049-a-ruling-that-a-later-record-moved-says-so-where-it-is-printed.md +153 -0
  56. simt_dev/vendored/docs/decisions/ADR-050-a-byte-count-is-not-a-region-and-updated-is-not-a-change-of-rules.md +102 -0
  57. simt_dev/vendored/docs/decisions/ADR-051-a-pointer-is-the-readers-only-route-so-it-is-not-a-convenience.md +134 -0
  58. simt_dev/vendored/docs/decisions/ADR-052-a-dependency-is-counted-against-the-budget-and-there-is-one-budget.md +101 -0
  59. simt_dev/vendored/docs/decisions/ADR-053-the-node-floor-is-22-20-0.md +49 -0
  60. simt_dev/vendored/docs/decisions/ADR-054-the-digest-covers-every-byte-that-leaves-the-package.md +100 -0
  61. simt_dev/vendored/docs/decisions/ADR-055-a-status-line-travels-so-it-may-not-name-a-location.md +98 -0
  62. simt_dev/vendored/docs/decisions/ADR-056-three-names-one-of-which-is-cheap-to-move.md +160 -0
  63. simt_dev/vendored/docs/decisions/ADR-057-an-identity-is-per-developer-and-a-repository-does-not-own-it.md +115 -0
  64. simt_dev/vendored/docs/decisions/ADR-058-one-tool-three-channels-and-the-order-is-forced.md +148 -0
  65. simt_dev/vendored/docs/decisions/README.md +530 -0
  66. simt_dev/vendored/graph/skills.graph.json +1622 -0
  67. simt_dev/vendored/package.json +43 -0
  68. simt_dev/vendored/rules/core.md +76 -0
  69. simt_dev/vendored/rules/mdc/intake-questions.mdc +50 -0
  70. simt_dev/vendored/rules/mdc/preview-postflight.mdc +61 -0
  71. simt_dev/vendored/rules/mdc/slice-commit.mdc +52 -0
  72. simt_dev/vendored/rules/mdc/test-gate.mdc +54 -0
  73. simt_dev/vendored/rules/stacks/android.md +63 -0
  74. simt_dev/vendored/rules/stacks/cloudflare-workers.md +65 -0
  75. simt_dev/vendored/rules/stacks/connectiq.md +62 -0
  76. simt_dev/vendored/rules/stacks/container.md +31 -0
  77. simt_dev/vendored/rules/stacks/jvm.md +54 -0
  78. simt_dev/vendored/rules/stacks/node.md +45 -0
  79. simt_dev/vendored/rules/stacks/python.md +48 -0
  80. simt_dev/vendored/rules/stacks/react-native.md +58 -0
  81. simt_dev/vendored/rules/stacks/react-web.md +61 -0
  82. simt_dev/vendored/rules/stacks/typescript.md +21 -0
  83. simt_dev/vendored/rules/stacks/uv.md +32 -0
  84. simt_dev/vendored/src/argv.mjs +116 -0
  85. simt_dev/vendored/src/ci/dependabot.mjs +268 -0
  86. simt_dev/vendored/src/ci/workflow.mjs +360 -0
  87. simt_dev/vendored/src/commands/ci.mjs +285 -0
  88. simt_dev/vendored/src/commands/compare.mjs +195 -0
  89. simt_dev/vendored/src/commands/graph.mjs +73 -0
  90. simt_dev/vendored/src/commands/help.mjs +108 -0
  91. simt_dev/vendored/src/commands/hooks.mjs +471 -0
  92. simt_dev/vendored/src/commands/index.mjs +241 -0
  93. simt_dev/vendored/src/commands/init.mjs +632 -0
  94. simt_dev/vendored/src/commands/mcp.mjs +63 -0
  95. simt_dev/vendored/src/commands/rules.mjs +374 -0
  96. simt_dev/vendored/src/commands/skills.mjs +647 -0
  97. simt_dev/vendored/src/commands/skillsinstall.mjs +515 -0
  98. simt_dev/vendored/src/commands/sync.mjs +331 -0
  99. simt_dev/vendored/src/commands/upgrade.mjs +202 -0
  100. simt_dev/vendored/src/compare/discover.mjs +212 -0
  101. simt_dev/vendored/src/compare/hooksstate.mjs +159 -0
  102. simt_dev/vendored/src/compare/row.mjs +299 -0
  103. simt_dev/vendored/src/decisions/index.mjs +536 -0
  104. simt_dev/vendored/src/graph/load.mjs +541 -0
  105. simt_dev/vendored/src/hooks/capability.mjs +129 -0
  106. simt_dev/vendored/src/hooks/dispatcher.mjs +268 -0
  107. simt_dev/vendored/src/hooks/gates.mjs +144 -0
  108. simt_dev/vendored/src/hooks/resolve.mjs +194 -0
  109. simt_dev/vendored/src/hooks/shim.mjs +140 -0
  110. simt_dev/vendored/src/hooks/where.mjs +118 -0
  111. simt_dev/vendored/src/identity.mjs +218 -0
  112. simt_dev/vendored/src/locate.mjs +75 -0
  113. simt_dev/vendored/src/mcp/server.mjs +167 -0
  114. simt_dev/vendored/src/mcp/tools.mjs +410 -0
  115. simt_dev/vendored/src/name.mjs +95 -0
  116. simt_dev/vendored/src/output/index.mjs +96 -0
  117. simt_dev/vendored/src/projection/baselines.mjs +209 -0
  118. simt_dev/vendored/src/projection/conflicts.mjs +205 -0
  119. simt_dev/vendored/src/projection/manifest.mjs +478 -0
  120. simt_dev/vendored/src/projection/markers.mjs +349 -0
  121. simt_dev/vendored/src/projection/mdc.mjs +327 -0
  122. simt_dev/vendored/src/projection/project.mjs +504 -0
  123. simt_dev/vendored/src/projection/rules.mjs +640 -0
  124. simt_dev/vendored/src/projection/surfaces.mjs +398 -0
  125. simt_dev/vendored/src/projection/targets.mjs +221 -0
  126. simt_dev/vendored/src/resolve/fingerprint.mjs +553 -0
  127. simt_dev/vendored/src/resolve/near.mjs +61 -0
  128. simt_dev/vendored/src/resolve/resolve.mjs +457 -0
  129. simt_dev/vendored/src/resolve/source.mjs +105 -0
  130. simt_dev/vendored/src/toolchain.mjs +202 -0
  131. simt_dev/vendored/src/tsconfig.mjs +314 -0
  132. simt_dev/vendored/src/upgrade/plan.mjs +203 -0
  133. simt_dev/vendored/src/workspace.mjs +289 -0
  134. simt_dev-0.2.0.dist-info/METADATA +150 -0
  135. simt_dev-0.2.0.dist-info/RECORD +138 -0
  136. simt_dev-0.2.0.dist-info/WHEEL +4 -0
  137. simt_dev-0.2.0.dist-info/entry_points.txt +2 -0
  138. simt_dev-0.2.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,203 @@
1
+ import { MANIFEST_COMMENT } from '../projection/manifest.mjs';
2
+ import { CLI } from '../name.mjs';
3
+ import { MANIFEST_FILE } from '../name.mjs';
4
+ // Classify one repository's upgrade state. Pure: no filesystem, no git.
5
+ //
6
+ // The line this module draws is narrow on purpose. `upgrade` moves a PIN. It does not
7
+ // adopt a repo, repair a broken one, or decide that a repo should have a region it never
8
+ // had. Each of those is a different command with a different review, and a command that
9
+ // silently performs them during an "upgrade" is a command nobody can predict.
10
+ //
11
+ // So there are exactly two outcomes that write: `updated` and `unchanged`. Everything
12
+ // else is a skip with its own remedy, and the skip is reported rather than tolerated -
13
+ // a repo silently left behind is the exact failure `compare` exists to surface, and it
14
+ // must not be one this command creates.
15
+
16
+ /**
17
+ * @param {object} input
18
+ * @param {string} input.name
19
+ * @param {string} input.path
20
+ * @param {object|null} input.manifest parsed simt-dev.json, or null
21
+ * @param {boolean} input.manifestExists
22
+ * @param {string|null} input.manifestError parse failure, when there is one
23
+ * @param {object[]} input.targets every planned target from `plan(root).targets`: the
24
+ * AGENTS.md region first, then the generated `.mdc` rules
25
+ * @param {string} input.sha the SHA this simt-dev stamps into regions
26
+ */
27
+ export function planUpgrade({ name, path, manifest, manifestExists, manifestError, targets, sha }) {
28
+ const list = Array.isArray(targets) ? targets : [];
29
+ const primary = list[0] ?? null;
30
+ const base = { name, path, sha, pinnedSha: manifest?.projection?.simtDevSha ?? null };
31
+
32
+ // A repo that never adopted is not behind; it is absent. `simt-dev init` is the command
33
+ // for it, and calling this a failure would make the exit code describe the estate's
34
+ // migration progress rather than this run's correctness.
35
+ if (!manifestExists) {
36
+ return { ...base, state: 'not-adopted', action: 'skip', detail: `no ${MANIFEST_FILE}`, remedy: `${CLI} init` };
37
+ }
38
+ if (manifestError) {
39
+ return {
40
+ ...base,
41
+ state: 'broken-manifest',
42
+ action: 'skip',
43
+ detail: `${MANIFEST_FILE} is unparseable (${manifestError})`,
44
+ remedy: `fix the JSON, or run: ${CLI} init`,
45
+ };
46
+ }
47
+ // Only a hand-owned file's malformed region is a fault. A managed `.mdc` rule is a
48
+ // generated file, so a malformed region there is repaired by replacement and nothing
49
+ // hand-written is lost — `plan` reports its action as `updated`, not `malformed`, and
50
+ // the write fixes it. AGENTS.md holds hand-written prose around its region, so
51
+ // overwriting it could destroy work: that is the fault, and the whole repo is left
52
+ // alone so no file is half-migrated.
53
+ const malformed = list.find((t) => t.action === 'malformed');
54
+ if (malformed) {
55
+ return {
56
+ ...base,
57
+ state: 'malformed-region',
58
+ action: 'skip',
59
+ detail: `${malformed.file} region is malformed (${malformed.reason})`,
60
+ remedy: `fix the markers at ${malformed.file}:${malformed.line}, then: ${CLI} sync --yes`,
61
+ };
62
+ }
63
+ if (primary === null) {
64
+ return {
65
+ ...base,
66
+ state: 'no-region',
67
+ action: 'skip',
68
+ detail: 'no generated file could be planned for this repo',
69
+ remedy: `run: ${CLI} check`,
70
+ };
71
+ }
72
+ // A manifest with no region is a repo mid-adoption or one that never adopted, and
73
+ // `upgrade` writing a region into it turns adoption into a side effect of a version
74
+ // bump. The question is whether the region exists, and `parse` has always answered it —
75
+ // reading `previousBytes` instead gets a repository whose AGENTS.md is hand-written prose
76
+ // wrong, which is the shape this guard was written for and the one it let through. A
77
+ // byte count cannot tell "no region" from "small file", and a hand-written AGENTS.md is
78
+ // never small.
79
+ //
80
+ // Keyed on the PRIMARY target alone, and that is not a simplification. The estate builds
81
+ // this state by adopting and then replacing AGENTS.md with prose, which leaves the
82
+ // baselines' regions in place: a guard asking whether *every* target lost its region sees
83
+ // two regions, decides the repo is adopted, and writes one — which is the defect. It is
84
+ // also the rule `compare` already applies (ADR-016 ruling on display versus gate: the row's
85
+ // `region` field is the primary document's), so the two commands now agree on which
86
+ // repository counts as adopted.
87
+ if (primary.region === 'absent') {
88
+ return {
89
+ ...base,
90
+ state: 'no-region',
91
+ action: 'skip',
92
+ detail: `${MANIFEST_FILE} exists but AGENTS.md has no generated region`,
93
+ // `sync --yes`, not `init --yes`. The remedy is a command that does the thing the
94
+ // skip refused to do and nothing else: `sync` writes regions and leaves the rest of
95
+ // the repository alone, while `init` re-runs adoption. `compare` already names
96
+ // `sync --yes` for this state, and two commands must not answer the same question
97
+ // with different instructions.
98
+ remedy: `run: ${CLI} sync --yes`,
99
+ };
100
+ }
101
+
102
+ // Every target counts: a repo whose AGENTS.md is current but whose `.mdc` rules are
103
+ // stale is behind, and restamping only the first of its files would leave `check` red
104
+ // immediately after a successful `upgrade`.
105
+ const regionBehind = list.some((t) => t.action !== 'unchanged');
106
+ // "Behind" is not "the rules changed", and the difference is what the message below
107
+ // has to state. A target is `updated` for two unrelated reasons: its region BODY moved,
108
+ // which is a change to the content this tool projects, or its seam moved, which is this
109
+ // tool's own formatting around a body that was already correct (ADR-037). Both read as
110
+ // `updated`, so reading the cause off `action` reports a formatting repair as a change
111
+ // of policy — and this command's whole purpose is to say why a repository is behind, so
112
+ // a false cause here sends the reader to diff the rules when the rules did not move.
113
+ //
114
+ // The comparison is body to body rather than byte to byte, because the region header
115
+ // carries the stamp: a stamp-only move is what `pinBehind` already reports separately,
116
+ // and folding it in here would make every pin bump claim the rules changed.
117
+ //
118
+ // And the two kinds of projected content are counted apart, because they are different
119
+ // files to diff. A baseline region is not a rule: `.editorconfig` and `.gitattributes`
120
+ // are settings a repository owns, and telling a reader the RULES changed when only an
121
+ // indentation setting did sends them to the wrong file (S53). It was reachable because
122
+ // the stamp could not see a baseline body change at all, so the pin reported unchanged
123
+ // and the only cause left to report was the one that was least true.
124
+ const moved = (t) => {
125
+ if (t.action === 'unchanged' || t.action === 'added') return t.action === 'added';
126
+ const before = (t.previousBody ?? '').replace(/^\n+/, '').replace(/\n+$/, '');
127
+ const after = (t.body ?? '').replace(/^\n+/, '').replace(/\n+$/, '');
128
+ return before !== after;
129
+ };
130
+ const rulesChanged = list.some((t) => t.kind !== 'baseline' && moved(t));
131
+ const baselineChanged = list.some((t) => t.kind === 'baseline' && moved(t));
132
+ // The cause, as the sentence the reader is sent to act on. Null means no projected body
133
+ // moved, so whatever is behind this repository is a seam, a stamp or the manifest.
134
+ const cause = rulesChanged && baselineChanged
135
+ ? 'the rules and a baseline changed'
136
+ : rulesChanged
137
+ ? 'the rules changed'
138
+ : baselineChanged
139
+ ? 'a baseline changed'
140
+ : null;
141
+ const pinBehind = base.pinnedSha !== sha;
142
+ // A generated field of the manifest can alone be stale. `$comment` is written by
143
+ // simt-dev and describes this file's provenance, so an adopted repo whose regions
144
+ // and pin are all current can still be carrying an older version's wording -
145
+ // and because `sync` does not own the manifest, nothing else would ever correct
146
+ // it. Counting it here is what gives staleness a path back to being correct: a
147
+ // field nobody checks is a field nobody fixes.
148
+ const manifestBehind =
149
+ manifest?.$comment !== undefined && manifest.$comment !== MANIFEST_COMMENT.$comment;
150
+ if (!regionBehind && !pinBehind && !manifestBehind) {
151
+ return { ...base, state: 'current', action: 'unchanged', detail: 'already at this simt-dev' };
152
+ }
153
+
154
+ // Why it moved is worth stating, and the causes are different problems even though
155
+ // one command fixes all of them: content drift means a projected BODY changed, a seam
156
+ // repair means none did and only the formatting around one was wrong, a pin-only move
157
+ // means the region was already correct and only its stamp was stale, and a
158
+ // generated-field move means nothing about the repo changed and this tool's own
159
+ // wording did. The transition itself is printed on its own `pin` line, so `detail`
160
+ // names the cause without repeating it.
161
+ // `cause` is null on the last branch and can only be: a body that moved is a target
162
+ // whose action is not `unchanged`, so "no region behind" already implies no cause.
163
+ return {
164
+ ...base,
165
+ state: 'behind',
166
+ action: 'updated',
167
+ detail: pinBehind
168
+ ? regionBehind
169
+ ? cause === null
170
+ ? 'pin moved and region repaired; the rules were already correct'
171
+ : `pin moved and region restamped (${cause})`
172
+ : 'pin moved; the region was already current'
173
+ : regionBehind
174
+ ? cause === null
175
+ ? 'region repaired; the pin and the rules were already current'
176
+ : `region restamped; the pin was already current (${cause})`
177
+ : 'manifest metadata refreshed; the rules and the pin were already current',
178
+ };
179
+ }
180
+
181
+ /**
182
+ * States that mean something is WRONG, as opposed to states that mean something is
183
+ * merely ABSENT. Only these fail the run.
184
+ *
185
+ * The distinction is the same one `compare` makes, and it is load-bearing: on day one of
186
+ * a migration most repos have no manifest at all. A command that exits non-zero for them
187
+ * would report a workspace that looks broken and is merely early, and people would stop
188
+ * reading its exit code.
189
+ */
190
+ export const FAULT_STATES = new Set(['broken-manifest', 'malformed-region', 'no-region']);
191
+
192
+ /** @returns {{repos:number, updated:number, unchanged:number, skipped:number, faults:number, skippedStates:string[]}} */
193
+ export function summariseUpgrade(plans) {
194
+ const skipped = plans.filter((p) => p.action === 'skip');
195
+ return {
196
+ repos: plans.length,
197
+ updated: plans.filter((p) => p.action === 'updated').length,
198
+ unchanged: plans.filter((p) => p.action === 'unchanged').length,
199
+ skipped: skipped.length,
200
+ faults: skipped.filter((p) => FAULT_STATES.has(p.state)).length,
201
+ skippedStates: [...new Set(skipped.map((p) => p.state))].sort(),
202
+ };
203
+ }
@@ -0,0 +1,289 @@
1
+ // Where the workspace is, and how to be sure.
2
+ //
3
+ // The workspace is the directory that CONTAINS repositories. It is not the same thing as a
4
+ // repository, and it is not required: a single repo with no workspace is a normal, supported
5
+ // case (ADR-014). What the workspace provides is the *sibling* relationship - it is how
6
+ // `graph/skills.graph.json`'s `repo/path` sources resolve, and how `compare` finds the rest of
7
+ // the estate.
8
+ //
9
+ // Three real failure modes motivated this module, all measured rather than reasoned about:
10
+ //
11
+ // 1. `resolve(HERE, '..', '..', '..')` is correct in the checkout and wrong in node_modules,
12
+ // where it lands inside `node_modules/@quazarmedia`. Every `skills` command then fails
13
+ // with ENOENT for all 38 sources.
14
+ // 2. A single repo passed to `compare` was reported as "contains no git repositories",
15
+ // because discovery scanned the CHILDREN of the given path.
16
+ // 3. The published package shipped no `docs/`, so `rules why` could not resolve a single ADR.
17
+ //
18
+ // The fix is not a cleverer heuristic. It is: resolve from explicit configuration, fall back to
19
+ // the heuristic only when it verifies, and report which one answered. A silent fallback to the
20
+ // wrong directory is worse than no answer at all, because every downstream answer is then
21
+ // confidently wrong.
22
+
23
+ import { readFile } from 'node:fs/promises';
24
+ import { existsSync } from 'node:fs';
25
+ import { homedir, platform as osPlatform } from 'node:os';
26
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+
29
+ const HERE = dirname(fileURLToPath(import.meta.url));
30
+ /**
31
+ * The package root. Here that is ONE level up from `src/`, not two - and getting
32
+ * it wrong silently shifts the workspace heuristic by a whole directory, which is
33
+ * why it is a named constant rather than an inline expression.
34
+ */
35
+ export const PACKAGE_ROOT = resolve(HERE, '..');
36
+
37
+ // Re-exported rather than re-spelled: the literal belongs to src/name.mjs with the
38
+ // other LOCKED on-disk names, and a second copy here is a name that can drift from the
39
+ // list of names that are allowed to move. Imported as well as re-exported, because a
40
+ // bare `export { X } from` does not bind X here and every use below would fail.
41
+ import { CONFIG_DIR } from './name.mjs';
42
+ export { CONFIG_DIR };
43
+ export const CONFIG_FILE = 'config.json';
44
+
45
+ /**
46
+ * Candidate config paths, most explicit first.
47
+ *
48
+ * `$XDG_CONFIG_HOME` comes first on every platform because it is the only way to *override* the
49
+ * location on a POSIX system, and honouring an explicit override before guessing is the entire
50
+ * point. macOS is listed after it because `~/Library/Application Support` is Apple's documented
51
+ * location for application data, but a developer who already exports XDG_CONFIG_HOME should not
52
+ * have to also move the file. Both conventions are therefore supported, and which one is in use
53
+ * is reported rather than assumed.
54
+ *
55
+ * Windows is listed although ADR-012 drops Windows support. Keeping the function total makes it
56
+ * testable on any host and correct if that ruling is ever reversed; `package.json` still refuses
57
+ * to install on Windows.
58
+ *
59
+ * @returns {string[]}
60
+ *
61
+ * `env` defaults to `process.env`, NOT to an empty object. An earlier version
62
+ * defaulted to `{}` so that a test could pass a fake environment, and the result was
63
+ * that `XDG_CONFIG_HOME` - the one documented way to OVERRIDE the config location -
64
+ * was silently ignored on every real invocation. Every unit test passed `{env}`
65
+ * explicitly, so nothing caught it; only running the CLI did. The default is part of
66
+ * this function's contract, not a convenience.
67
+ */
68
+ export function configPaths({ env = process.env, platform = osPlatform(), home = homedir() } = {}) {
69
+ const candidates = [];
70
+ const push = (p) => {
71
+ if (p && !candidates.includes(p)) candidates.push(p);
72
+ };
73
+
74
+ if (env.XDG_CONFIG_HOME) push(join(env.XDG_CONFIG_HOME, CONFIG_DIR, CONFIG_FILE));
75
+ push(join(home, '.config', CONFIG_DIR, CONFIG_FILE));
76
+ if (platform === 'darwin') {
77
+ push(join(home, 'Library', 'Application Support', CONFIG_DIR, CONFIG_FILE));
78
+ }
79
+ if (platform === 'win32') {
80
+ const base = env.APPDATA ?? join(home, 'AppData', 'Roaming');
81
+ push(join(base, CONFIG_DIR, CONFIG_FILE));
82
+ }
83
+ return candidates;
84
+ }
85
+
86
+ /**
87
+ * Read the user config, if there is one.
88
+ *
89
+ * An absent file is not an error: it means "not configured", and resolution continues down the
90
+ * precedence list. Only a file that EXISTS and does not parse is an error, because that is a
91
+ * real mistake the reader can fix - and silently ignoring it would send them looking for a
92
+ * configuration problem in the wrong place.
93
+ */
94
+ export async function readUserConfig(options = {}) {
95
+ const searched = configPaths(options);
96
+ for (const path of searched) {
97
+ let text;
98
+ try {
99
+ text = await readFile(path, 'utf8');
100
+ } catch {
101
+ continue;
102
+ }
103
+ try {
104
+ const parsed = JSON.parse(text);
105
+ return { path, exists: true, config: parsed ?? {}, error: null, searched };
106
+ } catch (error) {
107
+ return { path, exists: true, config: {}, error: `unparseable JSON (${error.message})`, searched };
108
+ }
109
+ }
110
+ return { path: null, exists: false, config: {}, error: null, searched };
111
+ }
112
+
113
+ /**
114
+ * Does this directory contain at least one git repository? The heuristic is only accepted when
115
+ * it verifies, which is what stops a stray `node_modules` parent from being silently believed.
116
+ */
117
+ async function looksLikeWorkspace(dir) {
118
+ const { readdir } = await import('node:fs/promises');
119
+ let entries;
120
+ try {
121
+ entries = await readdir(dir, { withFileTypes: true });
122
+ } catch {
123
+ return false;
124
+ }
125
+ for (const entry of entries) {
126
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
127
+ if (entry.name === 'node_modules') continue;
128
+ if (existsSync(join(dir, entry.name, '.git'))) return true;
129
+ }
130
+ return false;
131
+ }
132
+
133
+ /**
134
+ * Resolve the workspace.
135
+ *
136
+ * Precedence, highest first: `--workspace`, `SIMT_DEV_WORKSPACE`, the user config file, then the
137
+ * sibling heuristic of this package - and that last one only if it actually contains a git
138
+ * repository.
139
+ *
140
+ * @returns {Promise<{root: string|null, source: string, detail: string, searched: string[], error: string|null}>}
141
+ */
142
+ export async function resolveWorkspace(flags = {}, options = {}) {
143
+ const env = options.env ?? process.env;
144
+ const searched = configPaths(options);
145
+
146
+ // The config file is read FIRST, before precedence is applied, and a parse failure
147
+ // is reported immediately. That is deliberate: precedence decides which *answer*
148
+ // wins, but a config file that exists and does not parse is a defect in the
149
+ // reader's setup, and hiding it because some other source outranked it would send
150
+ // them looking for a configuration problem in the wrong place.
151
+ const user = await readUserConfig(options);
152
+ if (user.error) {
153
+ return { root: null, source: 'config', detail: user.path, searched, error: user.error };
154
+ }
155
+
156
+ // Only `--workspace` counts here. `--root` names a REPOSITORY, and a repository
157
+ // is not its own parent, so treating one as the other would resolve every
158
+ // `repo/path` source one directory too low - a wrong answer that reads as right.
159
+ const flag = flags.workspace;
160
+ if (flag) {
161
+ return (
162
+ disabled(flag, 'flag', '--workspace', searched) ?? {
163
+ root: absolute(flag),
164
+ source: 'flag',
165
+ detail: '--workspace',
166
+ searched,
167
+ error: null,
168
+ }
169
+ );
170
+ }
171
+
172
+ if (env.SIMT_DEV_WORKSPACE) {
173
+ return (
174
+ disabled(env.SIMT_DEV_WORKSPACE, 'env', 'SIMT_DEV_WORKSPACE', searched) ?? {
175
+ root: absolute(env.SIMT_DEV_WORKSPACE),
176
+ source: 'env',
177
+ detail: 'SIMT_DEV_WORKSPACE',
178
+ searched,
179
+ error: null,
180
+ }
181
+ );
182
+ }
183
+
184
+ if (user.exists && typeof user.config.workspace === 'string' && user.config.workspace.length > 0) {
185
+ return (
186
+ disabled(user.config.workspace, 'config', user.path, searched) ?? {
187
+ root: absolute(user.config.workspace),
188
+ source: 'config',
189
+ detail: user.path,
190
+ searched,
191
+ error: null,
192
+ }
193
+ );
194
+ }
195
+
196
+ const parent = resolve(PACKAGE_ROOT, '..');
197
+ if (await looksLikeWorkspace(parent)) {
198
+ return { root: parent, source: 'heuristic', detail: 'sibling of this package', searched, error: null };
199
+ }
200
+
201
+ return {
202
+ root: null,
203
+ source: 'none',
204
+ detail: 'no workspace configured',
205
+ searched,
206
+ error: null,
207
+ };
208
+ }
209
+
210
+ /** Values that mean "there is deliberately no workspace", from any source. */
211
+ const OFF = new Set(['off', 'none', 'no', '0', '-']);
212
+
213
+ /**
214
+ * The sentinel. Without it there is no way to *decline* the sibling heuristic, so a
215
+ * reader who does not want a directory guessed for them cannot say so - and no test
216
+ * running from this checkout can reach the unconfigured path at all. `off` is
217
+ * returned from any source and takes the same shape as any other answer, so callers
218
+ * need no special case for it.
219
+ */
220
+ function disabled(value, source, detail, searched) {
221
+ return OFF.has(String(value).trim().toLowerCase())
222
+ ? {
223
+ root: null,
224
+ source: 'disabled',
225
+ detail: `${detail}=off (workspace declined)`,
226
+ searched: searched ?? [],
227
+ error: null,
228
+ }
229
+ : null;
230
+ }
231
+
232
+ /**
233
+ * The one-line remedy every workspace-dependent failure prints.
234
+ *
235
+ * Built from this machine's OWN candidate list rather than a hardcoded example, because a
236
+ * remedy that names a path the reader does not have is a second problem stacked on the first.
237
+ * Index 0 is the right one to name: on POSIX that is `$XDG_CONFIG_HOME/...` when set and
238
+ * `$HOME/.config/...` when not, which is the path the reader most likely already expects.
239
+ */
240
+ // The remedy is a phrase, not a sentence: several callers embed it after their own
241
+ // lead-in ("workspace none configured - <here>"), so it must not start with one.
242
+ export const WORKSPACE_REMEDY = (() => {
243
+ const first = configPaths()[0];
244
+ const where = first ?? join('$HOME', '.config', CONFIG_DIR, CONFIG_FILE);
245
+ return `--workspace <path>, the SIMT_DEV_WORKSPACE environment variable, or ${where}`;
246
+ })();
247
+
248
+ function absolute(p) {
249
+ const expanded = p.startsWith('~/')
250
+ ? join(homedir(), p.slice(2))
251
+ : p;
252
+ return isAbsolute(expanded) ? expanded : resolve(expanded);
253
+ }
254
+
255
+ /**
256
+ * The workspace, or null. Callers that can degrade use this and report `source: 'none'`;
257
+ * callers that genuinely cannot proceed should use `resolveWorkspace` and fail with its detail.
258
+ */
259
+ export async function workspaceOrNull(flags = {}, options = {}) {
260
+ const { root, error } = await resolveWorkspace(flags, options);
261
+ return error ? null : root;
262
+ }
263
+
264
+ /**
265
+ * The path a multi-repo command should work on: `--root` or the first positional if
266
+ * given, else the resolved workspace, else the current directory.
267
+ *
268
+ * This lives here rather than in each command because precedence is a user-visible
269
+ * contract: `compare`, `upgrade` and any future workspace-wide command must agree on
270
+ * which source named the directory, or a reader who set `SIMT_DEV_WORKSPACE` gets
271
+ * three different answers from three commands.
272
+ *
273
+ * `--root` is returned verbatim as the source even though it names a repository; whether
274
+ * that repository is the only thing that counts is the caller's question, and `discover`
275
+ * answers it from the filesystem rather than from a guess.
276
+ */
277
+ export async function targetRoot(flags = {}, positional = []) {
278
+ const explicit = flags.root ?? positional[0];
279
+ if (explicit) {
280
+ return { root: absolute(explicit), source: 'flag', detail: explicit, ws: null };
281
+ }
282
+ const ws = await resolveWorkspace(flags);
283
+ return {
284
+ root: absolute(ws.root ?? process.cwd()),
285
+ source: ws.source,
286
+ detail: ws.detail,
287
+ ws,
288
+ };
289
+ }
@@ -0,0 +1,150 @@
1
+ Metadata-Version: 2.4
2
+ Name: simt-dev
3
+ Version: 0.2.0
4
+ Summary: 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
+ Project-URL: Homepage, https://github.com/QuazarMedia/simt-dev
6
+ Project-URL: Repository, https://github.com/QuazarMedia/simt-dev
7
+ Project-URL: Changelog, https://github.com/QuazarMedia/simt-dev/blob/main/CHANGELOG.md
8
+ Author: Said Elimam
9
+ License-Expression: Apache-2.0
10
+ License-File: LICENSE
11
+ Keywords: agent,agents,ai,codex,cursor,git-hooks,llm,rules,skills
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: Apache Software License
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Operating System :: POSIX :: Linux
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3 :: Only
20
+ Classifier: Programming Language :: Python :: 3.9
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Topic :: Software Development :: Quality Assurance
26
+ Requires-Python: >=3.9
27
+ Provides-Extra: node
28
+ Requires-Dist: nodejs-wheel-binaries; extra == 'node'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # simt-dev
32
+
33
+ Shared agent rules, skills and git hooks for a repository.
34
+
35
+ One command adopts a repository: it reads what the repo actually runs, projects the
36
+ matching rules into the files your agent already reads, and installs a pre-commit hook
37
+ that refuses a commit which breaks a gate that repo can genuinely run.
38
+
39
+ - **Nothing is declared that cannot run.** Every gate is probed against the working tree
40
+ before it is written. A gate whose tool is absent is dropped, with a remedy naming the
41
+ package to install — never silently present, never silently absent.
42
+ - **Nothing an agent reads is written twice.** Each rule goes to exactly one place, chosen
43
+ by what the harness actually reads.
44
+ - **The rules are a projection, not a copy.** They carry markers, so `check` can tell
45
+ drift from a deliberate edit, and `sync` can regenerate.
46
+ - **The tool explains itself.** `rules why` answers "why did I get told this?" from a
47
+ numbered record of the decisions behind the rules, with the rejected alternatives
48
+ attached.
49
+
50
+ ## Install
51
+
52
+ Requires **Node >= 22.20.0** (the PyPI install can supply it, see below).
53
+
54
+ ```sh
55
+ # pinned, per repository - what CI and a teammate both get
56
+ npm i -D simt-dev@0.2.0
57
+
58
+ # or globally, if you would rather not add a dependency
59
+ npm i -g simt-dev@0.2.0
60
+
61
+ # or with no install at all, for a one-off
62
+ npx -y simt-dev@0.2.0 check
63
+ ```
64
+
65
+ ### From PyPI
66
+
67
+ The same tool, from the same bytes. The wheel carries the exact contents the npm
68
+ tarball carries — the wheel is not a wrapper around `npm install`, so nothing is
69
+ fetched when you install it.
70
+
71
+ ```sh
72
+ pip install simt-dev
73
+
74
+ # and if this machine has no node at all
75
+ pip install "simt-dev[node]"
76
+ ```
77
+
78
+ `simt` is the command on both channels. Where node comes from is resolved in
79
+ this order, and the first one that answers wins: `SIMT_DEV_NODE`, the bundled
80
+ node from the `node` extra, then `node` on your `PATH`. If none of them resolve,
81
+ you get a message naming the remedy rather than a placeholder node path.
82
+
83
+ ### From Homebrew
84
+
85
+ ```sh
86
+ brew install QuazarMedia/homebrew-tap/simt-dev
87
+ ```
88
+
89
+ The formula declares its own `node` dependency, so `brew install` gives you a
90
+ working command on a machine that has never had Node.
91
+
92
+ Pin the version. The tool writes a content digest into the files it generates, and an
93
+ unpinned upgrade changes them under you.
94
+
95
+ ## Use
96
+
97
+ ```sh
98
+ simt init # adopt or scaffold this repository (dry-run; --yes to write)
99
+ simt doctor # what this repo is, and whether its config is healthy
100
+ simt check # the gate: projection drift, manifest validity, policy
101
+ simt sync --yes # regenerate the projection
102
+ simt upgrade --yes # move every consumer of this tool to this version
103
+ simt rules why ADR-042 # why a rule says what it says, and what was rejected
104
+ simt compare # what changed across every repository that adopted it
105
+ ```
106
+
107
+ `init` and `sync` print a plan and write nothing without `--yes`. Neither commits or pushes.
108
+
109
+ ## What it writes
110
+
111
+ | Path | What |
112
+ | --- | --- |
113
+ | `AGENTS.md` | the projection, between `<!-- simt:begin -->` and `<!-- simt:end -->` (renamed from `simt-dev:`; the old markers are still read, and a region migrates on its next write) |
114
+ | `simt.json` | your manifest: which gates to declare, which substitutions to fill (renamed from `simt-dev.json`; the old name is still read, and moves on the next write) |
115
+ | `.cursor/rules/*.mdc`, `.claude/rules/*.md`, `.agents/rules/*.md`, `.github/instructions/*.instructions.md` | one file per rule, written only for the harnesses that read their own directory |
116
+ | `.editorconfig`, `.gitattributes` | baseline regions |
117
+ | `.simt-hooks/` and `core.hooksPath` | the shared dispatcher, fail-closed |
118
+
119
+ Anything outside the markers is yours. simt-dev never edits it and never reverts it.
120
+
121
+ ## Platform
122
+
123
+ **macOS and Linux.** The hooks are POSIX `sh`; there is no Windows build and no plan for
124
+ one. `npm i` on Windows fails with npm's `EBADPLATFORM`, which npm will not let a package
125
+ explain — so the answer is here: **use WSL2**, and the gate works normally inside it.
126
+
127
+ A Windows checkout without WSL2 can still read every rule this tool generates, because the
128
+ projection is plain text in `AGENTS.md` and the per-harness rule files. Only the hooks and
129
+ the CI gate need the POSIX host.
130
+
131
+ ## How the rules are chosen
132
+
133
+ Detection reads what the repository runs — its dependency manifests, its build files, its
134
+ CI workflows — and resolves that against a registry of capabilities. Resolution is a
135
+ scored ranking over path, keyword, name, capability and stack: no model, no network, no
136
+ guessing, so a pull request can meaningfully diff the answer.
137
+
138
+ **Not every ecosystem has a profile yet.** A Go, Rust, .NET, Ruby, Swift, Dart or Elixir
139
+ repository is detected and gets facts, but it is adopted with the core rules and no gates
140
+ until its gate templates are written. A repository with no gates is a repository with an
141
+ agent and no pre-commit hook — better than one with a hook that runs nothing, but not full
142
+ support, and this README will not pretend otherwise.
143
+
144
+ Every behaviour that is a choice rather than a detail has a numbered record, and the
145
+ records are readable with `simt rules why <id>`. If the tool ever does something you
146
+ did not expect, that command is the shortest path from the symptom to the reason.
147
+
148
+ ## Licence
149
+
150
+ Apache-2.0. See [LICENSE](LICENSE).