simt-dev 0.1.1 → 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 (91) hide show
  1. package/CHANGELOG.md +209 -7
  2. package/README.md +32 -5
  3. package/docs/decisions/ADR-001-push-requires-approval.md +5 -5
  4. package/docs/decisions/ADR-002-refactor-blast-radius.md +1 -1
  5. package/docs/decisions/ADR-003-test-breadth.md +4 -4
  6. package/docs/decisions/ADR-004-hook-owns-format.md +5 -5
  7. package/docs/decisions/ADR-005-file-length.md +5 -5
  8. package/docs/decisions/ADR-006-coverage-bar.md +3 -3
  9. package/docs/decisions/ADR-007-section-skeleton.md +5 -5
  10. package/docs/decisions/ADR-008-verdict-vocabulary.md +1 -1
  11. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  12. package/docs/decisions/ADR-010-runner-invocation.md +1 -1
  13. package/docs/decisions/ADR-012-drop-windows-support.md +2 -2
  14. package/docs/decisions/ADR-015-a-source-is-content-not-a-location.md +2 -2
  15. package/docs/decisions/ADR-016-a-divergent-rule-is-retired-not-merged.md +13 -13
  16. package/docs/decisions/ADR-017-a-declared-gate-must-be-runnable.md +2 -2
  17. package/docs/decisions/ADR-018-an-unset-hooks-path-is-not-an-absent-hook.md +9 -9
  18. package/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +34 -7
  19. package/docs/decisions/ADR-023-the-gate-ships-as-a-file-the-repo-owns.md +2 -2
  20. package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +5 -5
  21. package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +2 -2
  22. package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
  23. package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +2 -2
  24. package/docs/decisions/ADR-031-the-suite-stages-the-environment-it-needs.md +3 -3
  25. package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +4 -4
  26. package/docs/decisions/ADR-041-the-suite-installs-the-package-in-order-to-test-it.md +2 -2
  27. package/docs/decisions/ADR-045-a-dashboard-that-counts-a-defect.md +3 -3
  28. package/docs/decisions/ADR-046-one-rule-one-copy.md +5 -5
  29. package/docs/decisions/ADR-047-a-profile-selects-on-what-a-repository-runs-on.md +4 -4
  30. package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +5 -5
  31. package/docs/decisions/ADR-057-an-identity-is-per-developer-and-a-repository-does-not-own-it.md +115 -0
  32. package/docs/decisions/ADR-058-one-tool-three-channels-and-the-order-is-forced.md +148 -0
  33. package/docs/decisions/ADR-059-the-version-a-repository-runs-is-the-one-it-pinned.md +75 -0
  34. package/docs/decisions/ADR-060-a-repository-with-nothing-to-run-is-adopted-and-says-so.md +131 -0
  35. package/docs/decisions/ADR-061-the-skill-registry-is-workspace-data.md +113 -0
  36. package/docs/decisions/ADR-062-a-fresh-adoption-seeds-the-gates-it-can-run.md +94 -0
  37. package/docs/decisions/ADR-063-the-adoption-escape-hatch-is-opt-in.md +69 -0
  38. package/docs/decisions/README.md +20 -1
  39. package/package.json +1 -2
  40. package/rules/stacks/dart.md +38 -0
  41. package/rules/stacks/deno.md +36 -0
  42. package/rules/stacks/dotnet.md +35 -0
  43. package/rules/stacks/elixir.md +39 -0
  44. package/rules/stacks/go.md +34 -0
  45. package/rules/stacks/ruby.md +36 -0
  46. package/rules/stacks/rust.md +34 -0
  47. package/rules/stacks/swift.md +37 -0
  48. package/src/argv.mjs +35 -6
  49. package/src/ci/dependabot.mjs +1 -1
  50. package/src/ci/workflow.mjs +33 -11
  51. package/src/commands/graph.mjs +13 -4
  52. package/src/commands/help.mjs +22 -4
  53. package/src/commands/hooks.mjs +183 -67
  54. package/src/commands/index.mjs +53 -45
  55. package/src/commands/init.mjs +156 -14
  56. package/src/commands/rules.mjs +54 -3
  57. package/src/commands/skills.mjs +143 -15
  58. package/src/commands/skillsinstall.mjs +4 -4
  59. package/src/commands/sync.mjs +153 -11
  60. package/src/compare/discover.mjs +1 -1
  61. package/src/compare/hooksstate.mjs +96 -9
  62. package/src/compare/row.mjs +36 -7
  63. package/src/decisions/index.mjs +51 -12
  64. package/src/graph/load.mjs +126 -15
  65. package/src/hooks/capability.mjs +49 -5
  66. package/src/hooks/dispatcher.mjs +51 -6
  67. package/src/hooks/gates.mjs +1 -3
  68. package/src/hooks/resolve.mjs +24 -25
  69. package/src/hooks/shim.mjs +29 -5
  70. package/src/hooks/where.mjs +1 -1
  71. package/src/identity.mjs +207 -0
  72. package/src/mcp/tools.mjs +2 -2
  73. package/src/output/index.mjs +8 -7
  74. package/src/package-version.mjs +74 -0
  75. package/src/projection/baselines.mjs +1 -1
  76. package/src/projection/conflicts.mjs +57 -6
  77. package/src/projection/manifest.mjs +248 -26
  78. package/src/projection/markers.mjs +3 -3
  79. package/src/projection/mdc.mjs +6 -15
  80. package/src/projection/project.mjs +1 -1
  81. package/src/projection/rules.mjs +21 -8
  82. package/src/projection/surfaces.mjs +2 -2
  83. package/src/projection/targets.mjs +53 -7
  84. package/src/resolve/eval.mjs +64 -0
  85. package/src/resolve/fingerprint.mjs +4 -3
  86. package/src/resolve/near.mjs +26 -7
  87. package/src/resolve/resolve.mjs +1 -1
  88. package/src/resolve/source.mjs +2 -2
  89. package/src/tsconfig.mjs +1 -1
  90. package/src/workspace.mjs +0 -9
  91. package/graph/skills.graph.json +0 -1622
