simt-dev 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. python/simt_dev/__init__.py +32 -0
  2. python/simt_dev/cli.py +225 -0
  3. simt_dev/__init__.py +32 -0
  4. simt_dev/cli.py +225 -0
  5. simt_dev/vendored/CHANGELOG.md +166 -0
  6. simt_dev/vendored/bin/simt-dev.mjs +28 -0
  7. simt_dev/vendored/docs/decisions/ADR-001-push-requires-approval.md +42 -0
  8. simt_dev/vendored/docs/decisions/ADR-002-refactor-blast-radius.md +45 -0
  9. simt_dev/vendored/docs/decisions/ADR-003-test-breadth.md +46 -0
  10. simt_dev/vendored/docs/decisions/ADR-004-hook-owns-format.md +45 -0
  11. simt_dev/vendored/docs/decisions/ADR-005-file-length.md +42 -0
  12. simt_dev/vendored/docs/decisions/ADR-006-coverage-bar.md +59 -0
  13. simt_dev/vendored/docs/decisions/ADR-007-section-skeleton.md +54 -0
  14. simt_dev/vendored/docs/decisions/ADR-008-verdict-vocabulary.md +59 -0
  15. simt_dev/vendored/docs/decisions/ADR-009-skill-layout.md +48 -0
  16. simt_dev/vendored/docs/decisions/ADR-010-runner-invocation.md +56 -0
  17. simt_dev/vendored/docs/decisions/ADR-011-windows-support.md +52 -0
  18. simt_dev/vendored/docs/decisions/ADR-012-drop-windows-support.md +59 -0
  19. simt_dev/vendored/docs/decisions/ADR-013-genericity-by-facts-and-local-profiles.md +114 -0
  20. simt_dev/vendored/docs/decisions/ADR-014-workspace-is-configuration.md +211 -0
  21. simt_dev/vendored/docs/decisions/ADR-015-a-source-is-content-not-a-location.md +117 -0
  22. simt_dev/vendored/docs/decisions/ADR-016-a-divergent-rule-is-retired-not-merged.md +180 -0
  23. simt_dev/vendored/docs/decisions/ADR-017-a-declared-gate-must-be-runnable.md +89 -0
  24. simt_dev/vendored/docs/decisions/ADR-018-an-unset-hooks-path-is-not-an-absent-hook.md +183 -0
  25. simt_dev/vendored/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +110 -0
  26. simt_dev/vendored/docs/decisions/ADR-020-a-rule-is-written-per-surface-and-pointed-not-copied.md +100 -0
  27. simt_dev/vendored/docs/decisions/ADR-021-one-surface-per-repo.md +94 -0
  28. simt_dev/vendored/docs/decisions/ADR-022-a-key-simt-dev-reads-is-prefixed.md +98 -0
  29. simt_dev/vendored/docs/decisions/ADR-023-the-gate-ships-as-a-file-the-repo-owns.md +106 -0
  30. simt_dev/vendored/docs/decisions/ADR-024-simt-dev-overwrites-its-own-gate-file.md +111 -0
  31. simt_dev/vendored/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +153 -0
  32. simt_dev/vendored/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +117 -0
  33. simt_dev/vendored/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +130 -0
  34. simt_dev/vendored/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +131 -0
  35. simt_dev/vendored/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +120 -0
  36. simt_dev/vendored/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +114 -0
  37. simt_dev/vendored/docs/decisions/ADR-031-the-suite-stages-the-environment-it-needs.md +129 -0
  38. simt_dev/vendored/docs/decisions/ADR-032-provenance-is-a-content-digest-not-a-commit.md +141 -0
  39. simt_dev/vendored/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +82 -0
  40. simt_dev/vendored/docs/decisions/ADR-034-a-staged-tool-refuses-a-partially-staged-file.md +91 -0
  41. simt_dev/vendored/docs/decisions/ADR-035-one-write-decision-and-one-json-document.md +119 -0
  42. simt_dev/vendored/docs/decisions/ADR-036-a-writer-that-writes-says-so-and-the-instructions-naming-it-move-with-it.md +103 -0
  43. simt_dev/vendored/docs/decisions/ADR-037-unchanged-means-writing-would-not-change-a-byte.md +100 -0
  44. simt_dev/vendored/docs/decisions/ADR-038-the-gate-filter-belongs-to-every-manifest-writer.md +95 -0
  45. simt_dev/vendored/docs/decisions/ADR-039-a-ci-step-runs-a-form-that-can-fail.md +101 -0
  46. simt_dev/vendored/docs/decisions/ADR-040-the-last-writer-that-wrote-by-default-was-not-the-one-named.md +158 -0
  47. simt_dev/vendored/docs/decisions/ADR-041-the-suite-installs-the-package-in-order-to-test-it.md +92 -0
  48. simt_dev/vendored/docs/decisions/ADR-042-a-declared-glob-must-match-a-path-built-from-it.md +94 -0
  49. simt_dev/vendored/docs/decisions/ADR-043-a-keyword-is-a-phrase-and-two-letters-is-a-word.md +90 -0
  50. simt_dev/vendored/docs/decisions/ADR-044-compatibility-is-not-evidence.md +116 -0
  51. simt_dev/vendored/docs/decisions/ADR-045-a-dashboard-that-counts-a-defect.md +119 -0
  52. simt_dev/vendored/docs/decisions/ADR-046-one-rule-one-copy.md +140 -0
  53. simt_dev/vendored/docs/decisions/ADR-047-a-profile-selects-on-what-a-repository-runs-on.md +141 -0
  54. simt_dev/vendored/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +150 -0
  55. simt_dev/vendored/docs/decisions/ADR-049-a-ruling-that-a-later-record-moved-says-so-where-it-is-printed.md +153 -0
  56. simt_dev/vendored/docs/decisions/ADR-050-a-byte-count-is-not-a-region-and-updated-is-not-a-change-of-rules.md +102 -0
  57. simt_dev/vendored/docs/decisions/ADR-051-a-pointer-is-the-readers-only-route-so-it-is-not-a-convenience.md +134 -0
  58. simt_dev/vendored/docs/decisions/ADR-052-a-dependency-is-counted-against-the-budget-and-there-is-one-budget.md +101 -0
  59. simt_dev/vendored/docs/decisions/ADR-053-the-node-floor-is-22-20-0.md +49 -0
  60. simt_dev/vendored/docs/decisions/ADR-054-the-digest-covers-every-byte-that-leaves-the-package.md +100 -0
  61. simt_dev/vendored/docs/decisions/ADR-055-a-status-line-travels-so-it-may-not-name-a-location.md +98 -0
  62. simt_dev/vendored/docs/decisions/ADR-056-three-names-one-of-which-is-cheap-to-move.md +160 -0
  63. simt_dev/vendored/docs/decisions/ADR-057-an-identity-is-per-developer-and-a-repository-does-not-own-it.md +115 -0
  64. simt_dev/vendored/docs/decisions/ADR-058-one-tool-three-channels-and-the-order-is-forced.md +148 -0
  65. simt_dev/vendored/docs/decisions/README.md +530 -0
  66. simt_dev/vendored/graph/skills.graph.json +1622 -0
  67. simt_dev/vendored/package.json +43 -0
  68. simt_dev/vendored/rules/core.md +76 -0
  69. simt_dev/vendored/rules/mdc/intake-questions.mdc +50 -0
  70. simt_dev/vendored/rules/mdc/preview-postflight.mdc +61 -0
  71. simt_dev/vendored/rules/mdc/slice-commit.mdc +52 -0
  72. simt_dev/vendored/rules/mdc/test-gate.mdc +54 -0
  73. simt_dev/vendored/rules/stacks/android.md +63 -0
  74. simt_dev/vendored/rules/stacks/cloudflare-workers.md +65 -0
  75. simt_dev/vendored/rules/stacks/connectiq.md +62 -0
  76. simt_dev/vendored/rules/stacks/container.md +31 -0
  77. simt_dev/vendored/rules/stacks/jvm.md +54 -0
  78. simt_dev/vendored/rules/stacks/node.md +45 -0
  79. simt_dev/vendored/rules/stacks/python.md +48 -0
  80. simt_dev/vendored/rules/stacks/react-native.md +58 -0
  81. simt_dev/vendored/rules/stacks/react-web.md +61 -0
  82. simt_dev/vendored/rules/stacks/typescript.md +21 -0
  83. simt_dev/vendored/rules/stacks/uv.md +32 -0
  84. simt_dev/vendored/src/argv.mjs +116 -0
  85. simt_dev/vendored/src/ci/dependabot.mjs +268 -0
  86. simt_dev/vendored/src/ci/workflow.mjs +360 -0
  87. simt_dev/vendored/src/commands/ci.mjs +285 -0
  88. simt_dev/vendored/src/commands/compare.mjs +195 -0
  89. simt_dev/vendored/src/commands/graph.mjs +73 -0
  90. simt_dev/vendored/src/commands/help.mjs +108 -0
  91. simt_dev/vendored/src/commands/hooks.mjs +471 -0
  92. simt_dev/vendored/src/commands/index.mjs +241 -0
  93. simt_dev/vendored/src/commands/init.mjs +632 -0
  94. simt_dev/vendored/src/commands/mcp.mjs +63 -0
  95. simt_dev/vendored/src/commands/rules.mjs +374 -0
  96. simt_dev/vendored/src/commands/skills.mjs +647 -0
  97. simt_dev/vendored/src/commands/skillsinstall.mjs +515 -0
  98. simt_dev/vendored/src/commands/sync.mjs +331 -0
  99. simt_dev/vendored/src/commands/upgrade.mjs +202 -0
  100. simt_dev/vendored/src/compare/discover.mjs +212 -0
  101. simt_dev/vendored/src/compare/hooksstate.mjs +159 -0
  102. simt_dev/vendored/src/compare/row.mjs +299 -0
  103. simt_dev/vendored/src/decisions/index.mjs +536 -0
  104. simt_dev/vendored/src/graph/load.mjs +541 -0
  105. simt_dev/vendored/src/hooks/capability.mjs +129 -0
  106. simt_dev/vendored/src/hooks/dispatcher.mjs +268 -0
  107. simt_dev/vendored/src/hooks/gates.mjs +144 -0
  108. simt_dev/vendored/src/hooks/resolve.mjs +194 -0
  109. simt_dev/vendored/src/hooks/shim.mjs +140 -0
  110. simt_dev/vendored/src/hooks/where.mjs +118 -0
  111. simt_dev/vendored/src/identity.mjs +218 -0
  112. simt_dev/vendored/src/locate.mjs +75 -0
  113. simt_dev/vendored/src/mcp/server.mjs +167 -0
  114. simt_dev/vendored/src/mcp/tools.mjs +410 -0
  115. simt_dev/vendored/src/name.mjs +95 -0
  116. simt_dev/vendored/src/output/index.mjs +96 -0
  117. simt_dev/vendored/src/projection/baselines.mjs +209 -0
  118. simt_dev/vendored/src/projection/conflicts.mjs +205 -0
  119. simt_dev/vendored/src/projection/manifest.mjs +478 -0
  120. simt_dev/vendored/src/projection/markers.mjs +349 -0
  121. simt_dev/vendored/src/projection/mdc.mjs +327 -0
  122. simt_dev/vendored/src/projection/project.mjs +504 -0
  123. simt_dev/vendored/src/projection/rules.mjs +640 -0
  124. simt_dev/vendored/src/projection/surfaces.mjs +398 -0
  125. simt_dev/vendored/src/projection/targets.mjs +221 -0
  126. simt_dev/vendored/src/resolve/fingerprint.mjs +553 -0
  127. simt_dev/vendored/src/resolve/near.mjs +61 -0
  128. simt_dev/vendored/src/resolve/resolve.mjs +457 -0
  129. simt_dev/vendored/src/resolve/source.mjs +105 -0
  130. simt_dev/vendored/src/toolchain.mjs +202 -0
  131. simt_dev/vendored/src/tsconfig.mjs +314 -0
  132. simt_dev/vendored/src/upgrade/plan.mjs +203 -0
  133. simt_dev/vendored/src/workspace.mjs +289 -0
  134. simt_dev-0.2.0.dist-info/METADATA +150 -0
  135. simt_dev-0.2.0.dist-info/RECORD +138 -0
  136. simt_dev-0.2.0.dist-info/WHEEL +4 -0
  137. simt_dev-0.2.0.dist-info/entry_points.txt +2 -0
  138. simt_dev-0.2.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,457 @@
