simt-dev 0.2.1 → 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 +202 -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/package.json +1 -1
- 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 +2 -0
- package/rules/stacks/deno.md +2 -0
- package/rules/stacks/dotnet.md +2 -0
- package/rules/stacks/elixir.md +2 -0
- package/rules/stacks/go.md +2 -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 +2 -0
- package/rules/stacks/rust.md +2 -0
- package/rules/stacks/swift.md +2 -0
- package/rules/stacks/typescript.md +2 -0
- package/rules/stacks/uv.md +2 -0
- package/src/argv.mjs +19 -0
- package/src/commands/compare.mjs +15 -0
- package/src/commands/graph.mjs +45 -21
- package/src/commands/harness.mjs +150 -0
- package/src/commands/help.mjs +15 -1
- package/src/commands/index.mjs +43 -25
- package/src/commands/init.mjs +12 -8
- package/src/commands/mcp.mjs +20 -5
- package/src/commands/rules.mjs +42 -57
- package/src/commands/skills.mjs +67 -92
- package/src/commands/skillsinstall.mjs +17 -2
- package/src/commands/sync.mjs +62 -6
- package/src/commands/tokens.mjs +138 -0
- package/src/compare/row.mjs +73 -2
- package/src/graph/spec.mjs +227 -0
- package/src/graph/speccheck.mjs +68 -0
- package/src/hooks/dispatcher.mjs +35 -3
- package/src/mcp/schema.mjs +139 -0
- package/src/mcp/server.mjs +197 -20
- package/src/mcp/tools.mjs +288 -1
- package/src/measure/context.mjs +371 -0
- package/src/monorepo.mjs +339 -0
- package/src/output/index.mjs +78 -2
- package/src/packs.mjs +182 -0
- package/src/projection/manifest.mjs +88 -0
- package/src/projection/project.mjs +140 -9
- package/src/projection/rules.mjs +288 -20
- package/src/projection/surfaces.mjs +133 -15
- package/src/projection/targets.mjs +37 -0
package/src/projection/rules.mjs
CHANGED
|
@@ -27,10 +27,13 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
27
27
|
|
|
28
28
|
import { fingerprint, KNOWN_TAGS } from '../resolve/fingerprint.mjs';
|
|
29
29
|
import { loadMdcRules, renderMdc } from './mdc.mjs';
|
|
30
|
-
import { foldsRules, frontmatterBlock, resolveSurfaces } from './surfaces.mjs';
|
|
30
|
+
import { foldsRules, frontmatterBlock, resolveSurfaces, rulePath, canScope } from './surfaces.mjs';
|
|
31
31
|
import { ruleTargets } from './targets.mjs';
|
|
32
|
-
import { manifestPath } from './manifest.mjs';
|
|
32
|
+
import { manifestPath, readManifest } from './manifest.mjs';
|
|
33
33
|
import { BASELINES } from './baselines.mjs';
|
|
34
|
+
import { CLI } from '../name.mjs';
|
|
35
|
+
import { PACK_DIR, resolvePacks } from '../packs.mjs';
|
|
36
|
+
import { discoverPackages } from '../monorepo.mjs';
|
|
34
37
|
|
|
35
38
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
36
39
|
export const ROOT = resolve(HERE, '..', '..');
|
|
@@ -123,12 +126,37 @@ async function walk(dir, prefix = '') {
|
|
|
123
126
|
/** Repo-local profile directory, relative to a repo root (ADR-013 section 2). */
|
|
124
127
|
export const LOCAL_PROFILE_DIR = '.simt-dev/profiles';
|
|
125
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Which origin outranks which when two profiles claim one `simt-id`.
|
|
131
|
+
*
|
|
132
|
+
* A pack is the repository's shared policy and the built-in set is the tool's
|
|
133
|
+
* opinion, so a repository can replace either and a pack can replace what the
|
|
134
|
+
* tool ships. Read in one place because the answer to "whose rule is this?" is
|
|
135
|
+
* asked on every rule of every projection, and a precedence that lives in two
|
|
136
|
+
* places is a precedence that will disagree with itself.
|
|
137
|
+
*/
|
|
138
|
+
const ORIGIN_RANK = { central: 0, pack: 1, local: 2 };
|
|
139
|
+
|
|
126
140
|
/** Every key a stack profile or core rule may declare is this tool's own, so every
|
|
127
141
|
* one carries the `simt-` prefix (ADR-022). A profile carrying a bare key would read
|
|
128
142
|
* as an editor's instruction — Cursor, Copilot and Antigravity all parse a
|
|
129
143
|
* frontmatter block the same way — and would be silently inert. */
|
|
130
144
|
/** Frontmatter keys a rule profile declares. See {@link KNOWN_KEYS} on why exported. */
|
|
131
|
-
export const PROFILE_KEYS = new Set(['simt-id', 'simt-title', 'simt-applies', 'simt-fallback']);
|
|
145
|
+
export const PROFILE_KEYS = new Set(['simt-id', 'simt-title', 'simt-applies', 'simt-fallback', 'simt-scope', 'simt-priority']);
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Priority classes, packed in this order (plan 0003 R07).
|
|
149
|
+
*
|
|
150
|
+
* A class is not a severity, it is a promise about what a reader loses by not having
|
|
151
|
+
* the line. A hard constraint is one whose absence produces a defect; a command is one
|
|
152
|
+
* the reader cannot guess; a convention is right to follow and harmless to miss; a style
|
|
153
|
+
* is a preference. Anything after `command` is the first thing a kernel drops, and that
|
|
154
|
+
* ordering is the whole content of the mode — everything else about it is bookkeeping.
|
|
155
|
+
*/
|
|
156
|
+
export const PRIORITY_CLASSES = ['hard', 'command', 'convention', 'style'];
|
|
157
|
+
|
|
158
|
+
/** The classes a kernel keeps. `full` mode keeps all four and is the default. */
|
|
159
|
+
export const KERNEL_CLASSES = new Set(['hard', 'command']);
|
|
132
160
|
|
|
133
161
|
/** Report the first thing wrong with a profile's frontmatter, in the order a reader
|
|
134
162
|
* fixes it: no id to select it by, then a key that will not be read, then a tag the
|
|
@@ -147,14 +175,33 @@ function describeDefect(meta) {
|
|
|
147
175
|
const prefixed = `simt-${unknown.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`;
|
|
148
176
|
return PROFILE_KEYS.has(prefixed)
|
|
149
177
|
? `key "${unknown}" is this tool's own, not the editor's; it is spelled "${prefixed}"`
|
|
150
|
-
: `unrecognised frontmatter key "${unknown}"; simt-dev's own keys are simt-id, simt-title, simt-applies, simt-fallback`;
|
|
178
|
+
: `unrecognised frontmatter key "${unknown}"; simt-dev's own keys are simt-id, simt-title, simt-applies, simt-fallback, simt-scope`;
|
|
151
179
|
}
|
|
152
180
|
const applies = meta['simt-applies'];
|
|
153
181
|
if (!Array.isArray(applies)) return null;
|
|
154
182
|
const undeclared = applies.find((tag) => !KNOWN_TAGS.has(tag));
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
183
|
+
if (undeclared !== undefined) {
|
|
184
|
+
return `simt-applies names "${undeclared}", which fingerprint() never emits, so this profile could never be selected`;
|
|
185
|
+
}
|
|
186
|
+
const priority = meta['simt-priority'];
|
|
187
|
+
if (priority !== undefined && !PRIORITY_CLASSES.includes(priority)) {
|
|
188
|
+
return `simt-priority is "${priority}"; the classes are ${PRIORITY_CLASSES.join(', ')}, in the order a kernel packs them`;
|
|
189
|
+
}
|
|
190
|
+
const scope = meta['simt-scope'];
|
|
191
|
+
if (scope === undefined) return null;
|
|
192
|
+
// A scope that matches the whole repository is the eager profile wearing a costume:
|
|
193
|
+
// the reader is told the rule narrows, and the rule loads on every file. `**` and
|
|
194
|
+
// `**/*` are the two spellings of that, and both are the default a surface applies
|
|
195
|
+
// when it is given no pattern at all.
|
|
196
|
+
const CATCH_ALL = new Set(['**', '**/*', '*']);
|
|
197
|
+
if (scope.length === 0) {
|
|
198
|
+
return 'simt-scope is empty; a profile with no pattern would load as eagerly as one with no simt-scope at all';
|
|
199
|
+
}
|
|
200
|
+
const bad = scope.find((glob) => typeof glob !== 'string' || glob === '' || CATCH_ALL.has(glob));
|
|
201
|
+
if (bad !== undefined) {
|
|
202
|
+
return `simt-scope needs at least one pattern that narrows; "${bad}" matches every file, so the profile would load exactly as eagerly as one with no simt-scope`;
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
158
205
|
}
|
|
159
206
|
|
|
160
207
|
/**
|
|
@@ -371,21 +418,88 @@ export async function desiredRegion(root, flags) {
|
|
|
371
418
|
const { stack, facts, manifest } = await fingerprint(root);
|
|
372
419
|
const all = await loadRules(root);
|
|
373
420
|
const { selected, fallback, notes } = select(all, stack, facts);
|
|
374
|
-
|
|
375
|
-
let body = render(selected);
|
|
376
|
-
let sources = selected.map((r) => r.source);
|
|
377
|
-
const folded = [];
|
|
378
|
-
|
|
379
|
-
// When every selected surface carries its rules inside the primary document, the
|
|
380
|
-
// per-rule bodies belong in the region too — otherwise a repo on the standard
|
|
381
|
-
// surface receives AGENTS.md and none of the always-on process rules, because
|
|
382
|
-
// those had nowhere else to live. One file, one region, one place a reader opens.
|
|
383
421
|
const { resolved, bodies, problems, notes: ruleNotes } = await ruleBodies(
|
|
384
422
|
root,
|
|
385
423
|
manifest,
|
|
386
424
|
flags ?? {},
|
|
387
425
|
);
|
|
388
426
|
notes.push(...ruleNotes);
|
|
427
|
+
|
|
428
|
+
// A profile that declares a scope leaves the region only when every selected surface
|
|
429
|
+
// can load it narrowly. The alternative is a profile written only as a scoped file for
|
|
430
|
+
// a reader whose surface cannot scope — a rule nobody loads, which is worse than one
|
|
431
|
+
// that loads eagerly (ADR-071). A surface that cannot scope it therefore keeps the
|
|
432
|
+
// profile in the region and says so, rather than letting the region quietly shrink.
|
|
433
|
+
const narrowing = resolved.surfaces.every((s) => canScope(s));
|
|
434
|
+
const scoped = [];
|
|
435
|
+
const inRegion = [];
|
|
436
|
+
for (const r of selected) {
|
|
437
|
+
const declares = Array.isArray(r.scope) && r.scope.length > 0 && r.error === null;
|
|
438
|
+
if (!declares) {
|
|
439
|
+
inRegion.push(r);
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
// A profile only leaves the region when a narrowed file can replace it. When any
|
|
443
|
+
// selected surface cannot narrow, the file would be the only home for a rule that
|
|
444
|
+
// reader cannot reach — so it stays, and says why.
|
|
445
|
+
if (narrowing) {
|
|
446
|
+
scoped.push(r);
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
inRegion.push(r);
|
|
450
|
+
notes.push(
|
|
451
|
+
`${r.id} declares simt-scope, but ${resolved.source} selects a surface that cannot narrow a rule, so it stays in the region`,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// The kernel. In `kernel` mode only the hard and command classes are packed into the
|
|
456
|
+
// region and every other rule is deferred to `rules show <id>`, which is what makes
|
|
457
|
+
// R08's accept criterion reachable at all: a budget cannot be met by a projection that
|
|
458
|
+
// never tries. `full` is the default and stays it until R02 has measured that a
|
|
459
|
+
// kernel does not lose compliance, because nobody has measured it yet.
|
|
460
|
+
//
|
|
461
|
+
// A rule with no stated class is deferred, not packed. The two mistakes are not
|
|
462
|
+
// symmetric: packing a line that should have been deferred spends the budget the mode
|
|
463
|
+
// exists to save, while deferring a line that should have been packed costs a reader
|
|
464
|
+
// one `rules show`. The mode is a promise about bytes, so it is the bytes that win.
|
|
465
|
+
const mode = await projectionMode(root);
|
|
466
|
+
const packed = [];
|
|
467
|
+
const deferred = [];
|
|
468
|
+
for (const r of inRegion) {
|
|
469
|
+
if (mode === 'kernel' && (r.error !== null || !KERNEL_CLASSES.has(r.priority))) deferred.push(r);
|
|
470
|
+
else packed.push(r);
|
|
471
|
+
}
|
|
472
|
+
let body = render(packed);
|
|
473
|
+
let sources = packed.map((r) => r.source);
|
|
474
|
+
const folded = [];
|
|
475
|
+
|
|
476
|
+
// The pointer. A reader who opens AGENTS.md and does not find a profile they know is
|
|
477
|
+
// there will look for it, and the honest answer is where it moved: the file a scoping
|
|
478
|
+
// surface loads it from, and the files it now matches. Without the line the region's
|
|
479
|
+
// source list is quietly short one entry, which reads as a rule that was deleted.
|
|
480
|
+
if (scoped.length > 0 && narrowing) {
|
|
481
|
+
const lines = scoped.map(
|
|
482
|
+
(r) =>
|
|
483
|
+
`- ${sourceTitle(r, 'profile')} — loads only when a matching file is in play (${r.scope.join(', ')}): ${scopedPaths(r, resolved).join(', ')}`,
|
|
484
|
+
);
|
|
485
|
+
body += `\n\n## Scoped rules\n\n${lines.join('\n')}`;
|
|
486
|
+
for (const r of scoped) sources.push(r.source);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (deferred.length > 0) {
|
|
490
|
+
// The pointer is the same shape as the scoped one above, and for the same reason:
|
|
491
|
+
// a reader who opens AGENTS.md and does not find a rule they know is there will
|
|
492
|
+
// look for it. What it costs to find it has to be one command, named per rule.
|
|
493
|
+
const lines = [...deferred]
|
|
494
|
+
.sort(byPriorityThenId)
|
|
495
|
+
.map(
|
|
496
|
+
(r) =>
|
|
497
|
+
`- ${sourceTitle(r, 'profile')} — ${r.priority ?? 'unclassified'}, deferred in kernel mode. Show it with: ${CLI} rules show ${r.id}`,
|
|
498
|
+
);
|
|
499
|
+
body += `\n\n## Deferred rules\n\n${lines.join('\n')}`;
|
|
500
|
+
for (const r of deferred) sources.push(r.source);
|
|
501
|
+
}
|
|
502
|
+
|
|
389
503
|
if (foldsRules(resolved)) {
|
|
390
504
|
for (const { rule, body: ruleBody } of bodies) {
|
|
391
505
|
// `## Process:`, not `### id`. The body already uses `##`, and a third-level
|
|
@@ -407,6 +521,13 @@ export async function desiredRegion(root, flags) {
|
|
|
407
521
|
all,
|
|
408
522
|
profiles: selected.map((r) => r.id),
|
|
409
523
|
folded,
|
|
524
|
+
mode,
|
|
525
|
+
deferred: deferred.map((r) => r.id),
|
|
526
|
+
// The profiles that left the region, and the surfaces that accepted them as files.
|
|
527
|
+
// `plan` writes them and `checkTarget` gates them through this list, so a projected
|
|
528
|
+
// file and a drifted one are decided by the same computation.
|
|
529
|
+
scoped,
|
|
530
|
+
narrowing,
|
|
410
531
|
surfaces: resolved.ids,
|
|
411
532
|
surfaceSource: resolved.source,
|
|
412
533
|
problems,
|
|
@@ -417,6 +538,27 @@ export async function desiredRegion(root, flags) {
|
|
|
417
538
|
};
|
|
418
539
|
}
|
|
419
540
|
|
|
541
|
+
/**
|
|
542
|
+
* Every per-rule file a scoped profile is written to, as paths a reader can open.
|
|
543
|
+
*
|
|
544
|
+
* Only the surfaces that can scope get one: a Cursor `.mdc` with `globs` and
|
|
545
|
+
* `alwaysApply: false`, an Antigravity file with `trigger: glob`, a Copilot
|
|
546
|
+
* `.instructions.md` with `applyTo`. A surface that reads the region already (opencode)
|
|
547
|
+
* has no file channel at all, and Claude's scoping key is the documented-defect one,
|
|
548
|
+
* so neither appears here.
|
|
549
|
+
*/
|
|
550
|
+
function scopedPaths(rule, resolved) {
|
|
551
|
+
const out = [];
|
|
552
|
+
for (const s of resolved.surfaces) {
|
|
553
|
+
if (!canScope(s) || !s.rule) continue;
|
|
554
|
+
// A folded surface already carries the whole region; it is the reason this profile
|
|
555
|
+
// is only a file when folding is off, which is the caller's question to answer.
|
|
556
|
+
if (s.foldRules === true && s.readsPrimary === true && foldsRules(resolved)) continue;
|
|
557
|
+
out.push(rulePath(s.id, rule.id));
|
|
558
|
+
}
|
|
559
|
+
return out;
|
|
560
|
+
}
|
|
561
|
+
|
|
420
562
|
/**
|
|
421
563
|
* Read every rule file once, with its frontmatter parsed, plus any repo-local
|
|
422
564
|
* profile under `.simt-dev/profiles/`.
|
|
@@ -444,7 +586,42 @@ export async function loadRules(root = null) {
|
|
|
444
586
|
collected.push({ file, dir: RULES_DIR, origin: 'central', source: `rules/${file}` });
|
|
445
587
|
}
|
|
446
588
|
|
|
589
|
+
// Packs go in before the repo's own profiles and after the built-ins, because
|
|
590
|
+
// that is the order they lose to. The collection order is the tie-break `select`
|
|
591
|
+
// reads for two profiles of equal rank, so "collected central, then pack, then
|
|
592
|
+
// local" is the precedence stated once rather than restated in the comparator.
|
|
447
593
|
if (root !== null) {
|
|
594
|
+
const { packs } = await resolvePacks(root);
|
|
595
|
+
for (const pack of packs) {
|
|
596
|
+
// `present`, not `ok`. A pack whose bytes disagree with its pin still projects:
|
|
597
|
+
// dropping it would let the next `sync` delete the rules it carries, so the
|
|
598
|
+
// remedy the finding names would cause the damage it exists to report.
|
|
599
|
+
if (!pack.present) continue;
|
|
600
|
+
const packRules = join(pack.dir, 'rules');
|
|
601
|
+
let names = [];
|
|
602
|
+
try {
|
|
603
|
+
names = (await readdir(packRules)).filter((n) => n.endsWith('.md')).sort();
|
|
604
|
+
} catch {
|
|
605
|
+
names = [];
|
|
606
|
+
}
|
|
607
|
+
let stackNames = [];
|
|
608
|
+
try {
|
|
609
|
+
stackNames = (await readdir(join(packRules, 'stacks')))
|
|
610
|
+
.filter((n) => n.endsWith('.md'))
|
|
611
|
+
.map((n) => `stacks/${n}`);
|
|
612
|
+
} catch {
|
|
613
|
+
stackNames.length = 0;
|
|
614
|
+
}
|
|
615
|
+
for (const file of [...names, ...stackNames].sort()) {
|
|
616
|
+
collected.push({
|
|
617
|
+
file,
|
|
618
|
+
dir: packRules,
|
|
619
|
+
origin: 'pack',
|
|
620
|
+
source: `${PACK_DIR}/${pack.name}/rules/${file}`,
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
448
625
|
const localDir = join(root, LOCAL_PROFILE_DIR);
|
|
449
626
|
let localNames = [];
|
|
450
627
|
try {
|
|
@@ -474,6 +651,8 @@ export async function loadRules(root = null) {
|
|
|
474
651
|
title: meta['simt-title'] ?? null,
|
|
475
652
|
applies: meta['simt-applies'] ?? [],
|
|
476
653
|
fallback: meta['simt-fallback'] === true,
|
|
654
|
+
scope: Array.isArray(meta['simt-scope']) ? meta['simt-scope'] : null,
|
|
655
|
+
priority: meta['simt-priority'] ?? null,
|
|
477
656
|
bytes: Buffer.byteLength(text),
|
|
478
657
|
body: stripFrontmatter(text),
|
|
479
658
|
error: describeDefect(meta),
|
|
@@ -482,6 +661,58 @@ export async function loadRules(root = null) {
|
|
|
482
661
|
return out;
|
|
483
662
|
}
|
|
484
663
|
|
|
664
|
+
/**
|
|
665
|
+
* The regions a monorepo's packages each need, as bodies ready to be written.
|
|
666
|
+
*
|
|
667
|
+
* A repository with packages in it is projected twice over: the root carries the
|
|
668
|
+
* rules every package shares, and each package carries the profiles IT selects and
|
|
669
|
+
* the root does not. A reader working in `apps/web` opens two files by walking up,
|
|
670
|
+
* and gets the union; a reader who only reads the root still gets everything that
|
|
671
|
+
* is not package-specific. Nothing is repeated, so nothing goes stale twice
|
|
672
|
+
* (ADR-066).
|
|
673
|
+
*
|
|
674
|
+
* A package whose delta is empty gets no file. Writing an empty region into every
|
|
675
|
+
* directory of a large monorepo would be noise the reader has to learn to skip,
|
|
676
|
+
* and a file with nothing in it is not a projection, it is an absence with a
|
|
677
|
+
* generated header on it.
|
|
678
|
+
*
|
|
679
|
+
* The rule set is the ROOT's - local profiles and packs are the repository's, and
|
|
680
|
+
* a package that re-read them from its own directory would resolve a different
|
|
681
|
+
* set in a directory that has no manifest.
|
|
682
|
+
*
|
|
683
|
+
* @returns {Promise<{regions: {path: string, ecosystem: string, stack: string[],
|
|
684
|
+
* facts: string[], profiles: string[], body: string, meta: {sha: string, sources: string[]}}[],
|
|
685
|
+
* notes: string[]}>}
|
|
686
|
+
*/
|
|
687
|
+
export async function packageRegions(root, flags, rootProfiles = null) {
|
|
688
|
+
const shared = rootProfiles ?? (await desiredRegion(root, flags)).profiles;
|
|
689
|
+
const { packages, notes } = await discoverPackages(root);
|
|
690
|
+
if (packages.length === 0) return { regions: [], notes };
|
|
691
|
+
|
|
692
|
+
const rules = await loadRules(root);
|
|
693
|
+
const sha = await provenanceDigest();
|
|
694
|
+
const regions = [];
|
|
695
|
+
for (const pkg of packages) {
|
|
696
|
+
const fp = await fingerprint(join(root, pkg.path));
|
|
697
|
+
const { selected } = select(rules, fp.stack ?? [], fp.facts ?? []);
|
|
698
|
+
const delta = selected.filter((r) => !shared.includes(r.id));
|
|
699
|
+
if (delta.length === 0) continue;
|
|
700
|
+
regions.push({
|
|
701
|
+
path: pkg.path,
|
|
702
|
+
ecosystem: pkg.ecosystem,
|
|
703
|
+
stack: fp.stack ?? [],
|
|
704
|
+
facts: fp.facts ?? [],
|
|
705
|
+
profiles: delta.map((r) => r.id),
|
|
706
|
+
// One sentence, because the file's whole purpose is to be found by a reader
|
|
707
|
+
// who does not know it exists: without it, a nested region reads as a
|
|
708
|
+
// complete set of rules and the shared half looks forgotten.
|
|
709
|
+
body: `The rules this package selects on its own. Everything the repository shares with its other packages is in the region at the root.\n\n${render(delta)}`,
|
|
710
|
+
meta: { sha, sources: delta.map((r) => r.source) },
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
return { regions, notes };
|
|
714
|
+
}
|
|
715
|
+
|
|
485
716
|
/**
|
|
486
717
|
* Selection is pure set intersection: core always, then every profile whose
|
|
487
718
|
* `simt-applies` intersects the repo's signals. `node` is the fallback, so it is only
|
|
@@ -492,7 +723,7 @@ export async function loadRules(root = null) {
|
|
|
492
723
|
* fact is the more durable choice because it survives the framework being
|
|
493
724
|
* replaced.
|
|
494
725
|
*
|
|
495
|
-
* `shadowed` lists
|
|
726
|
+
* `shadowed` lists profiles that replaced one they outrank, so a caller can
|
|
496
727
|
* report it rather than silently changing what a repo receives.
|
|
497
728
|
*/
|
|
498
729
|
export function select(rules, stack, facts = []) {
|
|
@@ -506,9 +737,14 @@ export function select(rules, stack, facts = []) {
|
|
|
506
737
|
ids.set(rule.id, rule);
|
|
507
738
|
continue;
|
|
508
739
|
}
|
|
509
|
-
// A
|
|
510
|
-
//
|
|
511
|
-
|
|
740
|
+
// A profile may replace one it outranks; two of the same origin are a defect
|
|
741
|
+
// the caller must be able to see.
|
|
742
|
+
//
|
|
743
|
+
// Ranked rather than `=== 'local'` because there is now a third origin. The
|
|
744
|
+
// ordering it encodes - the repository's own profile, then a pack it declares,
|
|
745
|
+
// then what the tool ships - is the whole precedence rule, in one comparison
|
|
746
|
+
// that cannot drift from the order the files were collected in.
|
|
747
|
+
const localWins = ORIGIN_RANK[rule.origin] > ORIGIN_RANK[prior.origin];
|
|
512
748
|
if (localWins) {
|
|
513
749
|
ids.set(rule.id, rule);
|
|
514
750
|
shadowed.push({
|
|
@@ -580,6 +816,32 @@ export function render(selected) {
|
|
|
580
816
|
.join('\n\n');
|
|
581
817
|
}
|
|
582
818
|
|
|
819
|
+
/**
|
|
820
|
+
* The projection mode this repository declared, or `full`.
|
|
821
|
+
*
|
|
822
|
+
* Absent means `full`, and `full` is the default rather than a fallback: no measurement
|
|
823
|
+
* yet says a kernel does not lose compliance (plan 0003 R02), so the mode that changes
|
|
824
|
+
* what a reader gets cannot be the one nobody chose.
|
|
825
|
+
*/
|
|
826
|
+
async function projectionMode(root) {
|
|
827
|
+
const { manifest } = await readManifest(root);
|
|
828
|
+
return manifest?.mode === 'kernel' ? 'kernel' : 'full';
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Deterministic kernel order: the class a rule declares, then its id.
|
|
833
|
+
*
|
|
834
|
+
* The tie-break is not decoration. Two rules of the same class packed in file order
|
|
835
|
+
* would move when a file is added to the directory, and a projection that reorders
|
|
836
|
+
* itself on an unrelated commit is a projection nobody can diff (ADR-013).
|
|
837
|
+
*/
|
|
838
|
+
function byPriorityThenId(a, b) {
|
|
839
|
+
const classA = PRIORITY_CLASSES.indexOf(a.priority ?? 'style');
|
|
840
|
+
const classB = PRIORITY_CLASSES.indexOf(b.priority ?? 'style');
|
|
841
|
+
if (classA !== classB) return classA - classB;
|
|
842
|
+
return String(a.id).localeCompare(String(b.id));
|
|
843
|
+
}
|
|
844
|
+
|
|
583
845
|
/**
|
|
584
846
|
* One source, one `##` heading, then its body with every heading demoted.
|
|
585
847
|
*
|
|
@@ -634,6 +896,12 @@ function parseFrontmatter(text) {
|
|
|
634
896
|
.slice(1, raw.lastIndexOf(']'))
|
|
635
897
|
.split(',')
|
|
636
898
|
.map((s) => s.trim())
|
|
899
|
+
// A quoted item is a quoted item: the glob inside is the value, not the quotes
|
|
900
|
+
// around it. A profile author quoting `\"src/**/*.ts\"` because their editor
|
|
901
|
+
// colouring complains about a bare `*` would otherwise project a pattern the
|
|
902
|
+
// surface matches nothing against.
|
|
903
|
+
.map((s) => (s.length >= 2 && s.startsWith('"') && s.endsWith('"') ? s.slice(1, -1) : s))
|
|
904
|
+
.map((s) => (s.length >= 2 && s.startsWith("'") && s.endsWith("'") ? s.slice(1, -1) : s))
|
|
637
905
|
.filter(Boolean);
|
|
638
906
|
} else out[key] = raw.trim();
|
|
639
907
|
}
|
|
@@ -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
|
}
|