simt-dev 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/CHANGELOG.md +375 -7
  2. package/README.md +4 -4
  3. package/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +27 -0
  4. package/docs/decisions/ADR-059-the-version-a-repository-runs-is-the-one-it-pinned.md +75 -0
  5. package/docs/decisions/ADR-060-a-repository-with-nothing-to-run-is-adopted-and-says-so.md +131 -0
  6. package/docs/decisions/ADR-061-the-skill-registry-is-workspace-data.md +113 -0
  7. package/docs/decisions/ADR-062-a-fresh-adoption-seeds-the-gates-it-can-run.md +94 -0
  8. package/docs/decisions/ADR-063-the-adoption-escape-hatch-is-opt-in.md +69 -0
  9. package/docs/decisions/ADR-064-the-context-meter-counts-what-the-tool-wrote.md +107 -0
  10. package/docs/decisions/ADR-065-a-pack-is-one-shared-rule-set-pinned-by-digest.md +97 -0
  11. package/docs/decisions/ADR-066-a-monorepo-projects-once-per-package.md +115 -0
  12. package/docs/decisions/ADR-067-which-harnesses-read-agents-md-is-a-column.md +110 -0
  13. package/docs/decisions/ADR-068-the-mcp-revision-list-cites-what-was-read.md +111 -0
  14. package/docs/decisions/ADR-069-the-conformance-lint-checks-the-published-specification.md +90 -0
  15. package/docs/decisions/ADR-070-the-json-payload-carries-its-own-version.md +91 -0
  16. package/docs/decisions/ADR-071-a-rule-that-can-be-narrowed-is-written-narrow.md +106 -0
  17. package/docs/decisions/ADR-072-the-budget-is-a-declaration-beside-the-gates.md +104 -0
  18. package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +92 -0
  19. package/docs/decisions/README.md +17 -0
  20. package/package.json +1 -2
  21. package/rules/core.md +1 -0
  22. package/rules/stacks/android.md +2 -0
  23. package/rules/stacks/cloudflare-workers.md +2 -0
  24. package/rules/stacks/connectiq.md +1 -0
  25. package/rules/stacks/container.md +2 -0
  26. package/rules/stacks/dart.md +40 -0
  27. package/rules/stacks/deno.md +38 -0
  28. package/rules/stacks/dotnet.md +37 -0
  29. package/rules/stacks/elixir.md +41 -0
  30. package/rules/stacks/go.md +36 -0
  31. package/rules/stacks/jvm.md +2 -0
  32. package/rules/stacks/node.md +2 -0
  33. package/rules/stacks/python.md +2 -0
  34. package/rules/stacks/react-native.md +2 -0
  35. package/rules/stacks/react-web.md +2 -0
  36. package/rules/stacks/ruby.md +38 -0
  37. package/rules/stacks/rust.md +36 -0
  38. package/rules/stacks/swift.md +39 -0
  39. package/rules/stacks/typescript.md +2 -0
  40. package/rules/stacks/uv.md +2 -0
  41. package/src/argv.mjs +54 -6
  42. package/src/ci/dependabot.mjs +1 -1
  43. package/src/ci/workflow.mjs +33 -11
  44. package/src/commands/compare.mjs +15 -0
  45. package/src/commands/graph.mjs +58 -25
  46. package/src/commands/harness.mjs +150 -0
  47. package/src/commands/help.mjs +37 -5
  48. package/src/commands/hooks.mjs +173 -57
  49. package/src/commands/index.mjs +95 -69
  50. package/src/commands/init.mjs +100 -20
  51. package/src/commands/mcp.mjs +20 -5
  52. package/src/commands/rules.mjs +96 -60
  53. package/src/commands/skills.mjs +189 -86
  54. package/src/commands/skillsinstall.mjs +20 -5
  55. package/src/commands/sync.mjs +213 -15
  56. package/src/commands/tokens.mjs +138 -0
  57. package/src/compare/hooksstate.mjs +96 -9
  58. package/src/compare/row.mjs +107 -7
  59. package/src/decisions/index.mjs +21 -6
  60. package/src/graph/load.mjs +125 -14
  61. package/src/graph/spec.mjs +227 -0
  62. package/src/graph/speccheck.mjs +68 -0
  63. package/src/hooks/capability.mjs +48 -4
  64. package/src/hooks/dispatcher.mjs +84 -7
  65. package/src/hooks/gates.mjs +0 -2
  66. package/src/hooks/resolve.mjs +24 -25
  67. package/src/hooks/shim.mjs +29 -5
  68. package/src/identity.mjs +0 -11
  69. package/src/mcp/schema.mjs +139 -0
  70. package/src/mcp/server.mjs +197 -20
  71. package/src/mcp/tools.mjs +290 -3
  72. package/src/measure/context.mjs +371 -0
  73. package/src/monorepo.mjs +339 -0
  74. package/src/output/index.mjs +86 -9
  75. package/src/package-version.mjs +74 -0
  76. package/src/packs.mjs +182 -0
  77. package/src/projection/conflicts.mjs +56 -5
  78. package/src/projection/manifest.mjs +335 -25
  79. package/src/projection/mdc.mjs +4 -13
  80. package/src/projection/project.mjs +139 -8
  81. package/src/projection/rules.mjs +308 -27
  82. package/src/projection/surfaces.mjs +133 -15
  83. package/src/projection/targets.mjs +90 -7
  84. package/src/resolve/eval.mjs +64 -0
  85. package/src/resolve/fingerprint.mjs +1 -0
  86. package/src/resolve/near.mjs +26 -7
  87. package/src/resolve/resolve.mjs +1 -1
  88. package/src/resolve/source.mjs +1 -1
  89. package/src/workspace.mjs +0 -9
  90. package/graph/skills.graph.json +0 -1622
