simt-dev 0.2.0 → 0.2.1

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 (56) hide show
  1. package/CHANGELOG.md +173 -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/README.md +17 -0
  10. package/package.json +1 -2
  11. package/rules/stacks/dart.md +38 -0
  12. package/rules/stacks/deno.md +36 -0
  13. package/rules/stacks/dotnet.md +35 -0
  14. package/rules/stacks/elixir.md +39 -0
  15. package/rules/stacks/go.md +34 -0
  16. package/rules/stacks/ruby.md +36 -0
  17. package/rules/stacks/rust.md +34 -0
  18. package/rules/stacks/swift.md +37 -0
  19. package/src/argv.mjs +35 -6
  20. package/src/ci/dependabot.mjs +1 -1
  21. package/src/ci/workflow.mjs +33 -11
  22. package/src/commands/graph.mjs +13 -4
  23. package/src/commands/help.mjs +22 -4
  24. package/src/commands/hooks.mjs +173 -57
  25. package/src/commands/index.mjs +53 -45
  26. package/src/commands/init.mjs +89 -13
  27. package/src/commands/rules.mjs +54 -3
  28. package/src/commands/skills.mjs +143 -15
  29. package/src/commands/skillsinstall.mjs +3 -3
  30. package/src/commands/sync.mjs +153 -11
  31. package/src/compare/hooksstate.mjs +96 -9
  32. package/src/compare/row.mjs +34 -5
  33. package/src/decisions/index.mjs +21 -6
  34. package/src/graph/load.mjs +125 -14
  35. package/src/hooks/capability.mjs +48 -4
  36. package/src/hooks/dispatcher.mjs +50 -5
  37. package/src/hooks/gates.mjs +0 -2
  38. package/src/hooks/resolve.mjs +24 -25
  39. package/src/hooks/shim.mjs +29 -5
  40. package/src/identity.mjs +0 -11
  41. package/src/mcp/tools.mjs +2 -2
  42. package/src/output/index.mjs +8 -7
  43. package/src/package-version.mjs +74 -0
  44. package/src/projection/conflicts.mjs +56 -5
  45. package/src/projection/manifest.mjs +247 -25
  46. package/src/projection/mdc.mjs +4 -13
  47. package/src/projection/project.mjs +1 -1
  48. package/src/projection/rules.mjs +21 -8
  49. package/src/projection/targets.mjs +53 -7
  50. package/src/resolve/eval.mjs +64 -0
  51. package/src/resolve/fingerprint.mjs +1 -0
  52. package/src/resolve/near.mjs +26 -7
  53. package/src/resolve/resolve.mjs +1 -1
  54. package/src/resolve/source.mjs +1 -1
  55. package/src/workspace.mjs +0 -9
  56. package/graph/skills.graph.json +0 -1622
@@ -64,14 +64,45 @@ export async function ruleTargets(root, rule, body, sha, resolved, notes) {
64
64
  }
65
65
 
66
66
  const mine = s.ruleFrontmatter(rule);
67
- const frontmatter = extractFrontmatter(existing) ?? frontmatterBlock(mine);
67
+ // The frontmatter is re-derived, never inherited (S16).
68
+ //
69
+ // Two things were wrong and only the second was visible. The first was
70
+ // `extractFrontmatter(existing) ?? frontmatterBlock(mine)`, where the file's own
71
+ // block won whenever the file already had one. The second was this line's
72
+ // neighbour: when the existing region parsed cleanly, `composed` was the whole
73
+ // existing file, so `inject` replaced the region between the markers and left the
74
+ // frontmatter above them exactly as it found it — whatever this tool emitted the
75
+ // first time it saw this surface, forever. A surface that adds a key, or whose
76
+ // `alwaysApply` flips, could never reach a repository that had already adopted it,
77
+ // and no gate could report it: the file agrees with itself, so nothing is stale by
78
+ // any comparison the tool makes.
79
+ //
80
+ // One asymmetry, deliberately: a surface that emits NO frontmatter leaves whatever
81
+ // is there alone. This tool owns the block it writes and will replace it; deleting a
82
+ // block it did not write is a different operation, and a surface with no frontmatter
83
+ // of its own has no opinion about it.
84
+ const frontmatter = mine.length > 0 ? frontmatterBlock(mine) : extractFrontmatter(existing) ?? '';
68
85
  const state = exists ? parse(existing) : { status: 'absent' };
