simt-dev 0.2.0 → 0.2.2

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 (90) hide show
  1. package/CHANGELOG.md +375 -7
  2. package/README.md +4 -4
  3. package/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +27 -0
  4. package/docs/decisions/ADR-059-the-version-a-repository-runs-is-the-one-it-pinned.md +75 -0
  5. package/docs/decisions/ADR-060-a-repository-with-nothing-to-run-is-adopted-and-says-so.md +131 -0
  6. package/docs/decisions/ADR-061-the-skill-registry-is-workspace-data.md +113 -0
  7. package/docs/decisions/ADR-062-a-fresh-adoption-seeds-the-gates-it-can-run.md +94 -0
  8. package/docs/decisions/ADR-063-the-adoption-escape-hatch-is-opt-in.md +69 -0
  9. package/docs/decisions/ADR-064-the-context-meter-counts-what-the-tool-wrote.md +107 -0
  10. package/docs/decisions/ADR-065-a-pack-is-one-shared-rule-set-pinned-by-digest.md +97 -0
  11. package/docs/decisions/ADR-066-a-monorepo-projects-once-per-package.md +115 -0
  12. package/docs/decisions/ADR-067-which-harnesses-read-agents-md-is-a-column.md +110 -0
  13. package/docs/decisions/ADR-068-the-mcp-revision-list-cites-what-was-read.md +111 -0
  14. package/docs/decisions/ADR-069-the-conformance-lint-checks-the-published-specification.md +90 -0
  15. package/docs/decisions/ADR-070-the-json-payload-carries-its-own-version.md +91 -0
  16. package/docs/decisions/ADR-071-a-rule-that-can-be-narrowed-is-written-narrow.md +106 -0
  17. package/docs/decisions/ADR-072-the-budget-is-a-declaration-beside-the-gates.md +104 -0
  18. package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +92 -0
  19. package/docs/decisions/README.md +17 -0
  20. package/package.json +1 -2
  21. package/rules/core.md +1 -0
  22. package/rules/stacks/android.md +2 -0
  23. package/rules/stacks/cloudflare-workers.md +2 -0
  24. package/rules/stacks/connectiq.md +1 -0
  25. package/rules/stacks/container.md +2 -0
  26. package/rules/stacks/dart.md +40 -0
  27. package/rules/stacks/deno.md +38 -0
  28. package/rules/stacks/dotnet.md +37 -0
  29. package/rules/stacks/elixir.md +41 -0
  30. package/rules/stacks/go.md +36 -0
  31. package/rules/stacks/jvm.md +2 -0
  32. package/rules/stacks/node.md +2 -0
  33. package/rules/stacks/python.md +2 -0
  34. package/rules/stacks/react-native.md +2 -0
  35. package/rules/stacks/react-web.md +2 -0
  36. package/rules/stacks/ruby.md +38 -0
  37. package/rules/stacks/rust.md +36 -0
  38. package/rules/stacks/swift.md +39 -0
  39. package/rules/stacks/typescript.md +2 -0
  40. package/rules/stacks/uv.md +2 -0
  41. package/src/argv.mjs +54 -6
  42. package/src/ci/dependabot.mjs +1 -1
  43. package/src/ci/workflow.mjs +33 -11
  44. package/src/commands/compare.mjs +15 -0
  45. package/src/commands/graph.mjs +58 -25
  46. package/src/commands/harness.mjs +150 -0
  47. package/src/commands/help.mjs +37 -5
  48. package/src/commands/hooks.mjs +173 -57
  49. package/src/commands/index.mjs +95 -69
  50. package/src/commands/init.mjs +100 -20
  51. package/src/commands/mcp.mjs +20 -5
  52. package/src/commands/rules.mjs +96 -60
  53. package/src/commands/skills.mjs +189 -86
  54. package/src/commands/skillsinstall.mjs +20 -5
  55. package/src/commands/sync.mjs +213 -15
  56. package/src/commands/tokens.mjs +138 -0
  57. package/src/compare/hooksstate.mjs +96 -9
  58. package/src/compare/row.mjs +107 -7
  59. package/src/decisions/index.mjs +21 -6
  60. package/src/graph/load.mjs +125 -14
  61. package/src/graph/spec.mjs +227 -0
  62. package/src/graph/speccheck.mjs +68 -0
  63. package/src/hooks/capability.mjs +48 -4
  64. package/src/hooks/dispatcher.mjs +84 -7
  65. package/src/hooks/gates.mjs +0 -2
  66. package/src/hooks/resolve.mjs +24 -25
  67. package/src/hooks/shim.mjs +29 -5
  68. package/src/identity.mjs +0 -11
  69. package/src/mcp/schema.mjs +139 -0
  70. package/src/mcp/server.mjs +197 -20
  71. package/src/mcp/tools.mjs +290 -3
  72. package/src/measure/context.mjs +371 -0
  73. package/src/monorepo.mjs +339 -0
  74. package/src/output/index.mjs +86 -9
  75. package/src/package-version.mjs +74 -0
  76. package/src/packs.mjs +182 -0
  77. package/src/projection/conflicts.mjs +56 -5
  78. package/src/projection/manifest.mjs +335 -25
  79. package/src/projection/mdc.mjs +4 -13
  80. package/src/projection/project.mjs +139 -8
  81. package/src/projection/rules.mjs +308 -27
  82. package/src/projection/surfaces.mjs +133 -15
  83. package/src/projection/targets.mjs +90 -7
  84. package/src/resolve/eval.mjs +64 -0
  85. package/src/resolve/fingerprint.mjs +1 -0
  86. package/src/resolve/near.mjs +26 -7
  87. package/src/resolve/resolve.mjs +1 -1
  88. package/src/resolve/source.mjs +1 -1
  89. package/src/workspace.mjs +0 -9
  90. package/graph/skills.graph.json +0 -1622
