simt-dev 0.2.0 → 0.2.2
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 +375 -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/ADR-064-the-context-meter-counts-what-the-tool-wrote.md +107 -0
- package/docs/decisions/ADR-065-a-pack-is-one-shared-rule-set-pinned-by-digest.md +97 -0
- package/docs/decisions/ADR-066-a-monorepo-projects-once-per-package.md +115 -0
- package/docs/decisions/ADR-067-which-harnesses-read-agents-md-is-a-column.md +110 -0
- package/docs/decisions/ADR-068-the-mcp-revision-list-cites-what-was-read.md +111 -0
- package/docs/decisions/ADR-069-the-conformance-lint-checks-the-published-specification.md +90 -0
- package/docs/decisions/ADR-070-the-json-payload-carries-its-own-version.md +91 -0
- package/docs/decisions/ADR-071-a-rule-that-can-be-narrowed-is-written-narrow.md +106 -0
- package/docs/decisions/ADR-072-the-budget-is-a-declaration-beside-the-gates.md +104 -0
- package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +92 -0
- package/docs/decisions/README.md +17 -0
- package/package.json +1 -2
- package/rules/core.md +1 -0
- package/rules/stacks/android.md +2 -0
- package/rules/stacks/cloudflare-workers.md +2 -0
- package/rules/stacks/connectiq.md +1 -0
- package/rules/stacks/container.md +2 -0
- package/rules/stacks/dart.md +40 -0
- package/rules/stacks/deno.md +38 -0
- package/rules/stacks/dotnet.md +37 -0
- package/rules/stacks/elixir.md +41 -0
- package/rules/stacks/go.md +36 -0
- package/rules/stacks/jvm.md +2 -0
- package/rules/stacks/node.md +2 -0
- package/rules/stacks/python.md +2 -0
- package/rules/stacks/react-native.md +2 -0
- package/rules/stacks/react-web.md +2 -0
- package/rules/stacks/ruby.md +38 -0
- package/rules/stacks/rust.md +36 -0
- package/rules/stacks/swift.md +39 -0
- package/rules/stacks/typescript.md +2 -0
- package/rules/stacks/uv.md +2 -0
- package/src/argv.mjs +54 -6
- package/src/ci/dependabot.mjs +1 -1
- package/src/ci/workflow.mjs +33 -11
- package/src/commands/compare.mjs +15 -0
- package/src/commands/graph.mjs +58 -25
- package/src/commands/harness.mjs +150 -0
- package/src/commands/help.mjs +37 -5
- package/src/commands/hooks.mjs +173 -57
- package/src/commands/index.mjs +95 -69
- package/src/commands/init.mjs +100 -20
- package/src/commands/mcp.mjs +20 -5
- package/src/commands/rules.mjs +96 -60
- package/src/commands/skills.mjs +189 -86
- package/src/commands/skillsinstall.mjs +20 -5
- package/src/commands/sync.mjs +213 -15
- package/src/commands/tokens.mjs +138 -0
- package/src/compare/hooksstate.mjs +96 -9
- package/src/compare/row.mjs +107 -7
- package/src/decisions/index.mjs +21 -6
- package/src/graph/load.mjs +125 -14
- package/src/graph/spec.mjs +227 -0
- package/src/graph/speccheck.mjs +68 -0
- package/src/hooks/capability.mjs +48 -4
- package/src/hooks/dispatcher.mjs +84 -7
- 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/schema.mjs +139 -0
- package/src/mcp/server.mjs +197 -20
- package/src/mcp/tools.mjs +290 -3
- package/src/measure/context.mjs +371 -0
- package/src/monorepo.mjs +339 -0
- package/src/output/index.mjs +86 -9
- package/src/package-version.mjs +74 -0
- package/src/packs.mjs +182 -0
- package/src/projection/conflicts.mjs +56 -5
- package/src/projection/manifest.mjs +335 -25
- package/src/projection/mdc.mjs +4 -13
- package/src/projection/project.mjs +139 -8
- package/src/projection/rules.mjs +308 -27
- package/src/projection/surfaces.mjs +133 -15
- package/src/projection/targets.mjs +90 -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
|
@@ -41,7 +41,11 @@
|
|
|
41
41
|
// #19377), and the documented examples are not valid YAML. Every rule
|
|
42
42
|
// shipped here is always-on, so the key would buy nothing and cost a
|
|
43
43
|
// silent-failure surface. Absence of frontmatter is documented to load
|
|
44
|
-
// eagerly, which is exactly what is wanted.
|
|
44
|
+
// eagerly, which is exactly what is wanted. (S50 ruled on this: a rule
|
|
45
|
+
// that carries a scope is emitted ONLY for surfaces whose scoping key is
|
|
46
|
+
// documented and parsed correctly, and Claude is not one of them, which
|
|
47
|
+
// is why `canScope` below excludes it rather than trusting `documentedKeys`
|
|
48
|
+
// to carry the reason.)
|
|
45
49
|
//
|
|
46
50
|
// 4. `verified` IS THE POINT OF THE TABLE. A row whose `verified` is null was
|
|
47
51
|
// not checked against a vendor document, and the row says so where a reader
|
|
@@ -57,6 +61,22 @@
|
|
|
57
61
|
* - `file` a wholly generated file; hand-written body text is retired
|
|
58
62
|
* - `pointer` a symlink to the primary target; carries no content of its own
|
|
59
63
|
*
|
|
64
|
+
* `readsAgentsMd` is a vendor fact, in three values rather than two:
|
|
65
|
+
* - `all` every reader this row names loads AGENTS.md
|
|
66
|
+
* - `some` some of them do, and `readsAgentsMdNote` says which
|
|
67
|
+
* - `none` none does, so the row is the only channel its readers have
|
|
68
|
+
*
|
|
69
|
+
* It is not `readsPrimary`, which asks a different question: that one is about
|
|
70
|
+
* whether a per-rule file would duplicate text the region already carries, and it is
|
|
71
|
+
* only meaningful while folding is on. This one is about the FILE, and it is what
|
|
72
|
+
* decides whether a new harness needs a row at all - a harness that reads AGENTS.md
|
|
73
|
+
* is supported by the standard surface, whatever else it reads.
|
|
74
|
+
*
|
|
75
|
+
* `configDirs` are the directories whose presence implies the harness is in use.
|
|
76
|
+
* Detection reads them and claims nothing else: a directory a harness may create for
|
|
77
|
+
* other reasons is a presence, not a proof, and the command says "present", never
|
|
78
|
+
* "installed".
|
|
79
|
+
*
|
|
60
80
|
* `ruleFrontmatter` returns the keys that editor reads BEFORE any rule text. A
|
|
61
81
|
* surface that needs none returns '' and the projected file starts at the body.
|
|
62
82
|
*/
|
|
@@ -82,6 +102,8 @@ export const SURFACES = [
|
|
|
82
102
|
// guessed path is the failure this table exists to prevent.
|
|
83
103
|
rule: null,
|
|
84
104
|
foldRules: true,
|
|
105
|
+
readsAgentsMd: 'all',
|
|
106
|
+
configDirs: ['.opencode'],
|
|
85
107
|
skills: '.agents/skills',
|
|
86
108
|
documentedSkillsPaths: ['.opencode/skills', '.claude/skills', '.agents/skills'],
|
|
87
109
|
// Codex concatenates AGENTS.md from the root down and stops at
|
|
@@ -107,6 +129,8 @@ export const SURFACES = [
|
|
|
107
129
|
primary: null,
|
|
108
130
|
kind: 'file',
|
|
109
131
|
rule: '.cursor/rules/{name}.mdc',
|
|
132
|
+
readsAgentsMd: 'all',
|
|
133
|
+
configDirs: ['.cursor'],
|
|
110
134
|
// `documentedKeys` is the set the vendor's own docs name for this surface, and
|
|
111
135
|
// `ruleFrontmatter` must emit nothing outside it. A key invented here would be
|
|
112
136
|
// inert at best; on Antigravity an unrecognised value silently discards the
|
|
@@ -129,12 +153,17 @@ export const SURFACES = [
|
|
|
129
153
|
// value. One body is written for all of them, so a Cursor name must not land
|
|
130
154
|
// in a file Claude Code also reads.
|
|
131
155
|
substitutions: { askTool: 'Use the interactive `AskQuestion` tool' },
|
|
132
|
-
ruleFrontmatter: (rule) =>
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
156
|
+
ruleFrontmatter: (rule) => [
|
|
157
|
+
`description: ${rule.description ?? `Canonical ${rule.id} rule managed by simt-dev`}`,
|
|
158
|
+
// `globs` only appears when the rule declares a scope. Cursor attaches a rule
|
|
159
|
+
// with no globs on the strength of `alwaysApply` alone, so emitting either
|
|
160
|
+
// unquoted patterns on an eager rule or no globs at all on a scoped one is
|
|
161
|
+
// asking for the wrong loading behaviour.
|
|
162
|
+
...(rule.scope ? [`globs: ${rule.scope.map((g) => `"${g}"`).join(', ')}`] : []),
|
|
163
|
+
`alwaysApply: ${rule.alwaysApply ? 'true' : 'false'}`,
|
|
164
|
+
],
|
|
165
|
+
verified:
|
|
166
|
+
'Cursor project rules (.cursor/rules/*.mdc, description + alwaysApply + globs); alwaysApply: false with globs is "auto-attached when a matching file is in context", and the docs warn that alwaysApply: false with no description is manual @-mention only',
|
|
138
167
|
note: 'The only surface that reads `.mdc`. Every other row here is a plain `.md` or a symlink.',
|
|
139
168
|
},
|
|
140
169
|
{
|
|
@@ -144,6 +173,16 @@ export const SURFACES = [
|
|
|
144
173
|
primary: null,
|
|
145
174
|
kind: 'file',
|
|
146
175
|
rule: '.claude/rules/{name}.md',
|
|
176
|
+
// `some`, not `none`. It reads AGENTS.md - the same docs say CLAUDE.md is used only
|
|
177
|
+
// when no AGENTS.md exists - but only on that condition, so a repository that has a
|
|
178
|
+
// hand-written CLAUDE.md is read by neither. That is why this row still needs its
|
|
179
|
+
// own channel and a pointer: the fallback is not a channel, it is the absence of
|
|
180
|
+
// one, and a reader who relied on it would be relying on the reader deleting a
|
|
181
|
+
// file they wrote.
|
|
182
|
+
readsAgentsMd: 'some',
|
|
183
|
+
readsAgentsMdNote:
|
|
184
|
+
'CLAUDE.md is read first; AGENTS.md is read only when there is no CLAUDE.md, which is why the pointer exists',
|
|
185
|
+
configDirs: ['.claude'],
|
|
147
186
|
// Intentionally no `paths:`. See decision 3 in the header.
|
|
148
187
|
documentedKeys: ['paths', 'description'],
|
|
149
188
|
ruleFrontmatter: () => [],
|
|
@@ -163,6 +202,8 @@ export const SURFACES = [
|
|
|
163
202
|
primary: null,
|
|
164
203
|
kind: 'file',
|
|
165
204
|
rule: '.agents/rules/{name}.md',
|
|
205
|
+
readsAgentsMd: 'all',
|
|
206
|
+
configDirs: ['.agents'],
|
|
166
207
|
// `trigger` is REQUIRED here and an unrecognised value discards the rule with no
|
|
167
208
|
// message, which is why the value is mapped rather than passed through.
|
|
168
209
|
documentedKeys: ['trigger', 'description', 'globs', 'glob'],
|
|
@@ -173,10 +214,15 @@ export const SURFACES = [
|
|
|
173
214
|
// what makes them duplicates rather than a fallback.
|
|
174
215
|
readsPrimary: true,
|
|
175
216
|
ruleFrontmatter: (rule) => [
|
|
176
|
-
`trigger:
|
|
217
|
+
// `trigger: glob` carries the patterns, and they are quoted because YAML reads a
|
|
218
|
+
// value starting with `*` as an alias anchor — the Antigravity docs require it, and
|
|
219
|
+
// an invalid frontmatter block is discarded with no message at all.
|
|
220
|
+
`trigger: ${rule.scope ? 'glob' : rule.alwaysApply ? 'always_on' : 'model_decision'}`,
|
|
221
|
+
...(rule.scope ? [`globs: ${rule.scope.map((g) => `"${g}"`).join(', ')}`] : []),
|
|
177
222
|
`description: ${rule.description ?? `Canonical ${rule.id} rule managed by simt-dev`}`,
|
|
178
223
|
],
|
|
179
|
-
verified:
|
|
224
|
+
verified:
|
|
225
|
+
'Google Antigravity docs, "Rules" — .agents/rules/*.md, YAML frontmatter with trigger: always_on | model_decision | glob | manual; missing or invalid frontmatter silently discards the rule; trigger: glob takes comma-separated globs, which the docs require to be quoted so YAML does not read a leading * as an alias anchor; a 24 KB per-file limit and a 20,000-token budget for always_on rules, with automatic demotion to pointers past it',
|
|
180
226
|
note:
|
|
181
227
|
'This is why frontmatter is mandatory here and why the trigger value is not guessed: an unrecognised trigger means the rule is dropped with no message.',
|
|
182
228
|
},
|
|
@@ -187,10 +233,18 @@ export const SURFACES = [
|
|
|
187
233
|
primary: null,
|
|
188
234
|
kind: 'file',
|
|
189
235
|
rule: '.github/instructions/{name}.instructions.md',
|
|
236
|
+
// Copilot is why the column is three values and not two. Its CLI reads AGENTS.md
|
|
237
|
+
// and its editor and code review do not, so the row both needs its own channel and
|
|
238
|
+
// cannot be described as "reads AGENTS.md" without a reader reading that claim as
|
|
239
|
+
// "this repo is covered for Copilot", which for two of its three readers it is not.
|
|
240
|
+
readsAgentsMd: 'some',
|
|
241
|
+
readsAgentsMdNote:
|
|
242
|
+
'Copilot CLI reads AGENTS.md; the editor, code review and the cloud agent do not',
|
|
243
|
+
configDirs: ['.github'],
|
|
190
244
|
documentedKeys: ['applyTo', 'excludeAgent'],
|
|
191
|
-
ruleFrontmatter: () => [
|
|
245
|
+
ruleFrontmatter: (rule) => [`applyTo: ${rule.scope ? `"${rule.scope.join(',')}"` : '"**"'}`],
|
|
192
246
|
verified:
|
|
193
|
-
'GitHub docs, "Adding repository custom instructions" — .github/instructions/NAME.instructions.md with applyTo globs; `applyTo: "**"` is the documented example',
|
|
247
|
+
'GitHub docs, "Adding repository custom instructions" — .github/instructions/NAME.instructions.md with applyTo globs; `applyTo: "**"` is the documented example, and a comma-separated pattern list is how more than one is named',
|
|
194
248
|
// NOT marked readsPrimary, and this row is the reason the flag is per-surface
|
|
195
249
|
// rather than global. The reader is split: Copilot CLI reads AGENTS.md, the
|
|
196
250
|
// editor and code review do not. A per-rule file here is not duplication for two
|
|
@@ -277,6 +331,27 @@ export function foldsRules(resolved) {
|
|
|
277
331
|
return resolved.surfaces.some((s) => s.foldRules === true);
|
|
278
332
|
}
|
|
279
333
|
|
|
334
|
+
/**
|
|
335
|
+
* Whether this surface can load a rule only when a matching file is in play.
|
|
336
|
+
*
|
|
337
|
+
* Three do, and each for its own documented key: Cursor patterns with `globs` under
|
|
338
|
+
* `alwaysApply: false`, Copilot with `applyTo`, and Antigravity with `trigger: glob`
|
|
339
|
+
* plus `globs`. Claude Code does not — its `paths:` key has a documented parser
|
|
340
|
+
* defect in which quoted values and YAML sequences silently fail to lazy-load — and
|
|
341
|
+
* opencode cannot, because it has no per-rule file channel at all.
|
|
342
|
+
*
|
|
343
|
+
* The question is asked per surface rather than per key because the answer decides
|
|
344
|
+
* whether a scoped profile may leave the region: a profile written only as a scoped
|
|
345
|
+
* file would be absent from a reader that cannot scope it, and a rule nobody loads is
|
|
346
|
+
* worse than a rule that loads eagerly (ADR-071).
|
|
347
|
+
*/
|
|
348
|
+
export function canScope(surface) {
|
|
349
|
+
return surface.rule !== null && surface.rule !== undefined && SCOPE_KEYS.has(surface.id);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Surface ids whose scoping key was read in the vendor's own documentation. */
|
|
353
|
+
const SCOPE_KEYS = new Set(['cursor', 'antigravity', 'copilot']);
|
|
354
|
+
|
|
280
355
|
/**
|
|
281
356
|
* Surfaces whose reader already loads the primary document, so a per-rule file for
|
|
282
357
|
* them repeats text the region already carries.
|
|
@@ -295,6 +370,37 @@ export function readsPrimary(resolved) {
|
|
|
295
370
|
|
|
296
371
|
export const SURFACE_IDS = SURFACES.map((s) => s.id);
|
|
297
372
|
|
|
373
|
+
/**
|
|
374
|
+
* Config directories whose presence implies a harness is in use, for the ones this
|
|
375
|
+
* tool has no surface row for.
|
|
376
|
+
*
|
|
377
|
+
* Deliberately a list of DIRECTORY NAMES and nothing else. It carries no claim about
|
|
378
|
+
* what any of these readers loads: the review that proposed the command listed these
|
|
379
|
+
* harnesses as candidates and explicitly did not re-verify each vendor's current
|
|
380
|
+
* documentation, and a row built from that list would ship a `verified` field citing
|
|
381
|
+
* nothing — decoration that misdescribes the artefact, which is the failure this whole
|
|
382
|
+
* repository exists to prevent. A directory here means "a reader may be configured in
|
|
383
|
+
* this repository", never "this harness is installed" and never "its rules are not
|
|
384
|
+
* being loaded".
|
|
385
|
+
*
|
|
386
|
+
* `.github` is absent even though the Copilot row carries it: `.github` holds workflows
|
|
387
|
+
* and issues, so its presence says nothing about whether Copilot is used, and the
|
|
388
|
+
* Copilot row's own detection is not something this command is for.
|
|
389
|
+
*/
|
|
390
|
+
export const CANDIDATE_DIRS = [
|
|
391
|
+
{ dir: '.gemini', harness: 'Gemini CLI' },
|
|
392
|
+
{ dir: '.windsurf', harness: 'Windsurf' },
|
|
393
|
+
{ dir: '.zed', harness: 'Zed' },
|
|
394
|
+
{ dir: '.kiro', harness: 'Kiro' },
|
|
395
|
+
{ dir: '.cline', harness: 'Cline' },
|
|
396
|
+
{ dir: '.roo', harness: 'Roo Code' },
|
|
397
|
+
{ dir: '.junie', harness: 'Junie' },
|
|
398
|
+
{ dir: '.aider', harness: 'Aider' },
|
|
399
|
+
{ dir: '.continue', harness: 'Continue' },
|
|
400
|
+
{ dir: '.amp', harness: 'Amp' },
|
|
401
|
+
{ dir: '.goose', harness: 'Goose' },
|
|
402
|
+
];
|
|
403
|
+
|
|
298
404
|
/**
|
|
299
405
|
* Frontmatter keys a surface emits that its own vendor does not document.
|
|
300
406
|
*
|
|
@@ -313,10 +419,22 @@ export function undocumentedKeys(surfaceRows = SURFACES) {
|
|
|
313
419
|
for (const s of surfaceRows) {
|
|
314
420
|
if (!s.ruleFrontmatter) continue;
|
|
315
421
|
const documented = Array.isArray(s.documentedKeys) ? s.documentedKeys : [];
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
422
|
+
// Both shapes, because a scoping key is emitted only on the scoped shape: probing
|
|
423
|
+
// the eager one alone would pass a row that drops `globs` on a scoped rule, and the
|
|
424
|
+
// rule would then load on nothing.
|
|
425
|
+
const probes = [
|
|
426
|
+
{ id: 'probe', description: 'probe', alwaysApply: true },
|
|
427
|
+
{ id: 'probe', description: 'probe', alwaysApply: false, scope: ['src/**'] },
|
|
428
|
+
];
|
|
429
|
+
for (const rule of probes) {
|
|
430
|
+
for (const line of s.ruleFrontmatter(rule)) {
|
|
431
|
+
const key = String(line).split(':')[0].trim();
|
|
432
|
+
// One report per key, not per probe: the same invented key appears on both
|
|
433
|
+
// shapes, and a gate that prints it twice reads as two defects.
|
|
434
|
+
if (documented.includes(key) || bad.some((b) => b.includes(`key "${key}"`))) continue;
|
|
435
|
+
bad.push(
|
|
436
|
+
`${s.id} emits frontmatter key "${key}"; its vendor documents ${documented.join(', ') || '(nothing)'}`,
|
|
437
|
+
);
|
|
320
438
|
}
|
|
321
439
|
}
|
|
322
440
|
}
|
|
@@ -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
|
|
@@ -105,6 +136,43 @@ export async function ruleTargets(root, rule, body, sha, resolved, notes) {
|
|
|
105
136
|
return { targets };
|
|
106
137
|
}
|
|
107
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Per-rule files for the profiles that left the region, for the surfaces that can
|
|
141
|
+
* narrow them.
|
|
142
|
+
*
|
|
143
|
+
* A scoped profile is an ordinary rule with a `scope` on it, and that is the whole
|
|
144
|
+
* reason this is a thin wrapper over {@link ruleTargets} rather than a second
|
|
145
|
+
* construction: the byte-level concerns — frontmatter re-derived per surface, the
|
|
146
|
+
* region injected between the markers, the retired-prose note — are already decided
|
|
147
|
+
* there and must not be decided twice. A second implementation would drift from the
|
|
148
|
+
* first and the gate would report drift on a clean tree, which is the failure this
|
|
149
|
+
* module exists to prevent.
|
|
150
|
+
*
|
|
151
|
+
* The one thing it adds is the skip: a surface that folds the region carries the whole
|
|
152
|
+
* region already, so a scoped file is the duplication S12 removed — UNLESS the profile
|
|
153
|
+
* actually left the region, which is `narrowing`, the caller's answer, and which means
|
|
154
|
+
* the reader would otherwise get nothing.
|
|
155
|
+
*/
|
|
156
|
+
export async function scopedTargets(root, rules, sha, resolved, notes) {
|
|
157
|
+
const targets = [];
|
|
158
|
+
for (const rule of rules) {
|
|
159
|
+
// `alwaysApply: false` is what asks the surface to attach the file on the patterns
|
|
160
|
+
// rather than always. Cursor's documented reading of no description plus no globs is
|
|
161
|
+
// a manual @-mention, so a scoped rule carries its scope and the description it
|
|
162
|
+
// already had.
|
|
163
|
+
// `target` is what `ruleName` reads, and a stack profile has none: it is a plain
|
|
164
|
+
// `python.md`, not an `.mdc` in the rules directory. Without it the file is named
|
|
165
|
+
// `null.mdc`, which is exactly what the first version of this did.
|
|
166
|
+
const scoped = {
|
|
167
|
+
...rule,
|
|
168
|
+
target: rule.target ?? `${rule.id}.mdc`,
|
|
169
|
+
alwaysApply: false,
|
|
170
|
+
scope: rule.scope,
|
|
171
|
+
};
|
|
172
|
+
targets.push(...(await ruleTargets(root, scoped, rule.body, sha, resolved, notes)).targets);
|
|
173
|
+
}
|
|
174
|
+
return { targets };
|
|
175
|
+
}
|
|
108
176
|
|
|
109
177
|
/**
|
|
110
178
|
* Pointer targets: `CLAUDE.md` and `GEMINI.md` as symlinks to `AGENTS.md`.
|
|
@@ -201,11 +269,6 @@ export async function pointerState(root, pointer) {
|
|
|
201
269
|
return { file: pointer.file, state: 'mispointed', link, detail: `points at ${link}` };
|
|
202
270
|
}
|
|
203
271
|
|
|
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
272
|
// The per-surface path lives in surfaces.mjs next to the row that justifies it;
|
|
210
273
|
// re-exported so a caller building a target never has to know which module owns
|
|
211
274
|
// path construction as opposed to frontmatter construction.
|
|
@@ -216,6 +279,26 @@ export function extractFrontmatter(text) {
|
|
|
216
279
|
return m ? m[0].trimEnd() : null;
|
|
217
280
|
}
|
|
218
281
|
|
|
282
|
+
/**
|
|
283
|
+
* Put `frontmatter` where a frontmatter block already is, and leave every other byte
|
|
284
|
+
* exactly where it was.
|
|
285
|
+
*
|
|
286
|
+
* The block match deliberately stops at the closing `---` and not past it, so the
|
|
287
|
+
* replacement starts at the newline that follows — the blank line a formatter requires
|
|
288
|
+
* before an HTML comment survives belongs to the file, not to the block, and consuming
|
|
289
|
+
* it here is what made `inject` report `updated` forever on files whose bytes never
|
|
290
|
+
* changed.
|
|
291
|
+
*
|
|
292
|
+
* A file with no block gets one above its content. The separator below it is left to
|
|
293
|
+
* `inject`'s `seam`, which is the one place that knows what the surrounding document
|
|
294
|
+
* format requires.
|
|
295
|
+
*/
|
|
296
|
+
function swapFrontmatter(text, frontmatter) {
|
|
297
|
+
if (frontmatter === '') return text;
|
|
298
|
+
const m = /^---\n[\s\S]*?\n---/.exec(text);
|
|
299
|
+
return m === null ? `${frontmatter}\n${text}` : `${frontmatter}${text.slice(m[0].length)}`;
|
|
300
|
+
}
|
|
301
|
+
|
|
219
302
|
function bodyAfterFrontmatter(text) {
|
|
220
303
|
return text.replace(/^---\n[\s\S]*?\n---\n*/, '');
|
|
221
304
|
}
|
|
@@ -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.
|