69
86
  // A surface that wants no frontmatter must not get a blank line where the
70
87
  // frontmatter would have been: Claude's rule files opened with two empty lines
71
88
  // before the marker, which is untidy in a file the reader will open, and a
72
89
  // difference between surfaces that no check reported.
90
+ //
91
+ // Swapped in place rather than stripped and rejoined. My first attempt here was
92
+ // `${frontmatter}\n${bodyAfterFrontmatter(existing)}`, which looked equivalent and
93
+ // was not: that helper's trailing `\n*` swallows the blank line between `---` and
94
+ // the region, so the reconstruction was one newline short of the file and `inject`
95
+ // reported `updated` on every run forever while writing identical bytes. The file
96
+ // was right; the verdict never settled, so `check` reported drift no reader could
97
+ // see and `sync` rewrote a correct file on every invocation. Anything that rebuilds
98
+ // a file has to reproduce the bytes it did not mean to touch, and the cheapest way
99
+ // to guarantee that is not to rebuild them.
73
100
  const composed =
74
- state.status === 'ok' ? existing : frontmatter === '' ? '' : `${frontmatter}\n`;
101
+ state.status === 'ok'
102
+ ? swapFrontmatter(existing, frontmatter)
103
+ : frontmatter === ''
104
+ ? ''
105
+ : `${frontmatter}\n`;
75
106
  const result = inject(composed, body, { sha, sources: [rule.source] });
76
107
 
77
108
  // Report the prose being retired rather than letting it vanish into a diff the
@@ -201,11 +232,6 @@ export async function pointerState(root, pointer) {
201
232
  return { file: pointer.file, state: 'mispointed', link, detail: `points at ${link}` };
202
233
  }
203
234
 
204
- export function surfaceFrontmatter(surfaceId, rule) {
205
- const s = SURFACES.find((x) => x.id === surfaceId);
206
- return s && s.ruleFrontmatter ? s.ruleFrontmatter(rule) : [];
207
- }
208
-
209
235
  // The per-surface path lives in surfaces.mjs next to the row that justifies it;
210
236
  // re-exported so a caller building a target never has to know which module owns
211
237
  // path construction as opposed to frontmatter construction.
@@ -216,6 +242,26 @@ export function extractFrontmatter(text) {
216
242
  return m ? m[0].trimEnd() : null;
217
243
  }
218
244
 
245
+ /**
246
+ * Put `frontmatter` where a frontmatter block already is, and leave every other byte
247
+ * exactly where it was.
248
+ *
249
+ * The block match deliberately stops at the closing `---` and not past it, so the
250
+ * replacement starts at the newline that follows — the blank line a formatter requires
251
+ * before an HTML comment survives belongs to the file, not to the block, and consuming
252
+ * it here is what made `inject` report `updated` forever on files whose bytes never
253
+ * changed.
254
+ *
255
+ * A file with no block gets one above its content. The separator below it is left to
256
+ * `inject`'s `seam`, which is the one place that knows what the surrounding document
257
+ * format requires.
258
+ */
259
+ function swapFrontmatter(text, frontmatter) {
260
+ if (frontmatter === '') return text;
261
+ const m = /^---\n[\s\S]*?\n---/.exec(text);
262
+ return m === null ? `${frontmatter}\n${text}` : `${frontmatter}${text.slice(m[0].length)}`;
263
+ }
264
+
219
265
  function bodyAfterFrontmatter(text) {
220
266
  return text.replace(/^---\n[\s\S]*?\n---\n*/, '');
221
267
  }
