simt-dev 0.6.3 → 0.7.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 (137) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +2 -2
  3. package/agents/simt-agent.md +84 -0
  4. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  5. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
  6. package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
  7. package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
  8. package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
  9. package/docs/decisions/ADR-109-simt-dev-is-its-own-consumer-and-the-guards-that-kept-it-from-being-one-are-retired.md +47 -0
  10. package/docs/decisions/ADR-110-the-lock-file-follows-the-manifest-name-and-the-old-name-is-read-until-the-next-write.md +43 -0
  11. package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
  12. package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
  13. package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
  14. package/docs/decisions/ADR-114-the-bench-pairs-at-the-task-level-a-missing-cost-is-not-zero-and-an-unasked-cross-group-compare-is-refused.md +74 -0
  15. package/docs/decisions/ADR-115-the-index-stamps-its-schema-version-migrates-older-databases-in-place-and-serves-newer-ones-from-a-sidecar.md +69 -0
  16. package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
  17. package/docs/decisions/README.md +9 -0
  18. package/package.json +4 -2
  19. package/rules/core.md +2 -2
  20. package/rules/mdc/test-gate.mdc +2 -0
  21. package/rules/stacks/android.md +3 -3
  22. package/rules/stacks/connectiq.md +2 -2
  23. package/rules/stacks/dart.md +1 -1
  24. package/rules/stacks/deno.md +1 -1
  25. package/rules/stacks/dotnet.md +1 -1
  26. package/rules/stacks/elixir.md +1 -1
  27. package/rules/stacks/go.md +1 -1
  28. package/rules/stacks/jvm.md +3 -2
  29. package/rules/stacks/node.md +2 -2
  30. package/rules/stacks/python.md +7 -6
  31. package/rules/stacks/ruby.md +1 -1
  32. package/rules/stacks/rust.md +1 -1
  33. package/rules/stacks/swift.md +1 -1
  34. package/rules/stacks/typescript.md +2 -2
  35. package/rules/stacks/uv.md +3 -2
  36. package/skills/simt-commands/SKILL.md +70 -0
  37. package/src/advice/advice.mjs +7 -3
  38. package/src/agenthooks/decide.mjs +141 -9
  39. package/src/agenthooks/opencode.mjs +72 -0
  40. package/src/agenthooks/prompt.mjs +6 -2
  41. package/src/agenthooks/render.mjs +186 -19
  42. package/src/agenthooks/shim.mjs +55 -0
  43. package/src/agents/render.mjs +277 -0
  44. package/src/argv.mjs +8 -0
  45. package/src/bench/ablate.mjs +31 -14
  46. package/src/bench/mine.mjs +48 -1
  47. package/src/bench/probes.mjs +5 -6
  48. package/src/bench/report.mjs +243 -80
  49. package/src/bench/run.mjs +12 -11
  50. package/src/bench/variants.mjs +112 -40
  51. package/src/brief/assemble.mjs +6 -0
  52. package/src/ci/workflow.mjs +16 -7
  53. package/src/commands/agent.mjs +206 -0
  54. package/src/commands/agenthook.mjs +109 -15
  55. package/src/commands/bench.mjs +2 -1
  56. package/src/commands/compare.mjs +8 -7
  57. package/src/commands/decide.mjs +6 -5
  58. package/src/commands/evidence.mjs +13 -3
  59. package/src/commands/gate.mjs +85 -19
  60. package/src/commands/graph.mjs +18 -5
  61. package/src/commands/help.mjs +35 -14
  62. package/src/commands/hooks.mjs +68 -25
  63. package/src/commands/impact.mjs +4 -2
  64. package/src/commands/index.mjs +17 -4
  65. package/src/commands/init.mjs +169 -37
  66. package/src/commands/lease.mjs +203 -35
  67. package/src/commands/lessons.mjs +65 -9
  68. package/src/commands/map.mjs +33 -18
  69. package/src/commands/plugin.mjs +1 -1
  70. package/src/commands/repoindex.mjs +1 -0
  71. package/src/commands/rules.mjs +92 -18
  72. package/src/commands/skills.mjs +60 -25
  73. package/src/commands/skillscompile.mjs +8 -7
  74. package/src/commands/skillsinstall.mjs +12 -0
  75. package/src/commands/slice.mjs +377 -18
  76. package/src/commands/sync.mjs +37 -22
  77. package/src/commands/tests.mjs +5 -2
  78. package/src/commands/undo.mjs +24 -16
  79. package/src/commands/upgrade.mjs +33 -1
  80. package/src/commands/why.mjs +10 -4
  81. package/src/compare/hooksstate.mjs +23 -1
  82. package/src/compare/learn.mjs +82 -57
  83. package/src/compare/parallel.mjs +50 -0
  84. package/src/compare/row.mjs +26 -1
  85. package/src/completions.mjs +5 -0
  86. package/src/decisions/dir.mjs +84 -0
  87. package/src/decisions/index.mjs +109 -11
  88. package/src/distill/distill.mjs +3 -1
  89. package/src/ecosystem/node-runner.mjs +144 -0
  90. package/src/explain.mjs +18 -6
  91. package/src/graph/load.mjs +38 -19
  92. package/src/hooks/capability.mjs +14 -1
  93. package/src/hooks/dispatcher.mjs +335 -13
  94. package/src/hooks/resolve.mjs +9 -3
  95. package/src/hooks/shim.mjs +12 -7
  96. package/src/identity.mjs +67 -14
  97. package/src/index/extract.mjs +192 -11
  98. package/src/index/persist.mjs +307 -47
  99. package/src/index/store.mjs +263 -60
  100. package/src/index/tests.mjs +6 -35
  101. package/src/journal.mjs +296 -77
  102. package/src/kernel/card.mjs +37 -12
  103. package/src/kernel/checks.mjs +44 -9
  104. package/src/kernel/variants.mjs +27 -16
  105. package/src/lease.mjs +97 -15
  106. package/src/ledger/index.mjs +91 -25
  107. package/src/mcp/resources.mjs +5 -3
  108. package/src/mcp/server.mjs +81 -22
  109. package/src/mcp/tools.mjs +45 -9
  110. package/src/memory/decide.mjs +98 -43
  111. package/src/memory/lessons.mjs +168 -33
  112. package/src/name.mjs +9 -0
  113. package/src/plan/index.mjs +31 -8
  114. package/src/plan/template.mjs +27 -5
  115. package/src/plugins.mjs +6 -2
  116. package/src/prbody.mjs +7 -5
  117. package/src/projection/baselines.mjs +1 -1
  118. package/src/projection/disclosure.mjs +2 -2
  119. package/src/projection/loaders.mjs +41 -1
  120. package/src/projection/lock.mjs +21 -14
  121. package/src/projection/manifest.mjs +69 -19
  122. package/src/projection/markers.mjs +43 -3
  123. package/src/projection/mdc.mjs +34 -7
  124. package/src/projection/project.mjs +162 -18
  125. package/src/projection/rules.mjs +54 -3
  126. package/src/projection/surfaces.mjs +9 -1
  127. package/src/repo-root.mjs +69 -0
  128. package/src/resolve/body.mjs +4 -0
  129. package/src/resolve/fingerprint.mjs +5 -0
  130. package/src/resolve/local.mjs +58 -0
  131. package/src/review/critic.mjs +4 -2
  132. package/src/secrets.mjs +165 -22
  133. package/src/state.mjs +76 -0
  134. package/src/status.mjs +9 -4
  135. package/src/toolwords.mjs +5 -0
  136. package/src/tsconfig.mjs +39 -99
  137. package/templates/plan/tracker.json +1 -1