1
+ // Skill resolution: turn a repo and a task into a ranked, explained selection.
2
+ //
3
+ // Deterministic by construction (plan 0002 principle 1). No embeddings, no model
4
+ // call, no network: the same repo plus the same task always yields the same
5
+ // answer in the same order. A rules system whose behaviour changes when the model
6
+ // changes is not a rules system.
7
+ //
8
+ // Every selection carries the signal that produced it, so `skills why` is a
9
+ // formatting concern rather than a re-derivation.
10
+
11
+ import { matchesGlob as pathMatchesGlob } from 'node:path';
12
+ import { STACK_TAGS } from './fingerprint.mjs';
13
+
14
+ /** Signal weights. Deliberately few, so the ranking is explainable. */
15
+ const W = {
16
+ path: 100, // the diff touches a path the skill claims
17
+ keyword: 40, // the task names a keyword the skill claims
18
+ capability: 25, // the task maps to a capability the skill covers
19
+ stack: 15, // the repo's stack matches (already required to survive the gate)
20
+ name: 30, // the task names the skill itself
21
+ summary: 8, // weak: a word shared with the summary
22
+ };
23
+
24
+ /**
25
+ * The least score that may select a skill. Set to `capability` on purpose: below that,
26
+ * the only signals available are `summary` at 8 and `stack` at 15, and neither is
27
+ * evidence that the skill is about the task. Compatibility is a precondition - the gate
28
+ * already ran - so a score made only of compatibility is not a weak match, it is no
29
+ * match, and reporting it as one fills the budget with bodies nobody asked for.
30
+ */
31
+ export const MIN_SCORE = W.capability;
32
+
33
+ /**
34
+ * English function words, which appear in every summary this registry has and identify
35
+ * none of them.
36
+ *
37
+ * Applied to the summary signal ONLY. A keyword is an explicit declaration and a name is
38
+ * an identity, so suppressing those would hide something a person wrote on purpose;
39
+ * a summary is prose, and prose is full of words that are there for grammar. Measured:
40
+ * the task "fix a typo in the README" selected four skills on `summary:the` and
41
+ * `summary:in` alone. Hand-authored rather than derived, for the same reason the
42
+ * capability aliases are: a resolver nobody can review is not a resolver.
43
+ */
44
+ const STOPWORDS = new Set([
45
+ 'about', 'after', 'all', 'also', 'and', 'an', 'any', 'are', 'as', 'at', 'be', 'because',
46
+ 'been', 'before', 'being', 'both', 'but', 'by', 'can', 'could', 'did', 'do', 'does', 'each',
47
+ 'for', 'from', 'had', 'has', 'have', 'he', 'her', 'here', 'his', 'how', 'if', 'in', 'into',
48
+ 'is', 'it', 'its', 'just', 'like', 'make', 'many', 'may', 'me', 'more', 'most', 'must', 'my',
49
+ 'no', 'not', 'now', 'of', 'off', 'on', 'one', 'only', 'onto', 'or', 'other', 'our', 'out',
50
+ 'over', 'own', 'per', 'same', 'see', 'set', 'she', 'should', 'so', 'some', 'such', 'than',
51
+ 'that', 'the', 'their', 'them', 'then', 'there', 'these', 'they', 'this', 'those', 'to', 'too',
52
+ 'two', 'under', 'up', 'us', 'use', 'used', 'using', 'very', 'was', 'way', 'we', 'were', 'what',
53
+ 'when', 'where', 'which', 'while', 'who', 'why', 'will', 'with', 'within', 'without', 'would',
54
+ 'you', 'your',
55
+ ]);
56
+
57
+ /**
58
+ * Split a task string into comparable tokens.
59
+ *
60
+ * Length 2 is kept. The floor used to be 3, and it dropped the most specific words in
61
+ * this domain: `d1`, `r2`, `kv`, `ci` are two-letter vocabulary, not noise, and a task
62
+ * saying "fix d1 migration" lost the one word that identified the change. The cost is
63
+ * that six stopwords of two letters (`in on of or an so`) now reach the summary signal,
64
+ * which is S04's finding and not something this change can resolve on its own - the
65
+ * remedy there is a stopword list, not a longer minimum.
66
+ */
67
+ export function tokenize(text) {
68
+ return String(text ?? '')
69
+ .toLowerCase()
70
+ .split(/[^a-z0-9]+/)
71
+ .filter((t) => t.length > 1);
72
+ }
73
+
74
+ /**
75
+ * Whether `phrase` occurs in `tokens` as consecutive words.
76
+ *
77
+ * Keywords are compared one token at a time, so a multi-word keyword could never
78
+ * match: "durable object" tokenizes to two tokens, and neither is equal to the keyword
79
+ * as declared, so the declared keyword was unreachable however the task was phrased.
80
+ * 13 of the registry's 232 unique keywords were dead that way, including one declared
81
+ * on four separate skills. Matching as a phrase keeps them checkable and keeps
82
+ * determinism - there is no model and no similarity, only adjacency.
83
+ */
84
+ function hasPhrase(tokens, phrase) {
85
+ if (phrase.length === 0) return false;
86
+ if (phrase.length === 1) return tokens.includes(phrase[0]);
87
+ for (let i = 0; i + phrase.length <= tokens.length; i += 1) {
88
+ if (phrase.every((word, j) => tokens[i + j] === word)) return true;
89
+ }
90
+ return false;
91
+ }
92
+
93
+ /**
94
+ * Capability aliases: the words a task uses that mean a capability id.
95
+ * Hand-authored rather than inferred, for the same reason the rest of the
96
+ * resolver is hand-authored - it must be reviewable and stable.
97
+ */
98
+ const CAPABILITY_ALIASES = {
99
+ test: ['test', 'tests', 'testing', 'coverage', 'regression', 'e2e'],
100
+ commit: ['commit', 'commitment', 'commitments', 'committing'],
101
+ security: ['security', 'secure', 'auth', 'authentication', 'idor', 'injection', 'csp', 'cors', 'secret', 'secrets'],
102
+ accessibility: ['accessibility', 'a11y', 'wcag', 'screenreader', 'keyboard', 'rtl', 'aria'],
103
+ maps: ['map', 'maps', 'pmtiles', 'maplibre', 'gazetteer', 'atlas', 'country', 'countries', 'galaxy'],
104
+ contract: ['contract', 'openapi', 'schema', 'schemaalignment', 'api'],
105
+ persistence: ['migration', 'migrations', 'database', 'd1', 'room', 'sql', 'schema'],
106
+ build: ['build', 'gradle', 'compile', 'bundle'],
107
+ release: ['release', 'deploy', 'deployment', 'publish', 'ship'],
108
+ observability: ['observability', 'logs', 'logging', 'metrics', 'traces', 'slo', 'alerting'],
109
+ platform: ['wrangler', 'worker', 'workers', 'durable', 'queue', 'r2', 'kv', 'cloudflare'],
110
+ scaffold: ['scaffold', 'module', 'newfeature', 'scaffolding'],
111
+ 'feature-slice': ['route', 'endpoint', 'slice', 'feature'],
112
+ seeding: ['seed', 'seeding', 'fixture', 'demo', 'data'],
113
+ orchestration: ['orchestrate', 'coordinate', 'parallel', 'team', 'agents'],
114
+ documentation: ['documentation', 'docs', 'documenting'],
115
+ content: ['post', 'blog', 'article', 'content'],
116
+ positioning: ['press', 'marketing', 'presskit'],
117
+ wear: ['wear', 'watch', 'wearos', 'horologist'],
118
+ 'ui-design': ['ui', 'design', 'mockup', 'styling', 'visual'],
119
+ quality: ['quality', 'review', 'audit'],
120
+ auth: ['auth', 'authentication', 'login', 'signup', 'firebase', 'token'],
121
+ knowledge: ['graph', 'graphify', 'knowledge'],
122
+ };
123
+
124
+ /**
125
+ * @param {object} skill a registry node
126
+ * @param {object} context { repoStack: string[], taskTokens: string[], paths: string[] }
127
+ * @returns {{score: number, reasons: string[]}} score 0 means "not applicable"
128
+ */
129
+ export function scoreSkill(skill, context) {
130
+ const { repoStack = [], taskTokens = [], paths = [] } = context;
131
+ const repo = new Set(repoStack);
132
+ const reasons = [];
133
+ let evidence = 0;
134
+
135
+ // --- path signal: strongest, because the diff is ground truth ----------------
136
+ for (const glob of skill.paths ?? []) {
137
+ if (paths.some((p) => matchesGlob(p, glob))) {
138
+ evidence += W.path;
139
+ reasons.push(`path:${glob}`);
140
+ break;
141
+ }
142
+ }
143
+
144
+ // --- capability signal ------------------------------------------------------
145
+ const wanted = new Set();
146
+ for (const cap of skill.capabilities ?? []) {
147
+ for (const alias of CAPABILITY_ALIASES[cap] ?? [cap]) {
148
+ if (taskTokens.includes(alias)) wanted.add(cap);
149
+ }
150
+ }
151
+ for (const cap of skill.capabilities ?? []) {
152
+ if (wanted.has(cap)) {
153
+ evidence += W.capability;
154
+ reasons.push(`capability:${cap}`);
155
+ }
156
+ }
157
+
158
+ // --- keyword signal ---------------------------------------------------------
159
+ // A declared keyword is matched as a phrase, so the reason quotes the declaration
160
+ // rather than a token of it: `keyword:durable object` is what a reader can check
161
+ // against the registry, and `keyword:durable` would be a keyword nobody declared.
162
+ const keywords = skill.keywords ?? [];
163
+ const summaryWords = new Set(tokenize(skill.summary));
164
+ const nameWords = new Set(tokenize(skill.name));
165
+ for (const keyword of keywords) {
166
+ if (hasPhrase(taskTokens, tokenize(keyword))) {
167
+ evidence += W.keyword;
168
+ reasons.push(`keyword:${keyword}`);
169
+ }
170
+ }
171
+ for (const token of taskTokens) {
172
+ if (nameWords.has(token)) {
173
+ evidence += W.name;
174
+ reasons.push(`name:${token}`);
175
+ } else if (summaryWords.has(token) && !STOPWORDS.has(token)) {
176
+ evidence += W.summary;
177
+ reasons.push(`summary:${token}`);
178
+ }
179
+ }
180
+
181
+ // --- stack signal -----------------------------------------------------------
182
+ const overlap = (skill.stack ?? []).filter((tag) => repo.has(tag));
183
+ const stackScore = overlap.length > 0 ? W.stack * Math.min(overlap.length, 3) : 0;
184
+ if (stackScore > 0) reasons.push(`stack:${overlap.join('+')}`);
185
+
186
+ // `evidence` is the score without the stack contribution. Carrying both is what lets
187
+ // the selection rule exclude stack from its own threshold: with two overlapping tags,
188
+ // stack alone is 30, which clears a threshold of 25 on a skill the task never named.
189
+ return { score: evidence + stackScore, evidence, reasons };
190
+ }
191
+
192
+ /**
193
+ * Hard gate: a skill whose tags the repo does not have cannot be selected,
194
+ * unless it was forced. This is what stops the Cloudflare reference from being
195
+ * offered to a Kotlin repo, and it is why `d1` lives in keywords rather than
196
+ * stack - a tag fingerprint() cannot emit would gate on nothing.
197
+ *
198
+ * `repoSignals` is stack ∪ facts, so a skill may declare a platform tag or an
199
+ * observable fact and be gated correctly either way (ADR-013).
200
+ */
201
+ export function stackCompatible(skill, repoSignals) {
202
+ if (repoSignals.length === 0) return true; // unknown repo: do not exclude
203
+ const repo = new Set(repoSignals);
204
+ return (skill.stack ?? []).some((tag) => repo.has(tag));
205
+ }
206
+
207
+ /**
208
+ * A short, human-readable reason for excluding a skill. The NOT SELECTED block is
209
+ * only useful if the agent can see *why* - "not in repo" is unactionable when the
210
+ * agent knows it is standing in that repo, so name the overlapping tag when there
211
+ * is one and say plainly when there is not.
212
+ */
213
+ export function stackReason(skill, repoSignals) {
214
+ if (repoSignals.length === 0) return 'repo signals undetected, not excluded';
215
+ const repo = new Set(repoSignals);
216
+ const overlap = (skill.stack ?? []).filter((tag) => repo.has(tag));
217
+ if (overlap.length > 0) return `signal overlap ${overlap.join('+')}`;
218
+ return `needs one of ${(skill.stack ?? []).slice(0, 3).join('|')}, repo is ${repoSignals.slice(0, 3).join('|')}`;
219
+ }
220
+
221
+ /** Whether `filePath` matches `glob`.
222
+ *
223
+ * `path.matchesGlob` is the implementation (ADR-053). The segment translation lived
224
+ * here because that call needs Node `>=22.20.0`, which was above the floor this
225
+ * package declared.
226
+ *
227
+ * A trailing double star also matches the directory itself. The built-in requires a
228
+ * child, so `migrations` misses `migrations/**`, and that empty expansion is the
229
+ * path `graph lint` builds to prove the glob covers anything at all (ADR-042).
230
+ *
231
+ * `?`, `{` and `[` still match nothing. `path.matchesGlob` reads `?` as one character,
232
+ * which is how `src/a?.ts` matched `src/aq.ts`, and it reads braces as alternatives.
233
+ * The registry uses neither, and a glob the matcher will not honour has to fail
234
+ * `graph lint` rather than select the wrong files (ADR-042, ruling 4). */
235
+ export function matchesGlob(filePath, glob) {
236
+ if (/[?{[\\]/.test(glob)) return false;
237
+ if (pathMatchesGlob(filePath, glob)) return true;
238
+ if (glob.endsWith('/**')) return pathMatchesGlob(`${filePath}/x`, glob);
239
+ return false;
240
+ }
241
+
242
+ /**
243
+ * The paths a glob is required to match, built from the glob itself.
244
+ *
245
+ * The registry is hand-authored, so a glob nobody ever compiled is a glob that can
246
+ * never select anything, and it is invisible: `graph lint` reported the registry
247
+ * clean while 13 of its globs matched nothing. A declared glob has to be shown the
248
+ * paths it claims to cover. A star-star is expanded once in each of its two shapes,
249
+ * because a glob only ever checked against the empty expansion is the exact defect
250
+ * this exists for.
251
+ */
252
+ export function syntheticPathFor(glob) {
253
+ const join = (parts) => parts.filter(Boolean).join('/');
254
+ const paths = [''];
255
+ for (const segment of glob.split('/')) {
256
+ const next = [];
257
+ for (const prefix of paths) {
258
+ if (segment === '**') {
259
+ // Empty segments are dropped rather than joined, so a glob whose star-star
260
+ // sits at either end yields `migrations` and not `migrations/` - a path that
261
+ // would match only by accident, through the same optional group the real one
262
+ // uses.
263
+ next.push(join([prefix]), join([prefix, 'deep/nested']));
264
+ } else {
265
+ next.push(join([prefix, segment.replace(/\*/g, 'sample').replace(/\?/g, 'q')]));
266
+ }
267
+ }
268
+ paths.length = 0;
269
+ paths.push(...next);
270
+ }
271
+ return paths.filter((p) => p !== '');
272
+ }
273
+
274
+ /**
275
+ * Whether a score may select, or the reason it may not.
276
+ *
277
+ * One place, because three commands answer this question and the S10 contract table
278
+ * cannot assert a rule that three files each hold a copy of. `null` means select.
279
+ *
280
+ * Both halves are needed and neither is sufficient alone. `stack` is 15 per tag, so a
281
+ * repo matching three of a skill's tags scores 45 with nothing said about the task -
282
+ * a score threshold alone does not exclude it. Conversely the two-letter tokens S03
283
+ * restored put stopwords into the summary signal, and a substance rule alone would let
284
+ * `summary:the` through. Stack is a precondition, not evidence: the gate has already
285
+ * asked it, and asking twice is how "compatible" came to mean "relevant".
286
+ */
287
+ export function selectionVerdict(evidence, reasons) {
288
+ if (reasons.length === 0) return 'no matching signal';
289
+ const nonStack = reasons.filter((r) => !r.startsWith('stack:'));
290
+ if (nonStack.length === 0) {
291
+ return 'matches this repo by stack alone, which is compatibility, not evidence about the task';
292
+ }
293
+ if (evidence < MIN_SCORE) {
294
+ return `only ${nonStack.join(' ')}, which scores ${evidence}; ${MIN_SCORE} is the least score that selects`;
295
+ }
296
+ return null;
297
+ }
298
+
299
+ /**
300
+ * Rank skills for a task. Returns only skills that scored above zero or were
301
+ * forced, sorted by score then name so the order is total and stable.
302
+ *
303
+ * @returns {{selected: object[], excluded: object[]}}
304
+ */
305
+ export function rankSkills(skills, context, { forced = [] } = {}) {
306
+ const forcedSet = new Set(forced);
307
+ const byName = new Map(skills.map((s) => [s.name, s]));
308
+ const selected = [];
309
+ const excluded = [];
310
+
311
+ for (const skill of skills) {
312
+ const isForced = forcedSet.has(skill.name);
313
+ if (!isForced && !stackCompatible(skill, context.repoStack ?? [])) {
314
+ excluded.push({ skill, reason: stackReason(skill, context.repoStack) });
315
+ continue;
316
+ }
317
+ const { score, evidence, reasons } = scoreSkill(skill, context);
318
+ if (isForced) {
319
+ selected.push({ skill, score: Infinity, reasons: ['forced'] });
320
+ continue;
321
+ }
322
+ const verdict = selectionVerdict(evidence, reasons);
323
+ if (verdict !== null) {
324
+ excluded.push({ skill, reason: verdict });
325
+ continue;
326
+ }
327
+ selected.push({ skill, score, reasons });
328
+ }
329
+
330
+ selected.sort((a, b) => b.score - a.score || a.skill.name.localeCompare(b.skill.name));
331
+ excluded.sort((a, b) => a.skill.name.localeCompare(b.skill.name));
332
+ return { selected, excluded };
333
+ }
334
+
335
+ /**
336
+ * What actually gets inlined, given a budget.
337
+ *
338
+ * Exists because the arithmetic was written twice — once in `skills brief`, once in the
339
+ * MCP tool — and the two copies had already drifted, which is ADR-035's shape one level
340
+ * down: a promise about a *set* of readers holds only while something checks the set, and
341
+ * here the set was two loops that each read `entry.via.size > 0` and meant something
342
+ * slightly different by it.
343
+ *
344
+ * Three rules, each of which was previously implicit or wrong:
345
+ *
346
+ * 1. **Dependencies are counted against the budget.** The old loops let any dependency in
347
+ * regardless of size, on the reasoning that "a skill that is useless without its
348
+ * dependency is worse than a large context, because it silently misleads". That
349
+ * reasoning is right about *silently misleading* and wrong about the conclusion: the
350
+ * fix for a missing dependency is a line in the output, not an unbounded budget. For
351
+ * `--skill parallel-feature-team --budget 2000` the old arithmetic resolved 13,503
352
+ * bytes of skills against a 2,000-byte budget — 6.7× over, with no note.
353
+ * 2. **A group that does not fit is deferred together.** An entry that is a dependency
354
+ * (`via` is non-empty) takes its parent with it: showing the parent without the thing
355
+ * it requires is the misleading state rule 1 was written to avoid, and it is now
356
+ * avoided by dropping the parent rather than by ignoring the budget.
357
+ * 3. **A body that is too large to inline is a different thing.** `deferred` is derived
358
+ * from `deferredThreshold`, not from the budget, and it means "fetch this with
359
+ * `skills show`". It does not propagate: `--all` means take everything, and the
360
+ * pointer to a deferred dependency is exactly what a pointer is for. Only *budget*
361
+ * pressure moves a parent.
362
+ *
363
+ * @param {{skill: {name: string, bytes: number, deferred?: boolean}, via: Set<string>}[]} entries
364
+ * the closure, in selection order, parents before their dependencies
365
+ * @param {{budget: number, all?: boolean}} opts
366
+ * @returns {{picked: object[], deferred: object[], used: number}}
367
+ */
368
+ export function selectWithinBudget(entries, { budget, all = false }) {
369
+ // 'in' | 'out-budget' | 'out-large'. The distinction is rule 3 and it is the reason
370
+ // this is not one boolean.
371
+ const verdict = new Map();
372
+ for (const e of entries) {
373
+ if (e.skill.deferred) verdict.set(e.skill.name, 'out-large');
374
+ else if (all) verdict.set(e.skill.name, 'in');
375
+ }
376
+
377
+ // The byte total is accumulated in order, so the budget is spent by score — the same
378
+ // order the resolver produced, and the reason a later skill cannot take the bytes of an
379
+ // earlier one. `out-large` is left alone: it is not a budget question.
380
+ if (!all) {
381
+ let used = 0;
382
+ for (const e of entries) {
383
+ if (verdict.get(e.skill.name) === 'out-large') continue;
384
+ if (used + e.skill.bytes <= budget) {
385
+ verdict.set(e.skill.name, 'in');
386
+ used += e.skill.bytes;
387
+ } else {
388
+ verdict.set(e.skill.name, 'out-budget');
389
+ }
390
+ }
391
+ }
392
+
393
+ // Rule 2, to a fixpoint: a parent whose dependency did not fit is out too, and that can
394
+ // itself be a dependency of something further up.
395
+ for (let changed = true; changed; ) {
396
+ changed = false;
397
+ for (const e of entries) {
398
+ if (verdict.get(e.skill.name) !== 'out-budget') continue;
399
+ for (const parent of e.via) {
400
+ if (verdict.get(parent) === 'in') {
401
+ verdict.set(parent, 'out-budget');
402
+ changed = true;
403
+ }
404
+ }
405
+ }
406
+ }
407
+
408
+ const picked = [];
409
+ const deferred = [];
410
+ let used = 0;
411
+ for (const e of entries) {
412
+ if (verdict.get(e.skill.name) === 'in') {
413
+ picked.push(e);
414
+ used += e.skill.bytes;
415
+ } else {
416
+ deferred.push(e);
417
+ }
418
+ }
419
+ return { picked, deferred, used };
420
+ }
421
+
422
+ /** Expand a selection to its `requires` closure, always including hard deps. */
423
+ export function closure(selected, skills) {
424
+ const byName = new Map(skills.map((s) => [s.name, s]));
425
+ const out = new Map();
426
+ const walk = (entry, via) => {
427
+ const existing = out.get(entry.skill.name);
428
+ if (existing) {
429
+ if (via) existing.via.add(via);
430
+ return;
431
+ }
432
+ out.set(entry.skill.name, { skill: entry.skill, score: entry.score, reasons: new Set(entry.reasons), via: new Set(via ? [via] : []) });
433
+ for (const dep of entry.skill.requires ?? []) {
434
+ const target = byName.get(dep);
435
+ if (target) walk({ skill: target, score: 0, reasons: [] }, entry.skill.name);
436
+ }
437
+ };
438
+ for (const entry of selected) walk(entry, null);
439
+ return [...out.values()];
440
+ }
441
+
442
+ /** Resolve an alias chain to its canonical target. */
443
+ export function resolveAlias(name, skills) {
444
+ const byName = new Map(skills.map((s) => [s.name, s]));
445
+ let current = byName.get(name);
446
+ const seen = new Set();
447
+ while (current?.aliasOf) {
448
+ if (seen.has(current.name)) break;
449
+ seen.add(current.name);
450
+ const next = byName.get(current.aliasOf);
451
+ if (!next) break;
452
+ current = next;
453
+ }
454
+ return current ?? byName.get(name);
455
+ }
456
+
457
+ export { STACK_TAGS };
@@ -0,0 +1,105 @@
1
+ // Locating a registry source: the body a node points at.
2
+ //
3
+ // The registry declares `sources: [{repo, path, bytes, role}]`. Resolving one used
4
+ // to mean exactly one thing - join the workspace root onto `repo` and `path`. That
5
+ // is the only definition that works in the SIMT checkout and the only definition
6
+ // that works nowhere else, which is how `skills install` ended up refusing in a
7
+ // perfectly ordinary single-repo clone.
8
+ //
9
+ // The second location is the one nobody wrote down: **the repository being operated
10
+ // on is itself a location for a declared source**. Someone who cloned only
11
+ // one repository has its body on disk at `.agents/skills/slice-commit`
12
+ // with no workspace anywhere. The registry entry is satisfied by that file.
13
+ //
14
+ // Bytes are what make this safe rather than hopeful. Every source declares its size,
15
+ // and `graph lint` already verifies every declared size against the file on disk. So
16
+ // a candidate location is accepted only when the measured size equals the declared
17
+ // size. That also settles variant selection without a guess: the repo holding 5048
18
+ // bytes of `slice-commit` is holding the `web` variant, whatever its stack tags say.
19
+ //
20
+ // Two rules keep this from becoming a way to install the wrong body:
21
+ //
22
+ // 1. A workspace location wins over a repo location. The canonical body beats the
23
+ // incidental copy.
24
+ // 2. Content decides identity, never location. A repo-relative candidate whose
25
+ // bytes do not match is not a degraded match, it is a different file, and it is
26
+ // rejected rather than installed.
27
+
28
+ import { existsSync } from 'node:fs';
29
+ import { join } from 'node:path';
30
+
31
+ import { measureSkill } from '../graph/load.mjs';
32
+
33
+ /**
34
+ * Every place a declared source can legitimately be.
35
+ *
36
+ * @returns {{path: string, how: 'workspace'|'repo'}[]}
37
+ */
38
+ export function candidateLocations(source, { workspaceRoot = null, root = null } = {}) {
39
+ const out = [];
40
+ if (workspaceRoot) out.push({ path: join(workspaceRoot, source.repo, source.path), how: 'workspace' });
41
+ if (root) out.push({ path: join(root, source.path), how: 'repo' });
42
+ return out;
43
+ }
44
+
45
+ /**
46
+ * Find the file that satisfies a declared source.
47
+ *
48
+ * @returns {Promise<{path: string|null, how: string|null, bytes: number|null,
49
+ * reason: string|null, candidates: {path:string, how:string, bytes:number}[]}>}
50
+ */
51
+ export async function locateSource(source, options = {}) {
52
+ const candidates = candidateLocations(source, options);
53
+ const measured = [];
54
+
55
+ for (const candidate of candidates) {
56
+ if (!existsSync(candidate.path)) continue;
57
+ let size;
58
+ try {
59
+ size = (await measureSkill(candidate.path)).bytes;
60
+ } catch {
61
+ // A directory with no SKILL.md, or an unreadable path: not this source.
62
+ continue;
63
+ }
64
+ measured.push({ ...candidate, bytes: size });
65
+ // Declared bytes are the identity check. No declared bytes means the registry
66
+ // has nothing to verify against, so a size match cannot be claimed.
67
+ if (typeof source.bytes === 'number' && size === source.bytes) {
68
+ return { path: candidate.path, how: candidate.how, bytes: size, reason: null, candidates: measured };
69
+ }
70
+ }
71
+
72
+ return {
73
+ path: null,
74
+ how: null,
75
+ bytes: null,
76
+ reason: measured.length === 0 ? 'no candidate location exists' : 'no candidate has the declared size',
77
+ candidates: measured,
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Which declared source, if any, this repo already holds.
83
+ *
84
+ * This is a real signal rather than a fallback: a repo that carries 5048 bytes of
85
+ * `slice-commit` is carrying the `web` variant, and its own contents are a better
86
+ * answer than its stack tags. It is also what makes a single-repo clone work at all,
87
+ * since such a clone's `package.json` usually says nothing that distinguishes the two
88
+ * variants.
89
+ *
90
+ * @returns {Promise<{source: object, how: string, path: string, bytes: number}|null>}
91
+ */
92
+ export async function sourceHeldLocally(skill, root, { workspaceRoot = null } = {}) {
93
+ if (!root) return null;
94
+ const held = [];
95
+ for (const source of skill.sources ?? []) {
96
+ // Only the repo-relative location is in play here; a workspace hit is the
97
+ // workspace's business, not this repo's.
98
+ const found = await locateSource(source, { root });
99
+ if (found.path) held.push({ source, how: found.how, path: found.path, bytes: found.bytes });
100
+ }
101
+ // Two different declared sources both satisfied by this one repo means the
102
+ // registry and the repo disagree; the caller refuses rather than picking.
103
+ if (held.length !== 1) return held.length === 0 ? null : { ambiguous: true, held };
104
+ return held[0];
105
+ }