simt-dev 0.3.0 → 0.4.0
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 +172 -0
- package/README.md +138 -74
- package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +1 -1
- package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +1 -1
- package/docs/decisions/ADR-083-a-budget-is-met-by-cutting-the-kernel-and-hard-is-the-floor.md +153 -0
- package/docs/decisions/ADR-084-a-workspace-carries-its-repeated-rules-once.md +100 -0
- package/docs/decisions/ADR-085-the-region-ends-with-the-names-it-kept-out.md +72 -0
- package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +69 -0
- package/docs/decisions/ADR-087-a-run-is-evidence-for-one-tree.md +67 -0
- package/docs/decisions/ADR-088-a-marked-line-is-enforced-by-a-checker.md +59 -0
- package/docs/decisions/ADR-089-the-local-index-is-a-cache-of-git-blobs.md +52 -0
- package/docs/decisions/ADR-090-an-optional-adapter-is-off-until-named.md +55 -0
- package/docs/decisions/ADR-091-the-map-ranks-files-a-task-mentions.md +60 -0
- package/docs/decisions/ADR-092-tests-are-imports-conventions-and-coverage.md +60 -0
- package/docs/decisions/ADR-093-why-names-what-governs-a-file.md +58 -0
- package/docs/decisions/ADR-094-a-change-runs-the-tests-that-import-it.md +44 -0
- package/docs/decisions/ADR-095-gate-output-keeps-the-failures.md +31 -0
- package/docs/decisions/ADR-096-impact-names-who-a-change-reaches.md +38 -0
- package/package.json +2 -2
- package/rules/core.md +2 -2
- package/src/agenthooks/policy.mjs +10 -2
- package/src/agenthooks/prompt.mjs +81 -0
- package/src/agenthooks/render.mjs +138 -17
- package/src/argv.mjs +34 -1
- package/src/bench/grade.mjs +3 -0
- package/src/bench/run.mjs +85 -39
- package/src/bench/variants.mjs +456 -0
- package/src/brief/assemble.mjs +346 -0
- package/src/brief/kind.mjs +134 -0
- package/src/brief/packet.mjs +197 -0
- package/src/ci/workflow.mjs +11 -1
- package/src/commands/agenthook.mjs +84 -5
- package/src/commands/bench.mjs +53 -16
- package/src/commands/brief.mjs +78 -0
- package/src/commands/compare.mjs +10 -2
- package/src/commands/decide.mjs +116 -0
- package/src/commands/evidence.mjs +96 -0
- package/src/commands/gate.mjs +165 -0
- package/src/commands/help.mjs +65 -3
- package/src/commands/hooks.mjs +45 -5
- package/src/commands/impact.mjs +205 -0
- package/src/commands/index.mjs +52 -1
- package/src/commands/lessons.mjs +127 -0
- package/src/commands/local.mjs +97 -0
- package/src/commands/map.mjs +126 -0
- package/src/commands/repoindex.mjs +55 -0
- package/src/commands/rules.mjs +17 -2
- package/src/commands/shared.mjs +398 -0
- package/src/commands/skills.mjs +3 -17
- package/src/commands/skillscompile.mjs +255 -0
- package/src/commands/sync.mjs +87 -3
- package/src/commands/tests.mjs +145 -0
- package/src/commands/tokens.mjs +30 -1
- package/src/commands/why.mjs +172 -0
- package/src/compare/learn.mjs +680 -0
- package/src/decisions/index.mjs +116 -65
- package/src/federation/client.mjs +188 -0
- package/src/federation/config.mjs +134 -0
- package/src/federation/query.mjs +48 -0
- package/src/graph/compile.mjs +475 -0
- package/src/graph/spec.mjs +1 -1
- package/src/hooks/dispatcher.mjs +15 -0
- package/src/hooks/shim.mjs +31 -0
- package/src/index/compact.mjs +228 -0
- package/src/index/extract.mjs +331 -0
- package/src/index/gate.mjs +89 -0
- package/src/index/govern.mjs +234 -0
- package/src/index/impact.mjs +186 -0
- package/src/index/map.mjs +627 -0
- package/src/index/persist.mjs +239 -0
- package/src/index/store.mjs +515 -0
- package/src/index/tests.mjs +294 -0
- package/src/kernel/budget.mjs +100 -0
- package/src/kernel/checks.mjs +378 -0
- package/src/kernel/variants.mjs +353 -0
- package/src/ledger/index.mjs +147 -0
- package/src/local/client.mjs +482 -0
- package/src/local/config.mjs +234 -0
- package/src/mcp/tools.mjs +138 -1
- package/src/measure/context.mjs +7 -5
- package/src/memory/decide.mjs +462 -0
- package/src/memory/lessons.mjs +858 -0
- package/src/projection/disclosure.mjs +92 -0
- package/src/projection/lock.mjs +125 -0
- package/src/projection/manifest.mjs +52 -0
- package/src/projection/markers.mjs +56 -17
- package/src/projection/project.mjs +24 -17
- package/src/projection/rules.mjs +407 -49
- package/src/projection/shared.mjs +428 -0
- package/src/projection/targets.mjs +2 -1
- package/src/toolwords.mjs +8 -0
package/src/projection/rules.mjs
CHANGED
|
@@ -29,11 +29,16 @@ import { fingerprint, KNOWN_TAGS } from '../resolve/fingerprint.mjs';
|
|
|
29
29
|
import { loadMdcRules, renderMdc } from './mdc.mjs';
|
|
30
30
|
import { foldsRules, frontmatterBlock, resolveSurfaces, rulePath, canScope } from './surfaces.mjs';
|
|
31
31
|
import { ruleTargets } from './targets.mjs';
|
|
32
|
-
import {
|
|
32
|
+
import { compose } from './markers.mjs';
|
|
33
|
+
import { declaredBudget, declaredShared, manifestPath, readManifest } from './manifest.mjs';
|
|
33
34
|
import { commandCard, CARD_LIMIT_BYTES } from '../kernel/card.mjs';
|
|
35
|
+
import { packToBudget } from '../kernel/budget.mjs';
|
|
34
36
|
import { filterInferable, inferableDefect, inferableProofs } from '../kernel/inferability.mjs';
|
|
37
|
+
import { checkDefect, foldChecks } from '../kernel/checks.mjs';
|
|
38
|
+
import { foldVariants, readLockedVariants, variantsDefect } from '../kernel/variants.mjs';
|
|
35
39
|
import { BASELINES } from './baselines.mjs';
|
|
36
40
|
import { CLI } from '../name.mjs';
|
|
41
|
+
import { installedSkills } from './disclosure.mjs';
|
|
37
42
|
import { PACK_DIR, resolvePacks } from '../packs.mjs';
|
|
38
43
|
import { discoverPackages } from '../monorepo.mjs';
|
|
39
44
|
|
|
@@ -399,12 +404,14 @@ async function ruleBodies(root, manifest, flags) {
|
|
|
399
404
|
for (const p of renderProblems) problems.push(`${rule.file}: ${p}`);
|
|
400
405
|
if (renderProblems.length > 0) continue;
|
|
401
406
|
|
|
407
|
+
const variantFolded = foldVariants(body, rule.id ?? '', flags?.variants ?? null);
|
|
408
|
+
|
|
402
409
|
// One canonical body, one target per selected surface. The body is identical
|
|
403
410
|
// everywhere; only the frontmatter the editor reads before any rule text
|
|
404
411
|
// differs. Without this loop a repo in a Claude Code session receives
|
|
405
412
|
// AGENTS.md and none of the always-on process rules, because those lived only
|
|
406
413
|
// in a directory only Cursor reads.
|
|
407
|
-
bodies.push({ rule, body });
|
|
414
|
+
bodies.push({ rule, body: variantFolded.body });
|
|
408
415
|
}
|
|
409
416
|
|
|
410
417
|
return { resolved, bodies, problems, notes, skipped };
|
|
@@ -429,14 +436,30 @@ async function inferableEvidence(root, { stack, facts, manifest }) {
|
|
|
429
436
|
* about what the region should contain — a divergence here would report drift
|
|
430
437
|
* where none exists, and CI would flake on a clean tree.
|
|
431
438
|
*/
|
|
439
|
+
/**
|
|
440
|
+
* Is this rule one this package ships, rather than one the repository added?
|
|
441
|
+
*
|
|
442
|
+
* Two lists, because two sources are read: profiles carry the `origin` they were loaded
|
|
443
|
+
* from (`central` for `rules/`, `pack`, `local`), while the canonical `.mdc` rules are read
|
|
444
|
+
* out of the package and have no origin at all — only a `rules/mdc/…` source. Asking
|
|
445
|
+
* `origin === 'central'` of both silently drops every process rule from the workspace's
|
|
446
|
+
* shared file, which is how the first version of this predicate lost three of five rules
|
|
447
|
+
* in a two-repository measurement without failing anything.
|
|
448
|
+
*/
|
|
449
|
+
function toolRule(rule) {
|
|
450
|
+
return rule.origin !== undefined ? rule.origin === 'central' : rule.source.startsWith('rules/');
|
|
451
|
+
}
|
|
452
|
+
|
|
432
453
|
export async function desiredRegion(root, flags) {
|
|
433
454
|
const { stack, facts, manifest } = await fingerprint(root);
|
|
455
|
+
const lockedVariants =
|
|
456
|
+
flags?.variants !== undefined ? flags.variants : await readLockedVariants(root);
|
|
434
457
|
const all = await loadRules(root);
|
|
435
458
|
const { selected, fallback, notes } = select(all, stack, facts);
|
|
436
459
|
const { resolved, bodies, problems, notes: ruleNotes } = await ruleBodies(
|
|
437
460
|
root,
|
|
438
461
|
manifest,
|
|
439
|
-
flags ?? {},
|
|
462
|
+
{ ...(flags ?? {}), variants: lockedVariants },
|
|
440
463
|
);
|
|
441
464
|
notes.push(...ruleNotes);
|
|
442
465
|
|
|
@@ -458,7 +481,8 @@ export async function desiredRegion(root, flags) {
|
|
|
458
481
|
// selected surface cannot narrow, the file would be the only home for a rule that
|
|
459
482
|
// reader cannot reach — so it stays, and says why.
|
|
460
483
|
if (narrowing) {
|
|
461
|
-
|
|
484
|
+
const vf = foldVariants(r.body, r.id ?? '', lockedVariants);
|
|
485
|
+
scoped.push(vf.changed ? { ...r, body: vf.body } : r);
|
|
462
486
|
continue;
|
|
463
487
|
}
|
|
464
488
|
inRegion.push(r);
|
|
@@ -503,17 +527,32 @@ export async function desiredRegion(root, flags) {
|
|
|
503
527
|
// which lines the reader already knows.
|
|
504
528
|
const inferable = [];
|
|
505
529
|
const shipped = [];
|
|
530
|
+
const variantsSelected = [];
|
|
531
|
+
let enforced = 0;
|
|
506
532
|
for (const r of packed) {
|
|
507
|
-
const
|
|
508
|
-
|
|
533
|
+
const variantFolded = foldVariants(r.body, r.id ?? '', lockedVariants);
|
|
534
|
+
const filtered = filterInferable(variantFolded.body, proofs);
|
|
535
|
+
const folded = foldChecks(filtered.body);
|
|
536
|
+
enforced += folded.enforced.length;
|
|
537
|
+
variantsSelected.push(...variantFolded.selected);
|
|
538
|
+
if (!variantFolded.changed && !filtered.changed && !folded.changed) {
|
|
509
539
|
shipped.push(r);
|
|
510
540
|
continue;
|
|
511
541
|
}
|
|
512
542
|
for (const x of filtered.removed) inferable.push({ rule: r.id, text: x.text, expr: x.expr });
|
|
513
|
-
shipped.push({ ...r, body:
|
|
543
|
+
shipped.push({ ...r, body: folded.body });
|
|
544
|
+
}
|
|
545
|
+
const lockedNonDefault = variantsSelected.filter((v) => v.locked && v.winner !== v.baseline);
|
|
546
|
+
if (lockedNonDefault.length > 0) {
|
|
547
|
+
notes.push(
|
|
548
|
+
`variants: applied ${lockedNonDefault.length} locked variant(s) (${lockedNonDefault.map((v) => `${v.slotId}=${v.winner}`).join(', ')})`,
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
if (enforced > 0) {
|
|
552
|
+
notes.push(
|
|
553
|
+
`enforced: ${enforced} line(s) left the region because a checker runs them`,
|
|
554
|
+
);
|
|
514
555
|
}
|
|
515
|
-
let body = card.text ? `${card.text}\n\n${render(shipped)}` : render(shipped);
|
|
516
|
-
let sources = shipped.map((r) => r.source);
|
|
517
556
|
if (inferable.length > 0) {
|
|
518
557
|
// Never silent, and never a heading. A line that left with no trace repeats the
|
|
519
558
|
// mistake ADR-073 rejected for deferred rules, and a `## Dropped` section spends region
|
|
@@ -528,48 +567,274 @@ export async function desiredRegion(root, flags) {
|
|
|
528
567
|
}
|
|
529
568
|
}
|
|
530
569
|
}
|
|
531
|
-
const folded = [];
|
|
570
|
+
const folded = foldsRules(resolved) ? bodies.map(({ rule }) => rule.id) : [];
|
|
532
571
|
|
|
533
|
-
// The
|
|
534
|
-
//
|
|
535
|
-
//
|
|
536
|
-
//
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
572
|
+
// The workspace-shared rules this repository declared (ADR-084).
|
|
573
|
+
//
|
|
574
|
+
// Read from the manifest and never from the reader's home directory, because this
|
|
575
|
+
// function is what `sync` and `check` both call: a region that depended on what
|
|
576
|
+
// somebody had installed would render differently on a machine where they had not,
|
|
577
|
+
// and CI would report drift on a tree `sync` had just written. The ids travel in the
|
|
578
|
+
// manifest so the answer to "what leaves this region" is a fact about the repository.
|
|
579
|
+
const declared = declaredShared(manifest);
|
|
580
|
+
const carriedIds = new Set(declared.level === null ? [] : [...declared.rules]);
|
|
581
|
+
|
|
582
|
+
// Every rule the workspace carries, as the rule objects the index below names. Both
|
|
583
|
+
// lists, because a surface that folds its rules into the primary document carries the
|
|
584
|
+
// canonical process bodies in that document too — and a reader who cannot find
|
|
585
|
+
// `slice-commit` anywhere near their repository will look for it in AGENTS.md.
|
|
586
|
+
const carried = [
|
|
587
|
+
...shipped.filter((r) => carriedIds.has(r.id)).map((rule) => ({ rule, kind: 'profile' })),
|
|
588
|
+
...bodies.filter(({ rule }) => carriedIds.has(rule.id)).map(({ rule }) => ({ rule, kind: 'process' })),
|
|
589
|
+
];
|
|
590
|
+
|
|
591
|
+
// The rules this root would carry in its region, with the bytes it would carry them
|
|
592
|
+
// in — the input ADR-084's workspace intersection compares.
|
|
593
|
+
//
|
|
594
|
+
// Computed from `shipped` rather than from what the region ends up carrying, and that
|
|
595
|
+
// is the whole reason it is computed at all: a rule already declared shared must still
|
|
596
|
+
// appear here, or a second `shared install` would intersect against a list that no
|
|
597
|
+
// longer contained it and shrink the shared file every time anybody ran the command.
|
|
598
|
+
// The declaration is an input to the region; it is not an input to what is shareable.
|
|
599
|
+
const shareable = [
|
|
600
|
+
// Only the rules this package ships. A repository's own profile, or a pack it
|
|
601
|
+
// installed, is policy somebody chose to write: carrying it into a shared home file
|
|
602
|
+
// would mean editing that file to change a repository's rules, and the copy would
|
|
603
|
+
// reach every other repository in the estate that declared the same id. A rule
|
|
604
|
+
// somebody can override must stay somewhere they own.
|
|
605
|
+
...shipped
|
|
606
|
+
.filter((r) => r.error === null && toolRule(r) && typeof r.id === 'string' && r.id !== '')
|
|
607
|
+
.map((r) => ({
|
|
608
|
+
id: r.id,
|
|
609
|
+
source: r.source,
|
|
610
|
+
priority: r.priority ?? null,
|
|
611
|
+
rendered: frameSource(r, stripFrontmatter(r.body).trim(), 'profile'),
|
|
612
|
+
})),
|
|
613
|
+
// The folded process bodies, and only while they are folded. A surface that writes
|
|
614
|
+
// one file per rule expects that file at a path inside the repository, so a
|
|
615
|
+
// user-level copy cannot stand in for it — but when the surface folds the whole region
|
|
616
|
+
// into the primary document, those bytes are as repeated across a workspace as the
|
|
617
|
+
// profiles are, and `tokens --repos` named them as the last duplicate block in a
|
|
618
|
+
// two-repository measurement.
|
|
619
|
+
...bodies
|
|
620
|
+
.filter(({ rule }) => folded.includes(rule.id) && toolRule(rule))
|
|
621
|
+
.map(({ rule, body }) => ({
|
|
622
|
+
id: rule.id,
|
|
623
|
+
source: rule.source,
|
|
624
|
+
priority: rule.priority ?? null,
|
|
625
|
+
rendered: frameSource(rule, body, 'process'),
|
|
626
|
+
})),
|
|
627
|
+
].filter((r) => r.rendered !== '');
|
|
628
|
+
|
|
629
|
+
// The selected rules that are not shareable, with the reason. `shared plan` reporting
|
|
630
|
+
// only what it carries would imply every profile in the workspace had been accounted
|
|
631
|
+
// for, and a reader whose own house rule is missing from the list would conclude it
|
|
632
|
+
// moved — so the names it did not take, and why, are data rather than an omission.
|
|
633
|
+
const shareableIds = new Set(shareable.map((r) => r.id));
|
|
634
|
+
const excluded = selected
|
|
635
|
+
.filter((r) => typeof r.id === 'string' && r.id !== '' && !shareableIds.has(r.id))
|
|
636
|
+
.map((r) => ({
|
|
637
|
+
id: r.id,
|
|
638
|
+
why: r.error !== null ? 'error' : toolRule(r) ? 'empty' : 'policy',
|
|
639
|
+
}));
|
|
640
|
+
|
|
641
|
+
// The budget, and the bytes held back for what loads beside the region.
|
|
642
|
+
//
|
|
643
|
+
// Read from the manifest rather than from the plan's spelling of it. Plan 0003 R08
|
|
644
|
+
// wrote `projection.budget.bytes`, and ADR-072 moved the key to the top level because
|
|
645
|
+
// `projection` is a generated key `upgrade` replaces wholesale - a budget that reset
|
|
646
|
+
// itself on upgrade would reset the reader's context size without anyone asking. The
|
|
647
|
+
// plan is not edited: the key it names is wrong, and a ruling that supersedes it is
|
|
648
|
+
// the record of why.
|
|
649
|
+
const budget = declaredBudget(manifest);
|
|
650
|
+
// Names and triggers only. Read once, outside the packer, because a skill does not
|
|
651
|
+
// change as rules are cut and reading its body here would put the cost back into the
|
|
652
|
+
// region the index exists to keep it out of (ADR-085).
|
|
653
|
+
const skills = await installedSkills(root);
|
|
654
|
+
|
|
655
|
+
// The digest is needed before the region can be weighed rather than after, because the
|
|
656
|
+
// provenance header is part of what lands in the file. `provenanceDigest` is a pure
|
|
657
|
+
// function of the bytes this package ships, so computing it once and passing it down is
|
|
658
|
+
// the same value the tail of this function was getting on its own.
|
|
659
|
+
const sha = await provenanceDigest();
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* The region, assembled from whatever survived the budget (ADR-083).
|
|
663
|
+
*
|
|
664
|
+
* One function, called once for the whole projection and again for each attempt the
|
|
665
|
+
* packer makes, so a cut region and an uncut one are the same document by construction
|
|
666
|
+
* rather than by two functions agreeing. The card, the inferability notes, the folded
|
|
667
|
+
* process bodies and the disclosure index are not candidates for a cut: a budget is a
|
|
668
|
+
* decision about rules, and dropping the index would hide the rules the cut removed.
|
|
669
|
+
*/
|
|
670
|
+
const assemble = (cut) => {
|
|
671
|
+
// A rule the workspace carries once leaves the region, and so does a rule the packer
|
|
672
|
+
// could not afford. Both are exclusions from the same list, so the section below can
|
|
673
|
+
// name all three reasons — which is the only reason the section exists: a reader who
|
|
674
|
+
// opens AGENTS.md and does not find a rule they know is there will look for it.
|
|
675
|
+
const kept = shipped.filter((r) => !cut.has(r.id) && !carriedIds.has(r.id));
|
|
676
|
+
// Stable bytes first (ADR-086). Core, then the profiles this package ships, then
|
|
677
|
+
// everything this repository added — the card, its own profiles, the process
|
|
678
|
+
// bodies. A line the repository edits then changes only the tail, and the prefix
|
|
679
|
+
// a reader has cached stays byte-identical. The card used to lead (ADR-077 ruling
|
|
680
|
+
// 1); it now leads the tail, because a command this repository owns is the thing
|
|
681
|
+
// an edit is most likely to move.
|
|
682
|
+
const stable = kept.filter((r) => toolRule(r)).sort(stableOrder);
|
|
683
|
+
const repoRules = kept.filter((r) => !toolRule(r)).sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
|
684
|
+
let text = render(stable);
|
|
685
|
+
if (card.text) text += `${text === '' ? '' : '\n\n'}${card.text}`;
|
|
686
|
+
const repoText = render(repoRules);
|
|
687
|
+
if (repoText) text += `\n\n${repoText}`;
|
|
688
|
+
const sources = [...stable, ...repoRules].map((r) => r.source);
|
|
689
|
+
|
|
690
|
+
// Every reason a rule is missing lands in this one section, and each line says which
|
|
691
|
+
// it was. A rule deferred by its class, a rule cut to reach a byte count and a rule
|
|
692
|
+
// the workspace carries once are three different decisions, but they are the same fact
|
|
693
|
+
// to a reader — it is not in this file — and three headings would spend region bytes
|
|
694
|
+
// on structure nobody can act on. The line names the command that shows the rule,
|
|
695
|
+
// because for all three the remedy is the same single read.
|
|
696
|
+
//
|
|
697
|
+
// The label is the rule's own frame title, which is why each entry carries the kind it
|
|
698
|
+
// is rendered as: a canonical rule that the surface folds in is `Process: <id>` in the
|
|
699
|
+
// shared file, and an index that calls it `Stack:` sends the reader looking for the
|
|
700
|
+
// wrong thing.
|
|
701
|
+
const omitted = [
|
|
702
|
+
...deferred.map((r) => ({ rule: r, kind: 'profile', why: `${r.priority ?? 'unclassified'}, deferred in kernel mode` })),
|
|
703
|
+
...shipped
|
|
704
|
+
.filter((r) => cut.has(r.id))
|
|
705
|
+
.map((r) => ({
|
|
706
|
+
rule: r,
|
|
707
|
+
kind: 'profile',
|
|
708
|
+
why: `${r.priority ?? 'unclassified'}, cut to fit the declared budget of ${budget} bytes`,
|
|
709
|
+
})),
|
|
710
|
+
...carried.map(({ rule, kind }) => ({
|
|
711
|
+
rule,
|
|
712
|
+
kind,
|
|
713
|
+
// No path here, deliberately. This text is rendered by `sync` in CI, where the
|
|
714
|
+
// reader's home directory is somebody else's, and a path is the one thing in a
|
|
715
|
+
// remedy that cannot be true on both. The remedy is a command that works on the
|
|
716
|
+
// machine the reader is on.
|
|
717
|
+
why: `${rule.priority ?? 'unclassified'}, carried once per workspace in your reader's user-level rules file instead; if your reader has no copy, run: ${CLI} shared install`,
|
|
718
|
+
})),
|
|
719
|
+
];
|
|
720
|
+
if (omitted.length > 0) {
|
|
721
|
+
// The pointer is the same shape as the scoped one above, and for the same reason:
|
|
722
|
+
// a reader who opens AGENTS.md and does not find a rule they know is there will
|
|
723
|
+
// look for it. What it costs to find it has to be one command, named per rule.
|
|
724
|
+
const lines = omitted
|
|
725
|
+
.sort((a, b) => byPriorityThenId(a.rule, b.rule))
|
|
726
|
+
.map(
|
|
727
|
+
({ rule, kind, why }) =>
|
|
728
|
+
`- ${sourceTitle(rule, kind)} — ${why}. Show it with: ${CLI} rules show ${rule.id}`,
|
|
729
|
+
);
|
|
730
|
+
// `## Rules not in this region`, not `## Deferred rules`. The heading was written
|
|
731
|
+
// when there was one reason to be here (ADR-073) and it is region text a reader
|
|
732
|
+
// reads, so a heading that names one of three reasons misdescribes the two others
|
|
733
|
+
// (ADR-084).
|
|
734
|
+
text += `\n\n## Rules not in this region\n\n${lines.join('\n')}`;
|
|
735
|
+
for (const { rule } of omitted) sources.push(rule.source);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
if (folded.length > 0) {
|
|
739
|
+
for (const { rule, body: ruleBody } of bodies) {
|
|
740
|
+
// A body the workspace carries once is not folded in here either. The heading is
|
|
741
|
+
// about how the surface loads the region, so it stays; the rule under it does not,
|
|
742
|
+
// and a region that carried the same bytes twice is the redundancy ADR-084 exists
|
|
743
|
+
// to remove — reintroduced by the feature that removes it.
|
|
744
|
+
if (carriedIds.has(rule.id)) continue;
|
|
745
|
+
// `## Process:`, not `### id`. The body already uses `##`, and a third-level
|
|
746
|
+
// heading in front of a second-level one is the break a reader cannot repair.
|
|
747
|
+
text += `\n\n${frameSource(rule, ruleBody, 'process')}`;
|
|
748
|
+
sources.push(rule.source);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// Last, because this is the index of what the file deliberately does not contain
|
|
753
|
+
// (ADR-085). A scoped profile keeps the pointer ADR-071 requires — its patterns and
|
|
754
|
+
// the files it moved to — and a skill keeps its name and the description that says
|
|
755
|
+
// when to open it. The body of either is the thing that was moved out.
|
|
756
|
+
const disclosure = [];
|
|
757
|
+
if (scoped.length > 0 && narrowing) {
|
|
758
|
+
for (const r of scoped) {
|
|
759
|
+
disclosure.push(
|
|
760
|
+
`- ${sourceTitle(r, 'profile')} — loads only when a matching file is in play (${r.scope.join(', ')}): ${scopedPaths(r, resolved).join(', ')}`,
|
|
761
|
+
);
|
|
762
|
+
sources.push(r.source);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
const skillNames = new Map();
|
|
766
|
+
for (const skill of skills) skillNames.set(skill.name, (skillNames.get(skill.name) ?? 0) + 1);
|
|
767
|
+
for (const skill of skills) {
|
|
768
|
+
const label = skillNames.get(skill.name) > 1 ? `${skill.name} (${skill.dir})` : skill.name;
|
|
769
|
+
disclosure.push(
|
|
770
|
+
skill.trigger === ''
|
|
771
|
+
? `- ${label} — no description, so nothing says when to open it. Show it with: ${CLI} skills show ${skill.name}`
|
|
772
|
+
: `- ${label} — ${skill.trigger}`,
|
|
555
773
|
);
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
774
|
+
}
|
|
775
|
+
if (disclosure.length > 0) text += `\n\n## Disclosure\n\n${disclosure.join('\n')}`;
|
|
776
|
+
|
|
777
|
+
return { body: text, sources };
|
|
778
|
+
};
|
|
779
|
+
|
|
780
|
+
// What the region will weigh in the file it lands in, measured the way the meter
|
|
781
|
+
// measures it: the composed region text, markers and provenance header included. One
|
|
782
|
+
// byte conservative, because the file's own trailing newline falls outside the span —
|
|
783
|
+
// and conservative in that direction is the only direction a ceiling can afford.
|
|
784
|
+
const regionBytes = (assembled) =>
|
|
785
|
+
Buffer.byteLength(compose(assembled.body, { sha, sources: assembled.sources }));
|
|
559
786
|
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
787
|
+
let assembled = assemble(new Set());
|
|
788
|
+
let packing = null;
|
|
789
|
+
if (mode === 'kernel') {
|
|
790
|
+
const reserve = await budgetReserve(root, resolved, bodies, sha);
|
|
791
|
+
const result = packToBudget({
|
|
792
|
+
// Least valuable first: the comparator the deferred section already uses, reversed.
|
|
793
|
+
// Class decides and the id breaks the tie, so the cut order is a fact about the
|
|
794
|
+
// repository rather than about the order the filesystem listed files in.
|
|
795
|
+
//
|
|
796
|
+
// The workspace-shared rules are not candidates, and that is not a rounding: cutting
|
|
797
|
+
// one cannot make the region smaller by more than the index line it adds to the
|
|
798
|
+
// section below, because the rule is carried once per workspace whatever this region
|
|
799
|
+
// does. A packer that spent its budget on them would report a saving it did not make.
|
|
800
|
+
candidates: shipped
|
|
801
|
+
.filter((r) => !carriedIds.has(r.id))
|
|
802
|
+
.sort((a, b) => -byPriorityThenId(a, b)),
|
|
803
|
+
ceiling: budget - reserve,
|
|
804
|
+
costs: (cut) => regionBytes(assemble(cut)),
|
|
805
|
+
});
|
|
806
|
+
packing = { ...result, bytes: budget, ceiling: budget - reserve, reserve };
|
|
807
|
+
if (result.cut.length > 0) assembled = assemble(new Set(result.cut));
|
|
808
|
+
if (result.cut.length > 0) {
|
|
809
|
+
notes.push(
|
|
810
|
+
`budget: cut ${result.cut.length} kernel rule(s) — ${result.cut.join(', ')} — from ${result.before} to ${result.after} bytes against a declared budget of ${budget}, with ${reserve} reserved for the generated files that load beside the region`,
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
if (result.floorHeld) {
|
|
814
|
+
// The one case the packer cannot fix, said where the reader will see it. `hard` is
|
|
815
|
+
// the floor, so the bytes still over the line are this repository's own hard
|
|
816
|
+
// constraints and the only levers left are its own: a larger budget, or a rule that
|
|
817
|
+
// is not as hard as it is declared. ADR-072's kernel finding says both; this says
|
|
818
|
+
// they were tried first.
|
|
819
|
+
notes.push(
|
|
820
|
+
`budget: still ${result.after} bytes after every rule outside the hard class was cut, against a ceiling of ${budget - reserve}; a hard constraint is not cut to reach a byte count, so the kernel itself is over the line`,
|
|
821
|
+
);
|
|
822
|
+
}
|
|
823
|
+
if (result.floorHeld && reserve >= budget) {
|
|
824
|
+
// A ceiling of zero or less is arithmetically true and useless to read: it says the
|
|
825
|
+
// region is allowed no bytes, without saying that the files loading beside it have
|
|
826
|
+
// already spent the whole budget. One number in that manifest is the only lever, and
|
|
827
|
+
// a reader left to infer it from a subtraction is a reader who will demote a hard
|
|
828
|
+
// rule to fix a budget that was set below the files it covers.
|
|
829
|
+
notes.push(
|
|
830
|
+
`budget: the ${reserve} bytes reserved for the generated files that load beside the region already reach the ${budget} declared, so no cut order meets this budget and the region is cut as far as the hard class allows`,
|
|
831
|
+
);
|
|
567
832
|
}
|
|
568
833
|
}
|
|
569
834
|
|
|
570
835
|
const meta = {
|
|
571
|
-
sha
|
|
572
|
-
sources,
|
|
836
|
+
sha,
|
|
837
|
+
sources: assembled.sources,
|
|
573
838
|
};
|
|
574
839
|
return {
|
|
575
840
|
stack,
|
|
@@ -580,6 +845,35 @@ export async function desiredRegion(root, flags) {
|
|
|
580
845
|
folded,
|
|
581
846
|
mode,
|
|
582
847
|
deferred: deferred.map((r) => r.id),
|
|
848
|
+
// What the workspace carries once per reader instead of here (ADR-084), as ids. The
|
|
849
|
+
// declaration is what `check` and `sync` both read to decide the region, so the ids
|
|
850
|
+
// that actually left are reported for the same reason the budget's cuts are: a rule
|
|
851
|
+
// that disappeared needs a name in the output, or a reader looks for it in vain.
|
|
852
|
+
carried: carried.map(({ rule }) => rule.id),
|
|
853
|
+
// What this root could contribute to a workspace's shared file, with the bytes it
|
|
854
|
+
// would contribute. Read by `simt shared`, never by the projection — which is why
|
|
855
|
+
// computing it costs a frame per rule and buys nothing on `sync`.
|
|
856
|
+
shareable,
|
|
857
|
+
// What it selected and cannot contribute, with the reason. Reported so that a rule
|
|
858
|
+
// staying in the region is always a decision somebody can see rather than an absence.
|
|
859
|
+
excluded,
|
|
860
|
+
// What the budget cost this projection (ADR-083), or null in `full` mode where no
|
|
861
|
+
// ceiling is applied because nothing is cut. The finding and `simt tokens` both read
|
|
862
|
+
// this rather than re-deriving it, so a cut cannot be reported one way and measured
|
|
863
|
+
// another.
|
|
864
|
+
budget: packing === null
|
|
865
|
+
? null
|
|
866
|
+
: {
|
|
867
|
+
bytes: packing.bytes,
|
|
868
|
+
ceiling: packing.ceiling,
|
|
869
|
+
reserve: packing.reserve,
|
|
870
|
+
before: packing.before,
|
|
871
|
+
after: packing.after,
|
|
872
|
+
floorHeld: packing.floorHeld,
|
|
873
|
+
cuts: shipped
|
|
874
|
+
.filter((r) => packing.cut.includes(r.id))
|
|
875
|
+
.map((r) => ({ id: r.id, priority: r.priority ?? 'unclassified' })),
|
|
876
|
+
},
|
|
583
877
|
// The marked lines that left, each with the evidence that dropped it. The region is
|
|
584
878
|
// written without them; `--explain` is what reads this list, so a removal is reportable
|
|
585
879
|
// without spending region bytes and without a second computation to find it.
|
|
@@ -594,11 +888,57 @@ export async function desiredRegion(root, flags) {
|
|
|
594
888
|
problems,
|
|
595
889
|
fallback,
|
|
596
890
|
notes,
|
|
597
|
-
body,
|
|
891
|
+
body: assembled.body,
|
|
598
892
|
meta,
|
|
599
893
|
};
|
|
600
894
|
}
|
|
601
895
|
|
|
896
|
+
/**
|
|
897
|
+
* The bytes held back from the region's own ceiling for the always-on files that load
|
|
898
|
+
* beside it.
|
|
899
|
+
*
|
|
900
|
+
* The budget is one number for a repository, not for one file, so packing the region
|
|
901
|
+
* against the whole number would ship a projection that fits on its own and pushes the
|
|
902
|
+
* total over the line the moment its rule files are written. Both terms below are lower
|
|
903
|
+
* bounds on what that costs, and the larger is taken because the failure each one covers
|
|
904
|
+
* is the one that must not happen: under-reserving reports compliance for a projection
|
|
905
|
+
* that is over budget.
|
|
906
|
+
*
|
|
907
|
+
* The region's own span is deliberately not in either. It is the one number that moves
|
|
908
|
+
* when this projection is packed, and a ceiling that depended on the thing it governs
|
|
909
|
+
* would rewrite the region on the next run and report drift on a tree `sync` had just
|
|
910
|
+
* written - the step-6 defect, rebuilt one layer up.
|
|
911
|
+
*
|
|
912
|
+
* @param {string} root
|
|
913
|
+
* @param {object} resolved the surfaces this projection writes for
|
|
914
|
+
* @param {{rule: object, body: string}[]} bodies the rule bodies being projected
|
|
915
|
+
* @param {string} sha the provenance digest those files will be stamped with
|
|
916
|
+
* @returns {Promise<number>}
|
|
917
|
+
*/
|
|
918
|
+
async function budgetReserve(root, resolved, bodies, sha) {
|
|
919
|
+
// Imported here rather than at the top of the module: `measure/context.mjs` reaches back
|
|
920
|
+
// into `projection/project.mjs` for the read-limit findings, so a static import from
|
|
921
|
+
// `rules.mjs` closes a loop through three modules that all have to survive being loaded
|
|
922
|
+
// in any order. The loop between the other two already exists and is load-bearing in
|
|
923
|
+
// neither direction; this one is walked only when a kernel is being packed.
|
|
924
|
+
const { measureRoot } = await import('../measure/context.mjs');
|
|
925
|
+
const measured = await measureRoot(root);
|
|
926
|
+
const region = measured.documents.find((d) => d.channel === 'region');
|
|
927
|
+
const installed = Math.max(0, measured.generatedBytes - (region?.generatedBytes ?? 0));
|
|
928
|
+
|
|
929
|
+
// What this projection is about to write, for a repository that has not written it yet.
|
|
930
|
+
// `ruleTargets` is the one place a rule file is turned into bytes, so the number comes
|
|
931
|
+
// from there rather than from a second measurement of the same file — and its notes go
|
|
932
|
+
// nowhere, because `mdcTargets` reports the same ones for the same files in the same run.
|
|
933
|
+
let planned = 0;
|
|
934
|
+
for (const { rule, body } of bodies) {
|
|
935
|
+
for (const target of (await ruleTargets(root, rule, body, sha, resolved, [])).targets) {
|
|
936
|
+
planned += target.bytes;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
return Math.max(installed, planned);
|
|
940
|
+
}
|
|
941
|
+
|
|
602
942
|
/**
|
|
603
943
|
* Every per-rule file a scoped profile is written to, as paths a reader can open.
|
|
604
944
|
*
|
|
@@ -721,7 +1061,11 @@ export async function loadRules(root = null) {
|
|
|
721
1061
|
// it travels the field that already reaches `check` and `rules list`. The
|
|
722
1062
|
// frontmatter defect wins, because a rule that cannot say what it applies to cannot
|
|
723
1063
|
// be reasoned about at all.
|
|
724
|
-
error:
|
|
1064
|
+
error:
|
|
1065
|
+
describeDefect(meta) ??
|
|
1066
|
+
inferableDefect(body) ??
|
|
1067
|
+
checkDefect(body) ??
|
|
1068
|
+
variantsDefect(body, meta['simt-id'] ?? ''),
|
|
725
1069
|
});
|
|
726
1070
|
}
|
|
727
1071
|
return out;
|
|
@@ -756,6 +1100,8 @@ export async function packageRegions(root, flags, rootProfiles = null) {
|
|
|
756
1100
|
if (packages.length === 0) return { regions: [], notes };
|
|
757
1101
|
|
|
758
1102
|
const rules = await loadRules(root);
|
|
1103
|
+
const lockedVariants =
|
|
1104
|
+
flags?.variants !== undefined ? flags.variants : await readLockedVariants(root);
|
|
759
1105
|
const sha = await provenanceDigest();
|
|
760
1106
|
// The ROOT's proofs, not the package's. The rule set is the root's, so which lines it
|
|
761
1107
|
// already proves is a fact about the repository rather than about the directory a file
|
|
@@ -769,8 +1115,12 @@ export async function packageRegions(root, flags, rootProfiles = null) {
|
|
|
769
1115
|
const delta = selected.filter((r) => !shared.includes(r.id));
|
|
770
1116
|
if (delta.length === 0) continue;
|
|
771
1117
|
const shipped = delta.map((r) => {
|
|
772
|
-
const
|
|
773
|
-
|
|
1118
|
+
const variantFolded = foldVariants(r.body, r.id ?? '', lockedVariants);
|
|
1119
|
+
const filtered = filterInferable(variantFolded.body, proofs);
|
|
1120
|
+
const folded = foldChecks(filtered.body);
|
|
1121
|
+
return variantFolded.changed || filtered.changed || folded.changed
|
|
1122
|
+
? { ...r, body: folded.body }
|
|
1123
|
+
: r;
|
|
774
1124
|
});
|
|
775
1125
|
regions.push({
|
|
776
1126
|
path: pkg.path,
|
|
@@ -910,7 +1260,15 @@ async function projectionMode(root) {
|
|
|
910
1260
|
* would move when a file is added to the directory, and a projection that reorders
|
|
911
1261
|
* itself on an unrelated commit is a projection nobody can diff (ADR-013).
|
|
912
1262
|
*/
|
|
913
|
-
|
|
1263
|
+
/** Core, then the remaining shipped profiles by id. Id, not load order: a directory
|
|
1264
|
+
* listing is not a fact about the rules, and two machines must not disagree about it. */
|
|
1265
|
+
function stableOrder(a, b) {
|
|
1266
|
+
if (a.id === 'core') return b.id === 'core' ? 0 : -1;
|
|
1267
|
+
if (b.id === 'core') return 1;
|
|
1268
|
+
return String(a.id).localeCompare(String(b.id));
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
export function byPriorityThenId(a, b) {
|
|
914
1272
|
const classA = PRIORITY_CLASSES.indexOf(a.priority ?? 'style');
|
|
915
1273
|
const classB = PRIORITY_CLASSES.indexOf(b.priority ?? 'style');
|
|
916
1274
|
if (classA !== classB) return classA - classB;
|