@@ -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
+ }
@@ -41,7 +41,7 @@ const MARKERS = [
41
41
  /**
42
42
  * Extensions are weaker evidence than marker files: a stray script must not
43
43
  * define a repo's stack. `python` is deliberately absent here because
44
- * simt-app carries .py tooling scripts inside a Gradle project - a marker file
44
+ * a repository carries .py tooling scripts inside a Gradle project - a marker file
45
45
  * is required for that tag. `kotlin` has no marker file, so it stays here.
46
46
  */
47
47
  const EXTENSION_TAGS = new Map([
@@ -83,7 +83,7 @@ const DEP_TAGS = new Map([
83
83
  *
84
84
  * Three, and the reason is a convention rather than a preference: the Android Gradle
85
85
  * Plugin puts a module's manifest at `<module>/src/main/AndroidManifest.xml`, and
86
- * simt-app's sits at `app/src/main/AndroidManifest.xml`. A bound shallower than the
86
+ * one common layout sits at `app/src/main/AndroidManifest.xml`. A bound shallower than the
87
87
  * convention makes `has-android-manifest` the shape S07 found - a fact no repository
88
88
  * could ever have. Deeper would be a scan rather than a probe, and ADR-013's promise
89
89
  * is that adding an ecosystem is data, so a fact must not cost a search.
@@ -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'] },
@@ -300,7 +301,7 @@ export async function fingerprint(root) {
300
301
  }
301
302
  }
302
303
 
303
- // simt-app and wakt-app both carry a wear/ module; wakt-app is the wearable.
304
+ // two repositories both carry a wear/ module; only one of them is the wearable.
304
305
  if (names.has('wear') && (await isDir(join(root, 'wear')))) {
305
306
  stack.add('wear-os');
306
307
  evidence.push('wear/');
@@ -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));
@@ -8,7 +8,7 @@
8
8
  //
9
9
  // The second location is the one nobody wrote down: **the repository being operated
10
10
  // on is itself a location for a declared source**. Someone who cloned only
11
- // `simt-social` has the `simt-social` body on disk at `.agents/skills/slice-commit`
11
+ // one repository has its body on disk at `.agents/skills/slice-commit`
12
12
  // with no workspace anywhere. The registry entry is satisfied by that file.
13
13
  //
14
14
  // Bytes are what make this safe rather than hopeful. Every source declares its size,
@@ -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/tsconfig.mjs CHANGED
@@ -32,7 +32,7 @@ export const TSCONFIG_FILE = 'tsconfig.json';
32
32
  * The strictness floor.
33
33
  *
34
34
  * Every key here is already set, and set the same way, by every `tsconfig.json` in the
35
- * estate that could afford it. `simt-goto` is the outlier and it is not one on purpose: it
35
+ * estate that could afford it. One repository is the outlier and it is not one on purpose: it
36
36
  * is missing the seven flags below that the three social repos share, which is the gap
37
37
  * plan section 3.1 row 5 named. Nothing in this object is a value anybody has argued about;
38
38
  * it is the intersection of what four repositories independently wrote.
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.