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.
- python/simt_dev/__init__.py +32 -0
- python/simt_dev/cli.py +225 -0
- simt_dev/__init__.py +32 -0
- simt_dev/cli.py +225 -0
- simt_dev/vendored/CHANGELOG.md +166 -0
- simt_dev/vendored/bin/simt-dev.mjs +28 -0
- simt_dev/vendored/docs/decisions/ADR-001-push-requires-approval.md +42 -0
- simt_dev/vendored/docs/decisions/ADR-002-refactor-blast-radius.md +45 -0
- simt_dev/vendored/docs/decisions/ADR-003-test-breadth.md +46 -0
- simt_dev/vendored/docs/decisions/ADR-004-hook-owns-format.md +45 -0
- simt_dev/vendored/docs/decisions/ADR-005-file-length.md +42 -0
- simt_dev/vendored/docs/decisions/ADR-006-coverage-bar.md +59 -0
- simt_dev/vendored/docs/decisions/ADR-007-section-skeleton.md +54 -0
- simt_dev/vendored/docs/decisions/ADR-008-verdict-vocabulary.md +59 -0
- simt_dev/vendored/docs/decisions/ADR-009-skill-layout.md +48 -0
- simt_dev/vendored/docs/decisions/ADR-010-runner-invocation.md +56 -0
- simt_dev/vendored/docs/decisions/ADR-011-windows-support.md +52 -0
- simt_dev/vendored/docs/decisions/ADR-012-drop-windows-support.md +59 -0
- simt_dev/vendored/docs/decisions/ADR-013-genericity-by-facts-and-local-profiles.md +114 -0
- simt_dev/vendored/docs/decisions/ADR-014-workspace-is-configuration.md +211 -0
- simt_dev/vendored/docs/decisions/ADR-015-a-source-is-content-not-a-location.md +117 -0
- simt_dev/vendored/docs/decisions/ADR-016-a-divergent-rule-is-retired-not-merged.md +180 -0
- simt_dev/vendored/docs/decisions/ADR-017-a-declared-gate-must-be-runnable.md +89 -0
- simt_dev/vendored/docs/decisions/ADR-018-an-unset-hooks-path-is-not-an-absent-hook.md +183 -0
- simt_dev/vendored/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +110 -0
- simt_dev/vendored/docs/decisions/ADR-020-a-rule-is-written-per-surface-and-pointed-not-copied.md +100 -0
- simt_dev/vendored/docs/decisions/ADR-021-one-surface-per-repo.md +94 -0
- simt_dev/vendored/docs/decisions/ADR-022-a-key-simt-dev-reads-is-prefixed.md +98 -0
- simt_dev/vendored/docs/decisions/ADR-023-the-gate-ships-as-a-file-the-repo-owns.md +106 -0
- simt_dev/vendored/docs/decisions/ADR-024-simt-dev-overwrites-its-own-gate-file.md +111 -0
- simt_dev/vendored/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +153 -0
- simt_dev/vendored/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +117 -0
- simt_dev/vendored/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +130 -0
- 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
- simt_dev/vendored/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +120 -0
- simt_dev/vendored/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +114 -0
- simt_dev/vendored/docs/decisions/ADR-031-the-suite-stages-the-environment-it-needs.md +129 -0
- simt_dev/vendored/docs/decisions/ADR-032-provenance-is-a-content-digest-not-a-commit.md +141 -0
- simt_dev/vendored/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +82 -0
- simt_dev/vendored/docs/decisions/ADR-034-a-staged-tool-refuses-a-partially-staged-file.md +91 -0
- simt_dev/vendored/docs/decisions/ADR-035-one-write-decision-and-one-json-document.md +119 -0
- simt_dev/vendored/docs/decisions/ADR-036-a-writer-that-writes-says-so-and-the-instructions-naming-it-move-with-it.md +103 -0
- simt_dev/vendored/docs/decisions/ADR-037-unchanged-means-writing-would-not-change-a-byte.md +100 -0
- simt_dev/vendored/docs/decisions/ADR-038-the-gate-filter-belongs-to-every-manifest-writer.md +95 -0
- simt_dev/vendored/docs/decisions/ADR-039-a-ci-step-runs-a-form-that-can-fail.md +101 -0
- simt_dev/vendored/docs/decisions/ADR-040-the-last-writer-that-wrote-by-default-was-not-the-one-named.md +158 -0
- simt_dev/vendored/docs/decisions/ADR-041-the-suite-installs-the-package-in-order-to-test-it.md +92 -0
- simt_dev/vendored/docs/decisions/ADR-042-a-declared-glob-must-match-a-path-built-from-it.md +94 -0
- simt_dev/vendored/docs/decisions/ADR-043-a-keyword-is-a-phrase-and-two-letters-is-a-word.md +90 -0
- simt_dev/vendored/docs/decisions/ADR-044-compatibility-is-not-evidence.md +116 -0
- simt_dev/vendored/docs/decisions/ADR-045-a-dashboard-that-counts-a-defect.md +119 -0
- simt_dev/vendored/docs/decisions/ADR-046-one-rule-one-copy.md +140 -0
- simt_dev/vendored/docs/decisions/ADR-047-a-profile-selects-on-what-a-repository-runs-on.md +141 -0
- simt_dev/vendored/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +150 -0
- simt_dev/vendored/docs/decisions/ADR-049-a-ruling-that-a-later-record-moved-says-so-where-it-is-printed.md +153 -0
- 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
- simt_dev/vendored/docs/decisions/ADR-051-a-pointer-is-the-readers-only-route-so-it-is-not-a-convenience.md +134 -0
- simt_dev/vendored/docs/decisions/ADR-052-a-dependency-is-counted-against-the-budget-and-there-is-one-budget.md +101 -0
- simt_dev/vendored/docs/decisions/ADR-053-the-node-floor-is-22-20-0.md +49 -0
- simt_dev/vendored/docs/decisions/ADR-054-the-digest-covers-every-byte-that-leaves-the-package.md +100 -0
- simt_dev/vendored/docs/decisions/ADR-055-a-status-line-travels-so-it-may-not-name-a-location.md +98 -0
- simt_dev/vendored/docs/decisions/ADR-056-three-names-one-of-which-is-cheap-to-move.md +160 -0
- simt_dev/vendored/docs/decisions/ADR-057-an-identity-is-per-developer-and-a-repository-does-not-own-it.md +115 -0
- simt_dev/vendored/docs/decisions/ADR-058-one-tool-three-channels-and-the-order-is-forced.md +148 -0
- simt_dev/vendored/docs/decisions/README.md +530 -0
- simt_dev/vendored/graph/skills.graph.json +1622 -0
- simt_dev/vendored/package.json +43 -0
- simt_dev/vendored/rules/core.md +76 -0
- simt_dev/vendored/rules/mdc/intake-questions.mdc +50 -0
- simt_dev/vendored/rules/mdc/preview-postflight.mdc +61 -0
- simt_dev/vendored/rules/mdc/slice-commit.mdc +52 -0
- simt_dev/vendored/rules/mdc/test-gate.mdc +54 -0
- simt_dev/vendored/rules/stacks/android.md +63 -0
- simt_dev/vendored/rules/stacks/cloudflare-workers.md +65 -0
- simt_dev/vendored/rules/stacks/connectiq.md +62 -0
- simt_dev/vendored/rules/stacks/container.md +31 -0
- simt_dev/vendored/rules/stacks/jvm.md +54 -0
- simt_dev/vendored/rules/stacks/node.md +45 -0
- simt_dev/vendored/rules/stacks/python.md +48 -0
- simt_dev/vendored/rules/stacks/react-native.md +58 -0
- simt_dev/vendored/rules/stacks/react-web.md +61 -0
- simt_dev/vendored/rules/stacks/typescript.md +21 -0
- simt_dev/vendored/rules/stacks/uv.md +32 -0
- simt_dev/vendored/src/argv.mjs +116 -0
- simt_dev/vendored/src/ci/dependabot.mjs +268 -0
- simt_dev/vendored/src/ci/workflow.mjs +360 -0
- simt_dev/vendored/src/commands/ci.mjs +285 -0
- simt_dev/vendored/src/commands/compare.mjs +195 -0
- simt_dev/vendored/src/commands/graph.mjs +73 -0
- simt_dev/vendored/src/commands/help.mjs +108 -0
- simt_dev/vendored/src/commands/hooks.mjs +471 -0
- simt_dev/vendored/src/commands/index.mjs +241 -0
- simt_dev/vendored/src/commands/init.mjs +632 -0
- simt_dev/vendored/src/commands/mcp.mjs +63 -0
- simt_dev/vendored/src/commands/rules.mjs +374 -0
- simt_dev/vendored/src/commands/skills.mjs +647 -0
- simt_dev/vendored/src/commands/skillsinstall.mjs +515 -0
- simt_dev/vendored/src/commands/sync.mjs +331 -0
- simt_dev/vendored/src/commands/upgrade.mjs +202 -0
- simt_dev/vendored/src/compare/discover.mjs +212 -0
- simt_dev/vendored/src/compare/hooksstate.mjs +159 -0
- simt_dev/vendored/src/compare/row.mjs +299 -0
- simt_dev/vendored/src/decisions/index.mjs +536 -0
- simt_dev/vendored/src/graph/load.mjs +541 -0
- simt_dev/vendored/src/hooks/capability.mjs +129 -0
- simt_dev/vendored/src/hooks/dispatcher.mjs +268 -0
- simt_dev/vendored/src/hooks/gates.mjs +144 -0
- simt_dev/vendored/src/hooks/resolve.mjs +194 -0
- simt_dev/vendored/src/hooks/shim.mjs +140 -0
- simt_dev/vendored/src/hooks/where.mjs +118 -0
- simt_dev/vendored/src/identity.mjs +218 -0
- simt_dev/vendored/src/locate.mjs +75 -0
- simt_dev/vendored/src/mcp/server.mjs +167 -0
- simt_dev/vendored/src/mcp/tools.mjs +410 -0
- simt_dev/vendored/src/name.mjs +95 -0
- simt_dev/vendored/src/output/index.mjs +96 -0
- simt_dev/vendored/src/projection/baselines.mjs +209 -0
- simt_dev/vendored/src/projection/conflicts.mjs +205 -0
- simt_dev/vendored/src/projection/manifest.mjs +478 -0
- simt_dev/vendored/src/projection/markers.mjs +349 -0
- simt_dev/vendored/src/projection/mdc.mjs +327 -0
- simt_dev/vendored/src/projection/project.mjs +504 -0
- simt_dev/vendored/src/projection/rules.mjs +640 -0
- simt_dev/vendored/src/projection/surfaces.mjs +398 -0
- simt_dev/vendored/src/projection/targets.mjs +221 -0
- simt_dev/vendored/src/resolve/fingerprint.mjs +553 -0
- simt_dev/vendored/src/resolve/near.mjs +61 -0
- simt_dev/vendored/src/resolve/resolve.mjs +457 -0
- simt_dev/vendored/src/resolve/source.mjs +105 -0
- simt_dev/vendored/src/toolchain.mjs +202 -0
- simt_dev/vendored/src/tsconfig.mjs +314 -0
- simt_dev/vendored/src/upgrade/plan.mjs +203 -0
- simt_dev/vendored/src/workspace.mjs +289 -0
- simt_dev-0.2.0.dist-info/METADATA +150 -0
- simt_dev-0.2.0.dist-info/RECORD +138 -0
- simt_dev-0.2.0.dist-info/WHEEL +4 -0
- simt_dev-0.2.0.dist-info/entry_points.txt +2 -0
- 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).
|