simt-dev 0.2.2 → 0.3.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/bin/simt-dev.mjs +21 -2
  3. package/docs/decisions/ADR-074-a-rule-and-a-decision-are-readable-by-uri.md +99 -0
  4. package/docs/decisions/ADR-075-nothing-leaves-the-machine.md +56 -0
  5. package/docs/decisions/ADR-076-a-change-to-what-agents-read-is-measured.md +70 -0
  6. package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +91 -0
  7. package/docs/decisions/ADR-078-a-line-the-repository-already-proves-does-not-ship.md +117 -0
  8. package/docs/decisions/ADR-079-the-rules-are-enforced-at-the-tool-call-from-one-policy-table.md +162 -0
  9. package/docs/decisions/ADR-080-the-tools-own-names-are-not-repositories-and-neither-reader-decides-by-shape.md +99 -0
  10. package/docs/decisions/ADR-081-a-tracker-is-read-through-the-repositorys-template.md +36 -0
  11. package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +35 -0
  12. package/package.json +2 -1
  13. package/src/agenthooks/decide.mjs +123 -0
  14. package/src/agenthooks/policy.mjs +78 -0
  15. package/src/agenthooks/render.mjs +240 -0
  16. package/src/argv.mjs +36 -1
  17. package/src/bench/ablate.mjs +630 -0
  18. package/src/bench/adapters.json +149 -0
  19. package/src/bench/grade.mjs +229 -0
  20. package/src/bench/mine.mjs +811 -0
  21. package/src/bench/probes.mjs +1350 -0
  22. package/src/bench/report.mjs +899 -0
  23. package/src/bench/run.mjs +1002 -0
  24. package/src/commands/agenthook.mjs +282 -0
  25. package/src/commands/bench.mjs +359 -0
  26. package/src/commands/harness.mjs +156 -5
  27. package/src/commands/help.mjs +58 -1
  28. package/src/commands/index.mjs +25 -1
  29. package/src/commands/plan.mjs +99 -0
  30. package/src/commands/tokens.mjs +31 -0
  31. package/src/decisions/index.mjs +23 -5
  32. package/src/kernel/card.mjs +254 -0
  33. package/src/kernel/inferability.mjs +188 -0
  34. package/src/mcp/resources.mjs +157 -0
  35. package/src/mcp/server.mjs +37 -6
  36. package/src/mcp/tools.mjs +84 -0
  37. package/src/measure/context.mjs +177 -6
  38. package/src/measure/loaderView.mjs +435 -0
  39. package/src/measure/session.mjs +267 -0
  40. package/src/plan/index.mjs +650 -0
  41. package/src/plan/template.mjs +331 -0
  42. package/src/projection/loaders.mjs +689 -0
  43. package/src/projection/manifest.mjs +26 -3
  44. package/src/projection/rules.mjs +80 -5
  45. package/src/projection/surfaces.mjs +22 -8
  46. package/src/toolwords.mjs +69 -0
  47. package/templates/plan/tracker.json +36 -0