@@ -0,0 +1,64 @@
1
+ // Gold-corpus evaluation: run each case through the resolver and score it.
2
+ //
3
+ // Plan 0002 section 9: "A resolver that is not measured is a resolver that is merely
4
+ // felt." The corpus (test/fixtures/eval-corpus.json) states, for real repository signals
5
+ // and tasks, which skills must come back and which must not. Two callers must never
6
+ // disagree about the score - `simt skills eval` reports it to a reader, and
7
+ // test/eval.test.mjs asserts it in CI - so the arithmetic lives here once (ADR-002).
8
+
9
+ import { rankSkills, tokenize } from './resolve.mjs';
10
+
11
+ /** The skill names the resolver selects for one case's declared signals and task. */
12
+ function selectForCase(registry, c) {
13
+ const context = {
14
+ repoStack: c.repo?.stack ?? [],
15
+ paths: c.repo?.paths ?? [],
16
+ taskTokens: tokenize(c.task ?? ''),
17
+ };
18
+ const { selected } = rankSkills(registry.skills, context);
19
+ return selected.map((e) => e.skill.name);
20
+ }
21
+
22
+ /** Skill names the corpus cites that the registry does not declare. */
23
+ export function unknownSkills(registry, corpus) {
24
+ const names = new Set(registry.skills.map((s) => s.name));
25
+ return [
26
+ ...new Set(
27
+ corpus.cases.flatMap((c) => [...(c.select ?? []), ...(c.reject ?? [])]).filter((n) => !names.has(n)),
28
+ ),
29
+ ];
30
+ }
31
+
32
+ /**
33
+ * Score the whole corpus: per-case detail plus precision and recall.
34
+ *
35
+ * `unexpected` is the half of precision that named negatives cannot see: a case whose
36
+ * truth is "nothing" names only a sample of what must not come back, so for those the
37
+ * whole selected set is the miss. `clean` is what the exit code rests on; the two
38
+ * ratios are measurements, and a case can break `clean` while both still read 1.0 -
39
+ * the first version of this arithmetic scored that case a perfect precision (S19).
40
+ */
41
+ export function scoreCorpus(registry, corpus) {
42
+ let expected = 0;
43
+ let negatives = 0;
44
+ const cases = corpus.cases.map((c) => {
45
+ expected += c.select?.length ?? 0;
46
+ negatives += c.reject?.length ?? 0;
47
+ const got = new Set(selectForCase(registry, c));
48
+ const missing = (c.select ?? []).filter((n) => !got.has(n));
49
+ const wronglySelected = (c.reject ?? []).filter((n) => got.has(n));
50
+ return {
51
+ name: c.name,
52
+ selected: [...got],
53
+ missing,
54
+ wronglySelected,
55
+ unexpected: c.select?.length === 0 ? [...got] : wronglySelected,
56
+ };
57
+ });
58
+ const missed = cases.reduce((n, c) => n + c.missing.length, 0);
59
+ const wrong = cases.reduce((n, c) => n + c.wronglySelected.length, 0);
60
+ const recall = expected === 0 ? 1 : (expected - missed) / expected;
61
+ const precision = negatives === 0 ? 1 : (negatives - wrong) / negatives;
62
+ const clean = missed === 0 && cases.every((c) => c.unexpected.length === 0);
63
+ return { cases, expected, missed, negatives, wrong, recall, precision, clean };
64
+ }
@@ -178,6 +178,7 @@ const FACTS = [
178
178
  { name: 'has-pubspec', kind: 'file', paths: ['pubspec.yaml'] },
179
179
  { name: 'has-swift-package', kind: 'file', paths: ['Package.swift'] },
180
180
  { name: 'has-mix', kind: 'file', paths: ['mix.exs'] },
181
+ { name: 'has-deno-config', kind: 'file', paths: ['deno.json', 'deno.jsonc'] },
181
182
  { name: 'has-bun-lock', kind: 'file', paths: ['bun.lock', 'bun.lockb'] },
182
183
  // A glob walk skips dot directories, and `.devcontainer` is one. Stat the path.
183
184
  { name: 'has-devcontainer', kind: 'path', paths: ['.devcontainer/devcontainer.json'] },
@@ -10,7 +10,7 @@
10
10
  // suggestion that far away is noise: an agent will install the wrong skill.
11
11
 
12
12
  /** Levenshtein distance, two-row, with an early exit once the bound is passed. */
13
- export function editDistance(a, b, max = Infinity) {
13
+ function editDistance(a, b, max = Infinity) {
14
14
  if (a === b) return 0;
15
15
  if (Math.abs(a.length - b.length) > max) return max + 1;
16
16
 
@@ -38,22 +38,41 @@ export function editDistance(a, b, max = Infinity) {
38
38
  /**
39
39
  * Candidates worth suggesting, nearest first.
40
40
  *
41
- * A substring hit is always included: `skill` is a real answer for a query of
42
- * `slice-commit`, and the distance test alone would rank it below noise.
41
+ * Two directions of substring, and they are not the same claim about what was typed.
42
+ *
43
+ * **The candidate contains the query** — `slice-commit` for `slice-commmit` — is a
44
+ * hit: the agent typed a prefix. It scores 1, not 0, because zero edits is what an
45
+ * exact match means and a prefix is not one. Scoring both 0 sorted `graph` ahead of
46
+ * `graphs` for the query `graphs`, and an agent that installs the first name in a
47
+ * "did you mean" list installs the wrong one.
48
+ *
49
+ * **The candidate sits inside the query** — `coverage` for `coverage-hunt` — is a hit
50
+ * only when it is at least half the query. Without that, the rule degenerates: `e` is
51
+ * a substring of `test`, so `nearMatches('test', ['e'])` returned `e` at distance 0
52
+ * and the message read `did you mean e?`.
53
+ *
54
+ * The bound also shrinks with the query, which is the same failure from the other side.
55
+ * Three edits is a typo in a name; three edits to a four-character query is a
56
+ * different string, and offering it violates the rule in the header of this file — "a
57
+ * suggestion that far away is noise: an agent will install the wrong skill". Under
58
+ * three characters nothing is a typo, so nothing is offered.
43
59
  */
44
60
  export function nearMatches(needle, candidates, { max = 3, limit = 3 } = {}) {
45
61
  if (!needle) return [];
46
62
  const lower = needle.toLowerCase();
63
+ const bound = Math.max(1, Math.min(max, Math.floor(lower.length / 3)));
47
64
 
48
65
  const scored = [];
49
66
  for (const candidate of candidates) {
50
67
  const cl = candidate.toLowerCase();
51
- if (cl.includes(lower) || lower.includes(cl)) {
68
+ if (cl === lower) {
52
69
  scored.push({ candidate, distance: 0 });
53
- continue;
70
+ } else if (cl.includes(lower) || (lower.includes(cl) && cl.length * 2 >= lower.length)) {
71
+ scored.push({ candidate, distance: 1 });
72
+ } else {
73
+ const distance = editDistance(lower, cl, bound);
74
+ if (distance <= bound) scored.push({ candidate, distance });
54
75
  }
55
- const distance = editDistance(lower, cl, max);
56
- if (distance <= max) scored.push({ candidate, distance });
57
76
  }
58
77
 
59
78
  scored.sort((a, b) => a.distance - b.distance || a.candidate.localeCompare(b.candidate));
@@ -210,7 +210,7 @@ export function stackCompatible(skill, repoSignals) {
210
210
  * agent knows it is standing in that repo, so name the overlapping tag when there
211
211
  * is one and say plainly when there is not.
212
212
  */
213
- export function stackReason(skill, repoSignals) {
213
+ function stackReason(skill, repoSignals) {
214
214
  if (repoSignals.length === 0) return 'repo signals undetected, not excluded';
215
215
  const repo = new Set(repoSignals);
216
216
  const overlap = (skill.stack ?? []).filter((tag) => repo.has(tag));
@@ -35,7 +35,7 @@ import { measureSkill } from '../graph/load.mjs';
35
35
  *
36
36
  * @returns {{path: string, how: 'workspace'|'repo'}[]}
37
37
  */
38
- export function candidateLocations(source, { workspaceRoot = null, root = null } = {}) {
38
+ function candidateLocations(source, { workspaceRoot = null, root = null } = {}) {
39
39
  const out = [];
40
40
  if (workspaceRoot) out.push({ path: join(workspaceRoot, source.repo, source.path), how: 'workspace' });
41
41
  if (root) out.push({ path: join(root, source.path), how: 'repo' });
package/src/workspace.mjs CHANGED
@@ -252,15 +252,6 @@ function absolute(p) {
252
252
  return isAbsolute(expanded) ? expanded : resolve(expanded);
253
253
  }
254
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
255
  /**
265
256
  * The path a multi-repo command should work on: `--root` or the first positional if
266
257
  * given, else the resolved workspace, else the current directory.