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,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
|
+
}
|