package/src/mcp/tools.mjs CHANGED
@@ -22,7 +22,7 @@
22
22
  import { loadRegistry } from '../graph/load.mjs';
23
23
  import { provenanceDigest } from '../projection/project.mjs';
24
24
  import { discover } from '../compare/discover.mjs';
25
- import { buildRow, summarise } from '../compare/row.mjs';
25
+ import { buildRows, resolveConcurrency, summarise } from '../compare/row.mjs';
26
26
  import { GATES } from '../hooks/gates.mjs';
27
27
  import { checkRepo } from '../commands/sync.mjs';
28
28
  import { diagnose, serialisedFindings } from '../commands/doctor.mjs';
@@ -381,6 +381,9 @@ const output = {
381
381
  repos: { type: 'array', items: { type: 'object' } },
382
382
  worktrees: { type: 'array', items: { type: 'object' } },
383
383
  directories: { type: 'array', items: { type: 'string' } },
384
+ partial: { type: 'boolean' },
385
+ cursor: { type: ['string', 'null'] },
386
+ total: { type: 'number' },
384
387
  },
385
388
  required: ['root', 'shape', 'expectedSha', 'summary', 'repos'],
386
389
  },
@@ -548,7 +551,13 @@ export const TOOLS = [
548
551
  name: 'skills_index',
549
552
  description:
550
553
  'Full skill catalogue: every skill with summary, size, stack tags and deferred flag. The fallback when brief returns nothing useful.',
551
- inputSchema: noArgs,
554
+ inputSchema: {
555
+ type: 'object',
556
+ properties: {
557
+ registry: { type: 'string', description: 'path to skills.graph.json; else --registry / SIMT_REGISTRY / default tiers' },
558
+ },
559
+ required: [],
560
+ },
552
561
  async run(args) {
553
562
  const { registry: reg, verified, workspace } = await registry(args);
554
563
  return {
@@ -578,6 +587,7 @@ export const TOOLS = [
578
587
  budget: { type: 'number', description: 'context budget in bytes (default 20480)' },
579
588
  skill: { type: 'string', description: 'force a skill in, comma-separated' },
580
589
  all: { type: 'boolean', description: 'ignore the budget' },
590
+ registry: { type: 'string', description: 'path to skills.graph.json; else --registry / SIMT_REGISTRY / default tiers' },
581
591
  },
582
592
  required: [],
583
593
  },
@@ -610,6 +620,7 @@ export const TOOLS = [
610
620
  name: { type: 'string' },
611
621
  root: { type: 'string' },
612
622
  maxBytes: { type: 'number', description: 'hard ceiling on the returned text' },
623
+ registry: { type: 'string', description: 'path to skills.graph.json; else --registry / SIMT_REGISTRY / default tiers' },
613
624
  },
614
625
  required: ['name'],
615
626
  },
@@ -632,11 +643,16 @@ export const TOOLS = [
632
643
  description: 'Raw search across capability, keyword and summary. Use when skills_brief has nothing and you suspect a skill exists.',
633
644
  inputSchema: {
634
645
  type: 'object',
635
- properties: { query: { type: 'string' } },
646
+ properties: {
647
+ query: { type: 'string' },
648
+ root: { type: 'string' },
649
+ registry: { type: 'string', description: 'path to skills.graph.json; else --registry / SIMT_REGISTRY / default tiers' },
650
+ },
636
651
  required: ['query'],
637
652
  },
638
653
  async run(args) {
639
- return skillsFindFacts(typeof args?.query === 'string' ? args.query : '', args ?? {});
654
+ const root = rootOf(args);
655
+ return skillsFindFacts(root, typeof args?.query === 'string' ? args.query : '', args ?? {});
640
656
  },
641
657
  },
642
658
 
@@ -647,7 +663,12 @@ export const TOOLS = [
647
663
  'The signal chain for one skill in one repository: its score, the reasons, whether the stack gate admits it, and what would select it.',
648
664
  inputSchema: {
649
665
  type: 'object',
650
- properties: { name: { type: 'string' }, root: { type: 'string' }, task: { type: 'string' } },
666
+ properties: {
667
+ name: { type: 'string' },
668
+ root: { type: 'string' },
669
+ task: { type: 'string' },
670
+ registry: { type: 'string', description: 'path to skills.graph.json; else --registry / SIMT_REGISTRY / default tiers' },
671
+ },
651
672
  required: ['name'],
652
673
  },
653
674
  async run(args) {
@@ -710,23 +731,37 @@ export const TOOLS = [
710
731
  outputSchema: output.compare,
711
732
  name: 'compare',
712
733
  description:
713
- 'The workspace matrix: every repository with its adoption, region state, pinned SHA, hook routing and gates, plus the problems found.',
734
+ 'The workspace matrix: adoption, region, SHA, hooks and gates per repo, plus problems. Pages with limit/cursor; honors progressToken and cancellation.',
714
735
  inputSchema: {
715
736
  type: 'object',
716
737
  properties: {
717
738
  root: { type: 'string', description: 'a workspace directory, or one repository' },
718
739
  workspace: { type: 'string', description: 'workspace root when root is not given' },
740
+ limit: { type: 'number', description: 'page size; without it every row is built' },
741
+ cursor: { type: 'string', description: 'opaque page start from a previous partial answer' },
742
+ concurrency: { type: 'number', description: 'rows built at once (default 4)' },
719
743
  },
720
744
  required: [],
721
745
  },
722
- async run(args) {
746
+ async run(args, ctx = {}) {
723
747
  const root = args?.root ?? args?.workspace ?? (await resolveWorkspace(args ?? {})).root ?? process.cwd();
724
748
  const found = await discover(root);
725
749
  if (found.error) throw Object.assign(new Error(`${root}: ${found.error}`), { remedy: 'pass root' });
726
750
  const expectedSha = await provenanceDigest();
727
751
  const context = { expectedSha, gates: new Set(Object.keys(GATES)), gateKinds: GATE_KINDS };
728
- const rows = [];
729
- for (const repo of found.repos) rows.push(await buildRow(repo, context));
752
+ // A page names its start as an index into discovery order, which is
753
+ // stable between calls on an unchanged estate; garbage parses to 0
754
+ // rather than erroring, because a dashboard that refuses to run over a
755
+ // stale cursor is worse than one that restarts the listing.
756
+ const start = Math.max(0, parseInt(typeof args?.cursor === 'string' ? args.cursor : '0', 10) || 0);
757
+ const limit = typeof args?.limit === 'number' && args.limit >= 1 ? Math.floor(args.limit) : null;
758
+ const page = limit === null ? found.repos : found.repos.slice(start, start + limit);
759
+ const rows = await buildRows(page, context, {
760
+ concurrency: resolveConcurrency(args ?? {}),
761
+ signal: ctx?.signal ?? null,
762
+ onBatch: ctx?.onProgress ? (done, total) => ctx.onProgress(start + done, found.repos.length) : null,
763
+ });
764
+ const next = limit !== null && start + page.length < found.repos.length ? String(start + page.length) : null;
730
765
  return {
731
766
  root,
732
767
  shape: found.shape,
@@ -735,6 +770,7 @@ export const TOOLS = [
735
770
  repos: rows,
736
771
  worktrees: found.worktrees.map((w) => ({ name: w.name, owner: w.ownerName })),
737
772
  directories: found.directories,
773
+ ...(limit === null ? {} : { partial: next !== null, cursor: next, total: found.repos.length }),
738
774
  };
739
775
  },
740
776
  },
@@ -7,7 +7,8 @@ import { execFile } from 'node:child_process';
7
7
  import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
8
8
  import { join } from 'node:path';
9
9
  import { promisify } from 'node:util';
10
- import { journaledWriteFile } from '../journal.mjs';
10
+ import { recordCreatedFile } from '../journal.mjs';
11
+ import { decisionsDir } from '../decisions/dir.mjs';
11
12
 
12
13
  const exec = promisify(execFile);
13
14
 
@@ -336,15 +337,19 @@ function renderDecisionScaffold({
336
337
  ].join('\n');
337
338
  }
338
339
 
339
- /** Scaffold a new ADR in `<root>/docs/decisions` with structured frontmatter. */
340
+ /**
341
+ * Scaffold a new ADR in the repository's decisions directory with structured frontmatter.
342
+ *
343
+ * The number is claimed with `O_EXCL`: two concurrent scaffolds that both read
344
+ * the same maximum must not land on the same number, and a check-then-write
345
+ * would let the second silently overwrite the first. On collision the number is
346
+ * re-read and the next free one taken, so the retry always terminates.
347
+ */
340
348
  export async function scaffoldDecision(root, options = {}) {
341
- const decisionsDir = options.decisionsDir ?? join(root, 'docs', 'decisions');
342
- const { number, id } = await nextDecisionNumber(decisionsDir);
349
+ const relDir = options.decisionsDir ?? (await decisionsDir(root));
350
+ const absDir = join(root, relDir);
343
351
  const title = String(options.title ?? 'Untitled decision').trim() || 'Untitled decision';
344
352
  const slug = slugifyTitle(title);
345
- const file = `${id}-${slug}.md`;
346
- const relPath = ['docs', 'decisions', file].join('/');
347
- const fullPath = join(decisionsDir, file);
348
353
  const affects = Array.isArray(options.affects)
349
354
  ? options.affects.map((p) => String(p).trim()).filter(Boolean)
350
355
  : await diffAffectedPaths(root);
@@ -352,38 +357,58 @@ export async function scaffoldDecision(root, options = {}) {
352
357
  const { entries: supersedes, errors } = parseSupersedesSpec(options.supersedes);
353
358
  if (errors.length > 0) return { ok: false, errors };
354
359
 
355
- const text = renderDecisionScaffold({
356
- id,
357
- title,
358
- date: options.date,
359
- status: options.status,
360
- affects,
361
- supersedes,
362
- });
363
-
364
- // A caller-supplied journal records the scaffold so `undo` can restore it
365
- // (R49). Callers without one (probes, tests staging fixtures) keep the old
366
- // behaviour exactly.
367
- if (options.write) {
368
- await mkdir(decisionsDir, { recursive: true });
369
- if (options.journal) await journaledWriteFile(root, options.journal, fullPath, text);
370
- else await writeFile(fullPath, text, 'utf8');
360
+ // A dry run names the number without claiming it: the file must not exist
361
+ // afterwards, so no exclusive create happens here.
362
+ if (!options.write) {
363
+ const { number, id } = await nextDecisionNumber(absDir);
364
+ const file = `${id}-${slug}.md`;
365
+ return {
366
+ ok: true,
367
+ id,
368
+ number,
369
+ title,
370
+ slug,
371
+ file,
372
+ relPath: [relDir, file].join('/'),
373
+ path: join(absDir, file),
374
+ affects,
375
+ supersedes,
376
+ text: renderDecisionScaffold({ id, title, date: options.date, status: options.status, affects, supersedes }),
377
+ written: false,
378
+ };
371
379
  }
372
380
 
373
- return {
374
- ok: true,
375
- id,
376
- number,
377
- title,
378
- slug,
379
- file,
380
- relPath,
381
- path: fullPath,
382
- affects,
383
- supersedes,
384
- text,
385
- written: Boolean(options.write),
386
- };
381
+ await mkdir(absDir, { recursive: true });
382
+ for (let attempt = 0; attempt < 100; attempt += 1) {
383
+ const { number, id } = await nextDecisionNumber(absDir);
384
+ const file = `${id}-${slug}.md`;
385
+ const fullPath = join(absDir, file);
386
+ const text = renderDecisionScaffold({ id, title, date: options.date, status: options.status, affects, supersedes });
387
+ try {
388
+ await writeFile(fullPath, text, { flag: 'wx' });
389
+ } catch (error) {
390
+ if (error.code !== 'EEXIST') throw error;
391
+ continue;
392
+ }
393
+ // The exclusive create is the journal entry's `before: null`: the file did
394
+ // not exist a moment ago, by construction rather than by re-reading.
395
+ if (options.journal) recordCreatedFile(root, options.journal, fullPath, text);
396
+ return {
397
+ ok: true,
398
+ id,
399
+ number,
400
+ title,
401
+ slug,
402
+ file,
403
+ relPath: [relDir, file].join('/'),
404
+ path: fullPath,
405
+ affects,
406
+ supersedes,
407
+ text,
408
+ written: true,
409
+ };
410
+ }
411
+ return { ok: false, errors: ['could not claim a free ADR number after 100 attempts'] };
387
412
  }
388
413
 
389
414
  /** Extract partial supersession claims stated in prose (`Status:` and header paragraphs). */
@@ -415,8 +440,15 @@ function prosePartialSupersessions(body) {
415
440
  return claims;
416
441
  }
417
442
 
418
- /** Lint a single ADR document for structured supersession conformance (plan 0003 R34). */
419
- export function lintDecision(text, { file = 'ADR-000.md', allowHistorical = false } = {}) {
443
+ /**
444
+ * Lint a single ADR document for structured supersession conformance (plan 0003 R34).
445
+ *
446
+ * `historicalCutoff` exempts the first N records from the frontmatter requirement:
447
+ * they predate it, and demanding it retroactively would fail a history nobody can
448
+ * rewrite without rewriting history. Q55: the number lives in the repository's
449
+ * config (`decisions.historicalCutoff`, resolved by the caller), never in code.
450
+ */
451
+ export function lintDecision(text, { file = 'ADR-000.md', historicalCutoff = 0 } = {}) {
420
452
  const fileMatch = file !== 'ADR-000.md' ? /^(ADR-\d{3,})/i.exec(file) : null;
421
453
  const headMatch = /^#\s+(ADR-\d{3,})\b/m.exec(String(text ?? ''));
422
454
  const id = (fileMatch?.[1] ?? headMatch?.[1] ?? /^(ADR-\d{3,})/i.exec(file)?.[1] ?? '').toUpperCase() || null;
@@ -432,7 +464,7 @@ export function lintDecision(text, { file = 'ADR-000.md', allowHistorical = fals
432
464
  remedy: 'fix simt-supersedes frontmatter to use [{ adr: "ADR-NNN", ruling: N }]',
433
465
  }));
434
466
 
435
- if (allowHistorical && !fm.hasFrontmatter && num !== null && num > 0 && num <= 96) {
467
+ if (!fm.hasFrontmatter && num !== null && num > 0 && num <= historicalCutoff) {
436
468
  return { ok: findings.length === 0, findings };
437
469
  }
438
470
 
@@ -455,13 +487,36 @@ export function lintDecision(text, { file = 'ADR-000.md', allowHistorical = fals
455
487
  return { ok: findings.length === 0, findings };
456
488
  }
457
489
 
458
- /** Lint all ADR records in a decisions directory. */
459
- export async function lintDecisions(decisionsDir, { allowHistorical = false } = {}) {
490
+ /**
491
+ * Lint all ADR records in a decisions directory.
492
+ *
493
+ * Duplicate ids are a lint finding, not a silent keep-first: two files claiming
494
+ * one number means `byId` answers for only one of them, and the reader of the
495
+ * other gets a record the index cannot see. The kept record is the first in
496
+ * numeric filename order — the same one the loader keeps — so lint and load
497
+ * never name different winners.
498
+ */
499
+ export async function lintDecisions(decisionsDir, { historicalCutoff = 0 } = {}) {
460
500
  const names = (await readdir(decisionsDir).catch(() => [])).filter((n) => /^ADR-\d{3,}-.*\.md$/i.test(n)).sort();
461
501
  const findings = [];
502
+ const seen = new Map();
462
503
  for (const file of names) {
504
+ const id = /^(ADR-\d{3,})/i.exec(file)?.[1]?.toUpperCase() ?? null;
505
+ if (id !== null) {
506
+ if (seen.has(id)) {
507
+ findings.push({
508
+ file,
509
+ id,
510
+ status: 'duplicate-decision-id',
511
+ detail: `decision id ${id} is claimed by ${seen.get(id)} and ${file}; the index only sees the first`,
512
+ remedy: 'renumber the newer record to a free number, then rerun the check',
513
+ });
514
+ continue;
515
+ }
516
+ seen.set(id, file);
517
+ }
463
518
  const text = await readFile(join(decisionsDir, file), 'utf8');
464
- findings.push(...lintDecision(text, { file, allowHistorical }).findings);
519
+ findings.push(...lintDecision(text, { file, historicalCutoff }).findings);
465
520
  }
466
521
  return { ok: findings.length === 0, findings };
467
522
  }