package/src/packs.mjs ADDED
@@ -0,0 +1,182 @@
1
+ // Shared rule sets, vendored into the repository that uses them.
2
+ //
3
+ // A pack is one answer to a question fourteen repositories kept asking separately:
4
+ // "several repositories share one set of rules, and it must change in one place."
5
+ // What a pack is NOT is a remote fetch. The content lives in the adopting
6
+ // repository at `.simt-dev/packs/<name>/`, and the manifest records a digest of
7
+ // it. Reading it from anywhere else would make the rendered region depend on a
8
+ // path that does not resolve in the receiving repository - which is the single
9
+ // failure this whole tool exists to prevent, and it would be introduced by the
10
+ // feature meant to remove duplication.
11
+ //
12
+ // `graph/skills.graph.json` is deliberately not part of a pack. ADR-061 ruled
13
+ // the registry to be workspace data; a second source for it would need its own
14
+ // resolution ruling, and inventing one here would be how that gets settled by
15
+ // accident.
16
+
17
+ import { readFile, readdir, stat } from 'node:fs/promises';
18
+ import { createHash } from 'node:crypto';
19
+ import { join } from 'node:path';
20
+
21
+ import { readManifest } from './projection/manifest.mjs';
22
+ import { CLI, MANIFEST_FILE } from './name.mjs';
23
+
24
+ /** Where a pack's files live, relative to a repository root. */
25
+ export const PACK_DIR = '.simt-dev/packs';
26
+
27
+ // A pack name is a directory name under `PACK_DIR`, and it ends up in a path the
28
+ // tool builds from manifest content. Restricted to the characters a directory
29
+ // name can hold without escaping, so a hand-edited manifest cannot point the
30
+ // reader's renderer at something outside the repository.
31
+ const NAME = /^[a-z0-9][a-z0-9._-]*$/;
32
+
33
+ /**
34
+ * @returns {string|null} the name if it is usable, else why it is not.
35
+ */
36
+ function packNameProblem(name) {
37
+ if (typeof name !== 'string' || name.length === 0) return 'a pack name must be a non-empty string';
38
+ if (!NAME.test(name)) return `\`${name}\` is not a usable pack name: lowercase letters, digits, dot, dash and underscore`;
39
+ if (name === '.' || name === '..') return `\`${name}\` is not a usable pack name`;
40
+ return null;
41
+ }
42
+
43
+ /**
44
+ * The digest of one pack directory: every file, relative path and bytes, in
45
+ * sorted path order. Same shape as the rule provenance digest on purpose - a
46
+ * reader who has seen one digest in a manifest has seen the other.
47
+ *
48
+ * The paths are pack-relative, so a pack moved to another repository hashes the
49
+ * same. That is the property that makes the digest a statement about CONTENT
50
+ * rather than about where the reader happens to keep it.
51
+ *
52
+ * @returns {Promise<string>} 40 hex chars, or `''` for a directory that is not there.
53
+ */
54
+ export async function packDigest(dir) {
55
+ const files = [];
56
+ const walk = async (d, prefix = '') => {
57
+ let entries;
58
+ try {
59
+ entries = await readdir(d, { withFileTypes: true });
60
+ } catch {
61
+ return;
62
+ }
63
+ for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
64
+ const rel = prefix === '' ? entry.name : `${prefix}/${entry.name}`;
65
+ if (entry.isDirectory()) await walk(join(d, entry.name), rel);
66
+ else if (entry.isFile()) files.push(rel);
67
+ }
68
+ };
69
+ await walk(dir);
70
+ files.sort();
71
+
72
+ const hash = createHash('sha256');
73
+ for (const rel of files) {
74
+ const bytes = await readFile(join(dir, rel));
75
+ hash.update(`${rel}:${bytes.length}:`);
76
+ hash.update(bytes);
77
+ }
78
+ return hash.digest('hex').slice(0, 40);
79
+ }
80
+
81
+ /**
82
+ * Resolve the packs a repository declares, and report the ones it cannot use.
83
+ *
84
+ * A declared pack with no directory, or whose bytes no longer match the digest,
85
+ * is a finding rather than a silent omission: a repo whose region renders
86
+ * without half its rules is a repo whose reader was told something false, and the
87
+ * next `sync` would overwrite the truth on disk with the shorter region.
88
+ *
89
+ * `present` and `ok` are separate questions on purpose. A pack whose bytes do not
90
+ * match its pin is still PROJECTED - otherwise the remedy the finding names,
91
+ * `sync --yes`, would delete every rule the pack carries, which is the opposite of
92
+ * what a reader who ran the remedy meant. What is questioned is the pin, and the
93
+ * gate stays red until it is re-recorded; what is missing is the directory, and
94
+ * there is nothing to project.
95
+ *
96
+ * The remedy is `git checkout -- <dir>`, because the pack is vendored: git has
97
+ * the bytes this manifest's digest names. `sync` cannot help - it would render
98
+ * the region without the pack and call it current - so the remedy does not name
99
+ * it as the repair and does name it as the thing to run afterwards.
100
+ *
101
+ * @returns {Promise<{packs: {name: string, dir: string, source: string, version: string, digest: string, declared: string, ok: boolean}[], findings: {file: string, status: string, detail: string, remedy: string}[]}>}
102
+ */
103
+ export async function resolvePacks(root) {
104
+ const findings = [];
105
+ const packs = [];
106
+ const { manifest, exists, error } = await readManifest(root);
107
+ if (!exists || error !== null || manifest === null) return { packs, findings };
108
+
109
+ const declared = Array.isArray(manifest.packs) ? manifest.packs : [];
110
+ const seen = new Set();
111
+
112
+ for (const entry of declared) {
113
+ const name = entry?.name;
114
+ const problem = packNameProblem(name);
115
+ if (problem !== null) {
116
+ findings.push({
117
+ file: MANIFEST_FILE,
118
+ status: 'pack-name-invalid',
119
+ detail: problem,
120
+ remedy: `correct or remove the entry, then: ${CLI} sync --yes`,
121
+ });
122
+ continue;
123
+ }
124
+ if (seen.has(name)) {
125
+ findings.push({
126
+ file: MANIFEST_FILE,
127
+ status: 'pack-declared-twice',
128
+ detail: `pack \`${name}\` is declared twice; the second declaration is read as the first`,
129
+ remedy: `remove the duplicate entry, then: ${CLI} sync --yes`,
130
+ });
131
+ continue;
132
+ }
133
+ seen.add(name);
134
+
135
+ const dir = join(root, PACK_DIR, name);
136
+ const source = typeof entry.source === 'string' ? entry.source : null;
137
+ const version = typeof entry.version === 'string' ? entry.version : null;
138
+ let present = false;
139
+ try {
140
+ present = (await stat(dir)).isDirectory();
141
+ } catch {
142
+ present = false;
143
+ }
144
+ if (!present) {
145
+ findings.push({
146
+ file: `${PACK_DIR}/${name}`,
147
+ status: 'pack-missing',
148
+ detail: `the manifest declares pack \`${name}\`, and there is no directory for it; every rule it carries is missing from this repository's projection`,
149
+ remedy: `restore it with: git checkout -- ${PACK_DIR}/${name} (or remove the declaration, then: ${CLI} sync --yes)`,
150
+ });
151
+ packs.push({ name, dir, source, version, digest: '', declared: '', present: false, ok: false });
152
+ continue;
153
+ }
154
+
155
+ const digest = await packDigest(dir);
156
+ const declaredDigest = typeof entry.digest === 'string' ? entry.digest : null;
157
+ // An absent digest is not a mismatch. A pack someone vendored and has not
158
+ // pinned yet still carries its rules, and refusing to project them would make
159
+ // the tool refuse a repository for a field it has always treated as optional.
160
+ // It is reported, because "pinned to nothing" is not a fact about the content.
161
+ let ok = true;
162
+ if (declaredDigest === null) {
163
+ findings.push({
164
+ file: MANIFEST_FILE,
165
+ status: 'pack-unpinned',
166
+ detail: `pack \`${name}\` has no digest, so nothing records which bytes it was declared at`,
167
+ remedy: `record \`${digest}\` as its digest, then: ${CLI} sync --yes`,
168
+ });
169
+ } else if (declaredDigest !== digest) {
170
+ ok = false;
171
+ findings.push({
172
+ file: `${PACK_DIR}/${name}`,
173
+ status: 'pack-digest-mismatch',
174
+ detail: `pack \`${name}\` is pinned to ${declaredDigest} and its bytes digest to ${digest}`,
175
+ remedy: `restore the pinned bytes with: git checkout -- ${PACK_DIR}/${name} (or update the digest to ${digest}, then: ${CLI} sync --yes)`,
176
+ });
177
+ }
178
+ packs.push({ name, dir, source, version, digest, declared: declaredDigest ?? digest, present: true, ok });
179
+ }
180
+
181
+ return { packs, findings };
182
+ }
@@ -11,6 +11,8 @@
11
11
  // substring scan reports that file as violating the very rule it is correctly