@@ -25,10 +25,11 @@ import { planDependabot } from '../ci/dependabot.mjs';
25
25
  import { checkTsconfig } from '../tsconfig.mjs';
26
26
  import { checkToolchain } from '../toolchain.mjs';
27
27
  import { diff, parse, inject, BEGIN, END } from './markers.mjs';
28
- import { readManifest } from './manifest.mjs';
28
+ import { budgetFinding, readManifest } from './manifest.mjs';
29
+ import { measureRoot } from '../measure/context.mjs';
29
30
  import { loadMdcRules, renderMdc } from './mdc.mjs';
30
31
  import { foldsRules, resolveSurfaces, SURFACES, SURFACE_IDS } from './surfaces.mjs';
31
- import { pointerState, pointerTargets, ruleTargets } from './targets.mjs';
32
+ import { pointerState, pointerTargets, ruleTargets, scopedTargets } from './targets.mjs';
32
33
  import { CLI } from '../name.mjs';
33
34
  import { MANIFEST_FILE } from '../name.mjs';
34
35
  import {
@@ -36,6 +37,7 @@ import {
36
37
  provenanceDigest,
37
38
  isEligible,
38
39
  loadRules,
40
+ packageRegions,
39
41
  LOCAL_PROFILE_DIR,
40
42
  mdcTargets,
41
43
  readDeclared,
@@ -69,12 +71,31 @@ export async function plan(root, flags = {}) {
69
71
 
70
72
  const surfaces = resolveSurfaces(manifest, flags);
71
73
 
74
+ // Profiles that narrowed out of the region, written as files for the surfaces whose
75
+ // scoping key was read in the vendor's own documentation. Only when EVERY selected
76
+ // surface can scope: a profile that left the region while a reader could not narrow
77
+ // would be a rule that reader never loads, and `desiredRegion` already kept it in the
78
+ // region in that case, which is why `narrowing` is the gate here too.
79
+ const scoped =
80
+ desired.narrowing === true
81
+ ? await scopedTargets(root, desired.scoped ?? [], meta.sha, surfaces, notes)
82
+ : { targets: [] };
83
+
72
84
  // The baselines are the same list shape as every other target, so `sync` writes
73
85
  // them and reports them in the same plan it prints for AGENTS.md and the rule
74
86
  // files. A file the reader was not shown is a file they approve by reading the diff
75
87
  // afterwards, which is the one review step this tool exists to make unnecessary.
76
88
  const baselines = await baselineTargets(root, meta.sha);
77
89
 
90
+ // A monorepo projects once per package as well as once at the root. The nested
91
+ // regions come after the root's and before the rule files, because the root is
92
+ // what a reader meets first and a plan that listed a package's region before the
93
+ // root's would read as though the packages were the repository.
94
+ const packages = await packageRegions(root, flags, profiles);
95
+ const nested = await Promise.all(
96
+ packages.regions.map((r) => targetFor(join(root, r.path), r.body, r.meta, `${r.path}/${PRIMARY_TARGET}`)),
97
+ );
98
+
78
99
  return {
79
100
  root,
80
101
  stack,
@@ -85,13 +106,14 @@ export async function plan(root, flags = {}) {
85
106
  surfaceSource: surfaces.source,
86
107
  surfaceProblems: surfaces.problems,
87
108
  rules: all,
88
- targets: [target, ...mdc.targets, ...baselines.targets],
109
+ targets: [target, ...nested, ...mdc.targets, ...scoped.targets, ...baselines.targets],
110
+ packages: packages.regions.map((r) => ({ path: r.path, profiles: r.profiles })),
89
111
  mdcProblems: mdc.problems,
90
- notes: [...notes, ...mdc.notes],
112
+ notes: [...notes, ...mdc.notes, ...packages.notes],
91
113
  };
92
114
  }
93
115
 
94
- async function targetFor(root, body, meta) {
116
+ async function targetFor(root, body, meta, label = PRIMARY_TARGET) {
95
117
  const path = join(root, PRIMARY_TARGET);
96
118
  let existing = '';
97
119
  let exists = true;
@@ -103,7 +125,10 @@ async function targetFor(root, body, meta) {
103
125
 
104
126
  const result = inject(existing, body, meta, await regionOpts(root));
105
127
  return {
106
- file: PRIMARY_TARGET,
128
+ // The repo-relative path, and not the basename: a monorepo writes the same
129
+ // document once per package, and a plan that printed `AGENTS.md` twice with no
130
+ // directory on either line would leave the reader unable to tell which is which.
131
+ file: label,
107
132
  path,
108
133
  exists,
109
134
  action: result.action,
@@ -140,6 +165,36 @@ export async function checkTargets(root, flags = {}) {
140
165
  findings.push(...readLimitFindings(text, PRIMARY_TARGET));
141
166
  }
142
167
 
168
+ // The budget: what this repository agreed to carry, against what it carries.
169
+ //
170
+ // Measured from the same `desiredRegion` the region above was compared against, plus
171
+ // the rule files this function went on to compare, because a gate and a meter that
172
+ // each counted their own set would report two different totals for one repository and
173
+ // the reader would have no way to tell which was the budget. Both numbers are the
174
+ // DESIRED ones, so a drifted repo is reported against what it would carry after a
175
+ // sync rather than what it carries today.
176
+ //
177
+ // Non-blocking by design: nothing is cut, because there is nothing to cut yet (ADR-072).
178
+ const budgetTotal = await alwaysOnBytes(root);
179
+ if (budgetTotal !== null) {
180
+ const { manifest } = await readManifest(root);
181
+ const over = budgetFinding(budgetTotal.bytes, manifest?.budget, manifest?.mode);
182
+ if (over) findings.push({ ...over, file: MANIFEST_FILE, detail: over.detail });
183
+ }
184
+
185
+ // Every package of a monorepo is a projection root in its own right, so the gate
186
+ // covers it the way it covers the root. A nested region that drifts is as stale as
187
+ // the root's region, and the reader working in that package reads only that file.
188
+ //
189
+ // Gated on the root having a region at all: a monorepo that has not adopted has no
190
+ // nested regions either, and reporting one as absent would describe a half-adoption
191
+ // that nobody performed.
192
+ const { regions: packageBodies } = await packageRegions(root, flags);
193
+ for (const region of packageBodies) {
194
+ const file = `${region.path}/${PRIMARY_TARGET}`;
195
+ findings.push({ ...(await checkRegion(root, join(root, region.path), region.body, region.meta)), file, profiles: region.profiles });
196
+ }
197
+
143
198
  // The gate has to run where the merge happens, not only on the machine of whoever
144
199
  // remembers to run it. A repo that already runs GitHub Actions and has no simt-dev
145
200
  // job is ungated at its front door, and that is the one finding here that no amount
@@ -189,7 +244,8 @@ export async function checkTargets(root, flags = {}) {
189
244
  // Selection must match `plan` exactly, or the gate reports a rule absent that
190
245
  // `sync` was never going to write — a permanently red check nothing can fix.
191
246
  const all = await loadMdcRules();
192
- const { stack, facts, manifest } = await desiredRegion(root, flags);
247
+ const desired = await desiredRegion(root, flags);
248
+ const { stack, facts, manifest } = desired;
193
249
  const declared = await readDeclared(root);
194
250
  const resolved = resolveSurfaces(manifest, flags);
195
251
  for (const p of resolved.problems) {
@@ -200,6 +256,36 @@ export async function checkTargets(root, flags = {}) {
200
256
  const sha = await provenanceDigest();
201
257
  const notes = [];
202
258
 
259
+ // The profiles that left the region, gated through the same construction that wrote
260
+ // them. Without this loop a scoped file is planned and written but never checked, so a
261
+ // repository that edits or deletes one shows a clean `check` and a stale reader — the
262
+ // exact asymmetry the region-versus-file gate exists to prevent.
263
+ const scopedRules = desired.scoped ?? [];
264
+ if (desired.narrowing === true) {
265
+ for (const target of (await scopedTargets(root, scopedRules, sha, resolved, notes)).targets) {
266
+ if (!target.exists) {
267
+ findings.push({
268
+ file: target.file,
269
+ surface: target.surface,
270
+ status: 'absent',
271
+ detail: `no ${target.surface} rule here; run: ${CLI} sync --yes`,
272
+ });
273
+ continue;
274
+ }
275
+ // The writer's own verdict, from the same inject call `plan` will make. A second
276
+ // construction here would be a third place computing the same file, which is the
277
+ // defect this whole function exists to avoid.
278
+ const action = target.action;
279
+ findings.push({
280
+ file: target.file,
281
+ surface: target.surface,
282
+ status: action === 'unchanged' ? 'current' : 'drift',
283
+ detail: action === 'unchanged' ? undefined : `region is ${action}; run: ${CLI} sync --yes`,
284
+ recordedSha: parse(target.previous).sha ?? null,
285
+ });
286
+ }
287
+ }
288
+
203
289
  for (const rule of rules) {
204
290
  if (rule.error !== null) {
205
291
  findings.push({
@@ -433,7 +519,18 @@ export async function checkTarget(root, flags = {}) {
433
519
 
434
520
  async function checkPrimary(root, flags = {}) {
435
521
  const { body, meta } = await desiredRegion(root, flags);
436
- const path = join(root, PRIMARY_TARGET);
522
+ return checkRegion(root, root, body, meta);
523
+ }
524
+
525
+ /**
526
+ * One region, compared against what this simt-dev would write for it.
527
+ *
528
+ * `dir` is the projection root that owns the document: the repository for the root
529
+ * region, a package for a nested one. `regionOpts` is read from the repository
530
+ * either way, because placement is a repository-level decision.
531
+ */
532
+ async function checkRegion(root, dir, body, meta) {
533
+ const path = join(dir, PRIMARY_TARGET);
437
534
  let existing = '';
438
535
  try {
439
536
  existing = await readFile(path, 'utf8');
@@ -468,6 +565,28 @@ async function checkPrimary(root, flags = {}) {
468
565
  expectedSha: meta.sha,
469
566
  };
470
567
  }
568
+ /**
569
+ * The bytes of always-on projection this repository carries, or null if it has not
570
+ * adopted at all.
571
+ *
572
+ * Delegates to the meter rather than counting here, and that is the whole of the
573
+ * design. A first version summed the region plus the rule files this function already
574
+ * compares, which sounds cheaper and is wrong: the meter also counts the managed skills
575
+ * and the `.mdc` files a reader loads, so the gate and the report came to different
576
+ * totals for one repository. Two definitions of one budget is the defect ADR-034 was
577
+ * filed about, rebuilt one layer up. The meter is the definition; the gate quotes it.
578
+ *
579
+ * The cost is that `check` now reads the documents the meter reads, which it did not
580
+ * do before. That is a real cost, paid knowingly: the alternative is a gate that fails
581
+ * on a number the report does not print, which is worse than a slower gate.
582
+ */
583
+ async function alwaysOnBytes(root) {
584
+ const { exists } = await readManifest(root);
585
+ if (!exists) return null;
586
+ const measured = await measureRoot(root);
587
+ return { bytes: measured.generatedBytes };
588
+ }
589
+
471
590
  /** `projection.place: "top"` puts the AGENTS.md region above the hand-written prefix. */
472
591
  async function regionOpts(root) {
473
592
  const { manifest } = await readManifest(root);
@@ -478,6 +597,10 @@ async function regionOpts(root) {
478
597
  * A reader that stops at a byte offset never sees a region that ends past it.
479
598
  * The finding names the reader, because "the file is long" does not say who stops.
480
599
  */
600
+ // Exported for the context meter (`simt-dev tokens`), which answers the same question
601
+ // about a region rather than about a diff. It reads the structured fields instead of
602
+ // re-deriving the limit, because a second implementation of "which reader stops, and
603
+ // where" is a second thing to keep right - and a second thing to quote in a message.
481
604
  export function readLimitFindings(text, file) {
482
605
  const state = parse(text);
483
606
  if (state.status !== 'ok' || typeof state.endByte !== 'number') return [];
@@ -489,6 +612,14 @@ export function readLimitFindings(text, file) {
489
612
  findings.push({
490
613
  file,
491
614
  status: 'beyond-read-limit',
615
+ // The numbers, not only the sentence. `check` prints the detail and the context
616
+ // meter puts the reader, the limit and the offset in its JSON, and a consumer
617
+ // that has to regex prose to get them is a consumer that breaks when the prose
618
+ // is edited.
619
+ reader: limit.reader,
620
+ limitBytes: limit.bytes,
621
+ endByte: state.endByte,
622
+ overBy: state.endByte - limit.bytes,
492
623
  detail: `BEYOND-READ-LIMIT: the region ends at byte ${state.endByte}, past ${limit.reader}'s ${limit.bytes}-byte read of ${file}`,
493
624
  remedy: `set projection.place to "top" in ${MANIFEST_FILE} and run: ${CLI} sync --yes, or raise ${limit.reader}'s project_doc_max_bytes`,
494
625
  });