@@ -67,8 +67,12 @@ export async function manifestPath(root) {
67
67
  * real is the recorded SHA under `projection`.
68
68
  */
69
69
  export const MANIFEST_COMMENT = {
70
- $comment:
71
- `Written and maintained by simt-dev. Edit by hand only if you intend to take ownership of this file: \`${CLI} sync --yes\` regenerates the projected rules, and re-running \`${CLI} init\` refreshes the SHA without weakening a value you set here.`,
70
+ $comment: [
71
+ `Written and maintained by simt-dev. \`${CLI} sync --yes\` regenerates the projected rules.`,
72
+ `\`${CLI} init\` refreshes the SHA and keeps every key it does not write, so a value you add here survives a re-init.`,
73
+ `\`plans\` is such a key: it selects the tracker template and the directories \`${CLI} plan\` reads.`,
74
+ 'Omit it and the tracker template applies, which reads docs/plans and docs/reviews.',
75
+ ].join(' '),
72
76
  };
73
77
 
74
78
  /**
@@ -418,7 +422,7 @@ export function buildManifest({ root, stack = [], sha, version, existing = null,
418
422
  ? { ...planned.gates, commands: planned.commands }
419
423
  : planned.gates;
420
424
 
421
- return {
425
+ const owned = {
422
426
  ...MANIFEST_COMMENT,
423
427
  id: existing?.id ?? defaultId(root),
424
428
  stack: existing?.stack?.length ? existing.stack : stack,
@@ -473,6 +477,25 @@ export function buildManifest({ root, stack = [], sha, version, existing = null,
473
477
  // adopted. See src/package-version.mjs.
474
478
  projection: { simtDevSha: sha ?? null, version: version ?? null },
475
479
  };
480
+ return withRepoKeys(existing, owned);
481
+ }
482
+
483
+ /**
484
+ * Keys this writer does not recognise belong to the repository.
485
+ *
486
+ * A re-init used to return only the object above, so anything else in the file
487
+ * disappeared. `plans` is the tracker overlay, and it is not the only key a
488
+ * repository might have added. Owned keys stay in the order the writer emits
489
+ * them; an unknown key is copied after them, in the order the file had it.
490
+ */
491
+ function withRepoKeys(existing, owned) {
492
+ if (!existing || typeof existing !== 'object' || Array.isArray(existing)) return owned;
493
+ const kept = {};
494
+ for (const key of Object.keys(existing)) {
495
+ if (Object.hasOwn(owned, key)) continue;
496
+ kept[key] = existing[key];
497
+ }
498
+ return { ...owned, ...kept };
476
499
  }
477
500
 
478
501
  /**
@@ -30,6 +30,8 @@ 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
32
  import { manifestPath, readManifest } from './manifest.mjs';
33
+ import { commandCard, CARD_LIMIT_BYTES } from '../kernel/card.mjs';
34
+ import { filterInferable, inferableDefect, inferableProofs } from '../kernel/inferability.mjs';
33
35
  import { BASELINES } from './baselines.mjs';
34
36
  import { CLI } from '../name.mjs';
35
37
  import { PACK_DIR, resolvePacks } from '../packs.mjs';
@@ -408,6 +410,19 @@ async function ruleBodies(root, manifest, flags) {
408
410
  return { resolved, bodies, problems, notes, skipped };
409
411
  }
410
412
 
413
+ /**
414
+ * The command card for a repository and the evidence its markers may be proved by.
415
+ *
416
+ * Both the region and the package regions need the proofs, and they must be the same set:
417
+ * a rule dropped at the root and kept in a package would be one rule treated as two. The
418
+ * card comes back alongside because the caller that needs the proofs also needs the card's
419
+ * bytes, and building it twice would read the manifest twice for no second answer.
420
+ */
421
+ async function inferableEvidence(root, { stack, facts, manifest }) {
422
+ const card = await commandCard({ root, manifest, stack, facts });
423
+ return { card, proofs: inferableProofs({ facts, stack, card, manifest }) };
424
+ }
425
+
411
426
  /**
412
427
  * The one place the desired region is computed. `plan` (which writes) and
413
428
  * `checkTarget` (which gates) both go through this, so they cannot disagree
@@ -469,8 +484,50 @@ export async function desiredRegion(root, flags) {
469
484
  if (mode === 'kernel' && (r.error !== null || !KERNEL_CLASSES.has(r.priority))) deferred.push(r);
470
485
  else packed.push(r);
471
486
  }
472
- let body = render(packed);
473
- let sources = packed.map((r) => r.source);
487
+ // The command card leads the region. The exact commands for this repository are the
488
+ // bytes a reader cannot infer from rules that are identical everywhere (plan 0003 R10),
489
+ // so they go first — above even the core rules. It is derived from committed evidence
490
+ // only, in one place, so `plan` and `checkTarget` cannot disagree about it and two
491
+ // machines render it byte-identically.
492
+ const { card, proofs } = await inferableEvidence(root, { stack, facts, manifest });
493
+ if (card.dropped.length > 0) {
494
+ // Reported where every other projection note is reported. A card that lost a command
495
+ // in silence is a card a reader cannot tell is incomplete.
496
+ notes.push(
497
+ `command card: dropped ${card.dropped.join(', ')} to stay within ${CARD_LIMIT_BYTES} bytes`,
498
+ );
499
+ }
500
+ // Inferability (R09). A marked line leaves when the repository proves the evidence it
501
+ // names. `shipped` is a separate list from `packed` because they are separate decisions:
502
+ // the kernel is about which classes cost a reader nothing to skip, and this is about
503
+ // which lines the reader already knows.
504
+ const inferable = [];
505
+ const shipped = [];
506
+ for (const r of packed) {
507
+ const filtered = filterInferable(r.body, proofs);
508
+ if (!filtered.changed) {
509
+ shipped.push(r);
510
+ continue;
511
+ }
512
+ for (const x of filtered.removed) inferable.push({ rule: r.id, text: x.text, expr: x.expr });
513
+ shipped.push({ ...r, body: filtered.body });
514
+ }
515
+ let body = card.text ? `${card.text}\n\n${render(shipped)}` : render(shipped);
516
+ let sources = shipped.map((r) => r.source);
517
+ if (inferable.length > 0) {
518
+ // Never silent, and never a heading. A line that left with no trace repeats the
519
+ // mistake ADR-073 rejected for deferred rules, and a `## Dropped` section spends region
520
+ // bytes on lines the reader is being told not to read. A count they can act on, plus —
521
+ // with `--explain` — the evidence behind each one, is the third option.
522
+ notes.push(
523
+ `inferability: dropped ${inferable.length} line(s) this repository already proves; ${CLI} sync --explain names each one and the evidence behind it`,
524
+ );
525
+ if (flags?.explain === true) {
526
+ for (const x of inferable) {
527
+ notes.push(`inferable: ${x.rule} dropped "${x.text}" — proved by ${x.expr}`);
528
+ }
529
+ }
530
+ }
474
531
  const folded = [];
475
532
 
476
533
  // The pointer. A reader who opens AGENTS.md and does not find a profile they know is
@@ -523,6 +580,10 @@ export async function desiredRegion(root, flags) {
523
580
  folded,
524
581
  mode,
525
582
  deferred: deferred.map((r) => r.id),
583
+ // The marked lines that left, each with the evidence that dropped it. The region is
584
+ // written without them; `--explain` is what reads this list, so a removal is reportable
585
+ // without spending region bytes and without a second computation to find it.
586
+ inferable,
526
587
  // The profiles that left the region, and the surfaces that accepted them as files.
527
588
  // `plan` writes them and `checkTarget` gates them through this list, so a projected
528
589
  // file and a drifted one are decided by the same computation.
@@ -643,6 +704,7 @@ export async function loadRules(root = null) {
643
704
  for (const item of collected) {
644
705
  const text = await readFile(join(item.dir, item.file), 'utf8');
645
706
  const meta = parseFrontmatter(text);
707
+ const body = stripFrontmatter(text);
646
708
  out.push({
647
709
  file: item.file,
648
710
  origin: item.origin,
@@ -654,8 +716,12 @@ export async function loadRules(root = null) {
654
716
  scope: Array.isArray(meta['simt-scope']) ? meta['simt-scope'] : null,
655
717
  priority: meta['simt-priority'] ?? null,
656
718
  bytes: Buffer.byteLength(text),
657
- body: stripFrontmatter(text),
658
- error: describeDefect(meta),
719
+ body,
720
+ // A marker naming evidence the tool cannot check is a broken rule like any other, so
721
+ // it travels the field that already reaches `check` and `rules list`. The
722
+ // frontmatter defect wins, because a rule that cannot say what it applies to cannot
723
+ // be reasoned about at all.
724
+ error: describeDefect(meta) ?? inferableDefect(body),
659
725
  });
660
726
  }
661
727
  return out;
@@ -691,12 +757,21 @@ export async function packageRegions(root, flags, rootProfiles = null) {
691
757
 
692
758
  const rules = await loadRules(root);
693
759
  const sha = await provenanceDigest();
760
+ // The ROOT's proofs, not the package's. The rule set is the root's, so which lines it
761
+ // already proves is a fact about the repository rather than about the directory a file
762
+ // lands in — filtering a package by the package's own facts would keep a rule here and
763
+ // drop it one directory up, which is the same rule treated as two.
764
+ const { proofs } = await inferableEvidence(root, await fingerprint(root));
694
765
  const regions = [];
695
766
  for (const pkg of packages) {
696
767
  const fp = await fingerprint(join(root, pkg.path));
697
768
  const { selected } = select(rules, fp.stack ?? [], fp.facts ?? []);
698
769
  const delta = selected.filter((r) => !shared.includes(r.id));
699
770
  if (delta.length === 0) continue;
771
+ const shipped = delta.map((r) => {
772
+ const filtered = filterInferable(r.body, proofs);
773
+ return filtered.changed ? { ...r, body: filtered.body } : r;
774
+ });
700
775
  regions.push({
701
776
  path: pkg.path,
702
777
  ecosystem: pkg.ecosystem,
@@ -706,7 +781,7 @@ export async function packageRegions(root, flags, rootProfiles = null) {
706
781
  // One sentence, because the file's whole purpose is to be found by a reader
707
782
  // who does not know it exists: without it, a nested region reads as a
708
783
  // 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)}`,
784
+ 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(shipped)}`,
710
785
  meta: { sha, sources: delta.map((r) => r.source) },
711
786
  });
712
787
  }
@@ -91,13 +91,13 @@ export const SURFACES = [
91
91
  id: 'opencode',
92
92
  label: 'opencode (AGENTS.md + .agents/skills)',
93
93
  readers:
94
- 'opencode, and AGENTS.md is also read natively by Codex, Cursor, Windsurf, Zed, Amp, Jules, Copilot CLI and the DeepSeek harness; Claude Code falls back to it, Antigravity and Gemini CLI accept GEMINI.md as an equal name',
94
+ 'OpenCode v2, and AGENTS.md is also read natively by Codex, Cursor, Windsurf, Zed, Amp, Jules, Copilot CLI and the DeepSeek harness; Claude Code falls back to it, Antigravity and Gemini CLI accept GEMINI.md as an equal name',
95
95
  primary: 'AGENTS.md',
96
96
  kind: 'region',
97
- // opencode documents NO per-rule directory: it reads AGENTS.md (walking up to the
98
- // git worktree), CLAUDE.md only as a fallback, and .opencode/AGENTS.md in
99
- // addition. So `foldRules` — this surface carries every rule inside the one
100
- // generated region, and the other rows are how a rule reaches an editor that
97
+ // OpenCode v2 documents no per-rule directory. It reads AGENTS.md only — every
98
+ // file from the workspace up to the project root, combined — and it does not fall
99
+ // back to CLAUDE.md. So `foldRules`: this surface carries every rule inside the
100
+ // one generated region, and the other rows are how a rule reaches an editor that
101
101
  // cannot read AGENTS.md. Writing .opencode/rules/*.md would be a guess, and a
102
102
  // guessed path is the failure this table exists to prevent.
103
103
  rule: null,
@@ -118,7 +118,7 @@ export const SURFACES = [
118
118
  },
119
119
  ],
120
120
  verified:
121
- 'opencode docs, "Rules" and "Agent Skills" (read 2026-10-07) — AGENTS.md walked up from cwd to the git worktree; CLAUDE.md used only if no AGENTS.md exists; .opencode/AGENTS.md additionally; skills at .opencode/skills/, .claude/skills/ and .agents/skills/<name>/SKILL.md; skill frontmatter recognises only name, description, license, compatibility, metadata',
121
+ 'OpenCode v2 instructions and skills docs (opencode.ai/v2/docs, read 2026-10-14) — AGENTS.md only, combined from the workspace up to the project root; CLAUDE.md is not a fallback; skills at .opencode/skills/, .claude/skills/ and .agents/skills/<name>/SKILL.md, with the model shown name and description',
122
122
  note:
123
123
  '.agents/skills is one of the three skill locations opencode documents, so the same directory serves the standard surface without a symlink.',
124
124
  },
@@ -286,8 +286,12 @@ export const POINTERS = [
286
286
  to: 'AGENTS.md',
287
287
  readers: 'Gemini CLI, Antigravity',
288
288
  verified:
289
- 'Google Antigravity docs, "Rules": workspace rules are AGENTS.md or GEMINI.md in the root or any subdirectory; Copilot CLI also discovers GEMINI.md',
290
- note: 'Covers Gemini CLI and gives Antigravity a second, equally-native name for the same file.',
289
+ 'Google Antigravity docs, "Rules": workspace rules are AGENTS.md or GEMINI.md in the root or any subdirectory; Copilot CLI also discovers GEMINI.md. ' +
290
+ 'Gemini CLI, read today: its own docs (core memory index, and the configuration reference) say the memory service loads GEMINI.md hierarchically from the working directory up to the project root and the home directory and also into subdirectories, and that `context.fileName` in settings.json "accepts either a single string or an array of strings" naming the context file(s) to load, default undefined',
291
+ note:
292
+ 'Covers Gemini CLI and gives Antigravity a second, equally-native name for the same file. ' +
293
+ 'A repository can also point Gemini CLI at AGENTS.md itself with `context.fileName: ["AGENTS.md"]` and need no pointer at all, ' +
294
+ 'which is the reason there is no separate Gemini CLI row here: this pointer and a native row would be two ways to hand one reader the same rule.',
291
295
  },
292
296
  ];
293
297
 
@@ -399,6 +403,16 @@ export const CANDIDATE_DIRS = [
399
403
  { dir: '.continue', harness: 'Continue' },
400
404
  { dir: '.amp', harness: 'Amp' },
401
405
  { dir: '.goose', harness: 'Goose' },
406
+ // Home directory, seen only with `harness detect --user`. DeepSeek keeps user data
407
+ // under ~/.dsh and reads the repository's AGENTS.md; it has no project config
408
+ // directory and no per-rule file to project (ADR-067).
409
+ {
410
+ dir: '.dsh',
411
+ harness: 'DeepSeek harness',
412
+ readsAgentsMd: 'all',
413
+ verified:
414
+ 'DeepSeek harness agent-instructions docs (read 2026-10-14): user data lives at $DSH_HOME or ~/.dsh, and the project chain is AGENTS.md',
415
+ },
402
416
  ];
403
417
 
404
418
  /**
@@ -0,0 +1,69 @@
1
+ // The `simt-` names this tool owns, and the records that argue about the prefix itself.
2
+ //
3
+ // Two readers need to tell this tool's own vocabulary from a repository in the estate
4
+ // these records were written about: the decision reader, which redacts the second kind
5
+ // and names it, and the guard on shipped text, which refuses to ship it. Both answered
6
+ // the question with a shape — `simt-` followed by anything but `dev` — and the shape is
7
+ // false about the tool's own names, which the corpus prints. `rules why ADR-073` reported
8
+ // that the ruling surveyed `simt-priority` as a repository, and printed the same word as
9
+ // `a repository: hard | command | convention | style` where the record says
10
+ // `simt-priority: hard | command | convention | style`: a reader with the tool and not
11
+ // the estate got a sentence with no subject, from the command whose whole job is to
12
+ // explain a citation.
13
+ //
14
+ // The words are derived from the modules that own them (ADR-022) rather than listed
15
+ // here, because a second list is a list that drifts and then forgives. Both readers read
16
+ // this one, and a name added to a frontmatter vocabulary reaches both without an edit.
17
+ import { KNOWN_KEYS } from './projection/mdc.mjs';
18
+ import { PROFILE_KEYS } from './projection/rules.mjs';
19
+ import { INFERABLE_MARKER } from './kernel/inferability.mjs';
20
+
21
+ /**
22
+ * `simt-` names that belong to this tool and never to a repository.
23
+ *
24
+ * The last three are not frontmatter: they are names this tool writes into files it
25
+ * adopts a repository into — the package, the gate file it puts in `.github`, and the
26
+ * hooks directory it points `core.hooksPath` at. `simt-dev` itself is here for the same
27
+ * reason the estate pattern exempts it, and stating it once here is what stops the two
28
+ * readers from disagreeing about the tool's own name.
29
+ */
30
+ export const TOOL_WORDS = new Set([
31
+ ...KNOWN_KEYS,
32
+ ...PROFILE_KEYS,
33
+ INFERABLE_MARKER,
34
+ 'simt-dev',
35
+ 'simt-dev-check',
36
+ 'simt-hooks',
37
+ ]);
38
+
39
+ /**
40
+ * Records whose subject is the prefix itself, so the prefixed names in them are the
41
+ * argument rather than a citation.
42
+ *
43
+ * Each entry is a file and a reason, never a repository. That distinction is the whole
44
+ * point: an exemption says "this document discusses the namespace", which is a fact
45
+ * about this tool and carries nothing about whoever uses it. A vocabulary cannot settle
46
+ * these two — a hypothetical key in an argument and a repository name are the same shape
47
+ * — so the file is exempted where the word cannot be.
48
+ *
49
+ * The guard fails on a stale entry: each one must still be tripping the rule, so an
50
+ * exemption that stops being needed is removed rather than kept as a precedent.
51
+ */
52
+ export const PREFIX_ARGUING = new Map([
53
+ [
54
+ 'ADR-022-a-key-simt-dev-reads-is-prefixed.md',
55
+ 'is the record arguing for the prefix, using hypothetical keys as examples',
56
+ ],
57
+ [
58
+ 'ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md',
59
+ 'names one prefixed key to record Dependabot rejecting it',
60
+ ],
61
+ [
62
+ 'ADR-080-the-tools-own-names-are-not-repositories-and-neither-reader-decides-by-shape.md',
63
+ 'quotes the prefixed names it measured, which are its evidence rather than a citation',
64
+ ],
65
+ [
66
+ 'ADR-081-a-tracker-is-read-through-the-repositorys-template.md',
67
+ 'names the plan overlay file the next record withdraws, and that filename has the shape of a repository',
68
+ ],
69
+ ]);
@@ -0,0 +1,36 @@
1
+ {
2
+ "$comment": "Tracker format delivered with the tool. A repository replaces any section with the plans key in simt.json; a section it omits stays as written here. directories are the only places read, and each word is what a dependency uses to name that place (\"review 0002 S41\"). Headers are tried in the order written. Only a state marked satisfies unblocks a slice that depends on it. ready is the state plan next returns. dependency.group matches a wave of rows; wave names that table's columns.",
3
+ "name": "tracker",
4
+ "directories": [
5
+ { "kind": "plans", "word": "plan", "path": "docs/plans" },
6
+ { "kind": "reviews", "word": "review", "path": "docs/reviews" }
7
+ ],
8
+ "id": {
9
+ "pattern": "^[A-Za-z]{0,4}[- ]?[0-9]{1,4}$",
10
+ "headers": ["id", "#", "step", "item", "slice", "task"]
11
+ },
12
+ "columns": {
13
+ "state": ["state", "status", "done when"],
14
+ "depends": ["depends on", "depends", "dependency", "dependencies", "deps", "needs"],
15
+ "title": ["slice", "deliverable", "title", "summary", "description", "content"],
16
+ "evidence": ["sha / evidence", "sha/evidence", "evidence", "sha", "notes", "note"]
17
+ },
18
+ "states": [
19
+ { "id": "queued", "write": "Queued", "read": ["queued"] },
20
+ { "id": "in-progress", "write": "In progress", "read": ["in progress", "in-progress"] },
21
+ { "id": "complete", "write": "Complete", "read": ["complete", "completed", "done"], "satisfies": true },
22
+ { "id": "accepted", "write": "Accepted", "read": ["accepted"] }
23
+ ],
24
+ "ready": "queued",
25
+ "dependency": {
26
+ "empty": ["none", "null", "n/a", "n.a.", "na", "—", "-", "–"],
27
+ "separators": ",;",
28
+ "notes": true,
29
+ "group": "^waves?\\s+(.+)$"
30
+ },
31
+ "wave": {
32
+ "label": ["wave"],
33
+ "members": ["slices"]
34
+ },
35
+ "section": { "depth": 3 }
36
+ }