12
12
  // restating. So every match is checked for prohibition context first.
13
13
 
14
+ import { POINTERS, SURFACES } from './surfaces.mjs';
15
+
14
16
  /** Files whose text is a hand-written rule surface. Scanned for conflicts only;
15
17
  * generated regions are excluded at read time by the caller.
16
18
  *
@@ -18,11 +20,60 @@
18
20
  * worth recording: the estate's three always-on `.mdc` families were the single
19
21
  * worst-drifted content in it, and `simt-dev init` reported `CONFLICTS none detected`
20
22
  * on the repo holding them. A conflict detector that only looks at the filenames
21
- * it was written for will quietly miss a whole rule surface added later. */
23
+ * it was written for will quietly miss a whole rule surface added later.
24
+ *
25
+ * And then it did. The comment above was written as a lesson and the lesson was not
26
+ * taken: the list stayed hand-written, two surfaces were added to `SURFACES`
27
+ * afterwards, and `.claude/rules/` and `.agents/rules/` were silently unscanned —
28
+ * two of the five surfaces, and both of them read a rule that this tool writes. A
29
+ * comment saying "derive this" is not deriving it. So the per-rule directories come
30
+ * from `SURFACES` now (see `ruleGlobs`), and this list holds only the files no
31
+ * surface declares. */
22
32
  export const RULE_FILES = ['AGENTS.md', 'CLAUDE.md', 'CONTRIBUTING.md'];
