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