23
33
 
24
- /** Rule files discovered by glob rather than by name. */
25
- export const RULE_GLOBS = ['.cursor/rules/*.mdc', '.github/instructions/*.md'];
34
+ /** Pointer targets, scanned because this tool writes them and they are rule files.
35
+ *
36
+ * `CLAUDE.md` was named by hand in `RULE_FILES` for the whole life of the tool, and
37
+ * `GEMINI.md` — which `POINTERS` creates for a repo on the Antigravity surface — was
38
+ * not. So one file this tool writes was scanned and the other was not, with nothing
39
+ * in the source saying which was which: the difference was an accident of the order
40
+ * the two were added. Derived from `POINTERS` now, so it cannot recur. */
41
+ function pointerFiles() {
42
+ return POINTERS.map((p) => p.path);
43
+ }
44
+
45
+ /**
46
+ * The per-rule directories, read out of `SURFACES` rather than written out here.
47
+ *
48
+ * One list of surfaces, read by everything that has to know where a surface keeps its
49
+ * rules. A surface added to `SURFACES` is now scanned for conflicts the moment it
50
+ * exists, which is the property the hand-written list kept failing to have — and it
51
+ * could only fail quietly, because a detector that matches nothing reports the same
52
+ * answer as a repository with no rules.
53
+ *
54
+ * `CLAUDE.md` and `GEMINI.md` are here through `RULE_FILES`/`POINTERS` rather than
55
+ * through this: they are symlinks to the primary region, so they are scanned as
56
+ * *files*, and `CONTRIBUTING.md` belongs to no surface at all.
57
+ *
58
+ * Takes the list as an argument rather than closing over it so the derivation can be
59
+ * shown on a surface that does not exist. The default is `SURFACES`, so every caller
60
+ * still reads the one authority; there is no way to pass anything else in production.
61
+ *
62
+ * @param {{rule?: string}[]} [surfaces]
63
+ */
64
+ export function ruleGlobs(surfaces = SURFACES) {
65
+ return surfaces.filter((s) => typeof s.rule === 'string').map((s) => {
66
+ const slash = s.rule.lastIndexOf('/');
67
+ const extension = s.rule.slice(s.rule.lastIndexOf('.'));
68
+ // The FINAL extension, not the whole tail after `{name}`. Copilot's template is
69
+ // `{name}.instructions.md`, and matching that whole suffix would be narrower than
70
+ // the `.md` this file used to list by hand — which is the difference between seeing
71
+ // a hand-written `reviewer.md` in that directory and reporting the directory as
72
+ // empty. A file this tool writes always carries its own suffix; a file a person
73
+ // wrote in the same directory need not, and the person is who this scanner is for.
74
+ return `${s.rule.slice(0, slash)}/*${extension}`;
75
+ });
76
+ }
26
77
 
27
78
  /** Every hand-written rule path in a repo, named or globbed.
28
79
  *
@@ -36,8 +87,8 @@ export const RULE_GLOBS = ['.cursor/rules/*.mdc', '.github/instructions/*.md'];
36
87
  export async function rulePaths(root) {
37
88
  const { readdir } = await import('node:fs/promises');
38
89
  const { join } = await import('node:path');
39
- const out = RULE_FILES.map((f) => join(root, f));
40
- for (const glob of RULE_GLOBS) {
90
+ const out = [...new Set([...RULE_FILES, ...pointerFiles()])].map((f) => join(root, f));
91
+ for (const glob of ruleGlobs()) {
41
92
  const star = glob.lastIndexOf('*');
42
93
  const dir = join(root, glob.slice(0, star));
43
94
  const tail = glob.slice(star + 1);