simt-dev 0.3.1 → 0.5.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 (60) hide show
  1. package/CHANGELOG.md +157 -0
  2. package/README.md +271 -62
  3. package/package.json +6 -2
  4. package/rules/core.md +1 -1
  5. package/src/advice/advice.mjs +229 -0
  6. package/src/agenthooks/policy.mjs +10 -2
  7. package/src/agenthooks/prompt.mjs +81 -0
  8. package/src/agenthooks/render.mjs +138 -17
  9. package/src/argv.mjs +28 -1
  10. package/src/bench/grade.mjs +3 -0
  11. package/src/bench/run.mjs +88 -54
  12. package/src/bench/tasks.mjs +38 -0
  13. package/src/bench/variants.mjs +456 -0
  14. package/src/brief/assemble.mjs +368 -0
  15. package/src/brief/kind.mjs +134 -0
  16. package/src/brief/packet.mjs +200 -0
  17. package/src/commands/agenthook.mjs +84 -5
  18. package/src/commands/bench.mjs +72 -15
  19. package/src/commands/brief.mjs +78 -0
  20. package/src/commands/compare.mjs +10 -2
  21. package/src/commands/decide.mjs +116 -0
  22. package/src/commands/distill.mjs +36 -0
  23. package/src/commands/help.mjs +62 -3
  24. package/src/commands/index.mjs +29 -1
  25. package/src/commands/lessons.mjs +127 -0
  26. package/src/commands/local.mjs +97 -0
  27. package/src/commands/map.mjs +42 -7
  28. package/src/commands/repoindex.mjs +4 -1
  29. package/src/commands/review.mjs +65 -0
  30. package/src/commands/rules.mjs +17 -2
  31. package/src/commands/skills.mjs +5 -50
  32. package/src/commands/skillscompile.mjs +255 -0
  33. package/src/commands/sync.mjs +15 -0
  34. package/src/commands/tests.mjs +2 -49
  35. package/src/commands/why.mjs +23 -25
  36. package/src/compare/learn.mjs +680 -0
  37. package/src/decisions/index.mjs +116 -65
  38. package/src/distill/distill.mjs +246 -0
  39. package/src/distill/engine.mjs +187 -0
  40. package/src/federation/client.mjs +188 -0
  41. package/src/federation/config.mjs +134 -0
  42. package/src/federation/query.mjs +48 -0
  43. package/src/graph/compile.mjs +475 -0
  44. package/src/index/gate.mjs +1 -13
  45. package/src/index/govern.mjs +23 -11
  46. package/src/index/map.mjs +251 -17
  47. package/src/index/persist.mjs +74 -7
  48. package/src/index/store.mjs +222 -8
  49. package/src/kernel/variants.mjs +353 -0
  50. package/src/ledger/index.mjs +36 -1
  51. package/src/local/client.mjs +482 -0
  52. package/src/local/config.mjs +269 -0
  53. package/src/mcp/tools.mjs +138 -1
  54. package/src/memory/decide.mjs +462 -0
  55. package/src/memory/lessons.mjs +858 -0
  56. package/src/projection/lock.mjs +39 -7
  57. package/src/projection/rules.mjs +32 -8
  58. package/src/resolve/body.mjs +46 -0
  59. package/src/review/critic.mjs +509 -0
  60. package/src/toolwords.mjs +6 -0
package/src/bench/run.mjs CHANGED
@@ -6,6 +6,9 @@ import { dirname, join, resolve, sep } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
 
8
8
  import { loadRegistry } from '../graph/load.mjs';
9
+ import { extractOpenedSkills, matchSkillsForPaths } from '../graph/compile.mjs';
10
+ import { matchGlob } from '../index/govern.mjs';
11
+ import { formatLessonParagraph, listPromotedLessons } from '../memory/lessons.mjs';
9
12
  import { CONFIG_DIR, MANIFEST_FILE } from '../name.mjs';
10
13
  import { readManifest } from '../projection/manifest.mjs';
11
14
  import { plan } from '../projection/project.mjs';
@@ -14,7 +17,9 @@ import { fingerprint } from '../resolve/fingerprint.mjs';
14
17
  import { rankSkills, tokenize } from '../resolve/resolve.mjs';
15
18
  import { gradeWorkspace } from './grade.mjs';
16
19
  import { resolveVaultDir } from './mine.mjs';
20
+ import { loadTasks } from './tasks.mjs';
17
21
  import { ablateSectionInWorkspace, parseAblationArmName } from './ablate.mjs';
22
+ import { applyVariantInWorkspace, parseVariantArmName } from './variants.mjs';
18
23
 
19
24
  const STATE_DIR = `.${CONFIG_DIR}`;
20
25
 
@@ -288,6 +293,15 @@ export async function parseArmSpec(rawSpec, cwd = process.cwd()) {
288
293
  env: {},
289
294
  };
290
295
  }
296
+ const variantSpec = parseVariantArmName(spec);
297
+ if (variantSpec) {
298
+ return {
299
+ ...variantSpec,
300
+ write: {},
301
+ remove: [],
302
+ env: {},
303
+ };
304
+ }
291
305
  if (!BUILTIN_ARMS.has(spec)) {
292
306
  throw new Error(
293
307
  `unknown arm "${spec}"; built-in arms are: ${[...BUILTIN_ARMS].join(', ')} (or pass --arm name=path/to/arm.json)`,
@@ -400,7 +414,7 @@ async function stripBareArm(workDir) {
400
414
  }
401
415
  }
402
416
 
403
- for (const rel of ['.githooks', '.mcp.json', '.cursor/mcp.json', '.vscode/mcp.json']) {
417
+ for (const rel of ['.githooks', '.mcp.json', '.cursor/mcp.json', '.vscode/mcp.json', 'docs/lessons']) {
404
418
  await rm(join(workDir, rel), { recursive: true, force: true }).catch(() => {});
405
419
  }
406
420
 
@@ -457,20 +471,32 @@ async function applyProjectionMode(workDir, mode) {
457
471
 
458
472
  async function writeTaskBrief(workDir, task) {
459
473
  const prompt = String(task?.prompt ?? '');
460
- const [registry, fp] = await Promise.all([
474
+ const taskTokens = tokenize(prompt);
475
+ const taskTokenSet = new Set(taskTokens);
476
+ const [registry, fp, lessons] = await Promise.all([
461
477
  loadRegistry().catch(() => ({ skills: [] })),
462
478
  fingerprint(workDir).catch(() => ({ stacks: [], facts: new Set() })),
479
+ listPromotedLessons(workDir).catch(() => []),
463
480
  ]);
464
481
  const skillsList = Array.isArray(registry.skills)
465
482
  ? registry.skills
466
483
  : Object.values(registry.skills ?? {});
467
484
  const { selected = [] } = rankSkills(skillsList, {
468
- taskTokens: tokenize(prompt),
485
+ taskTokens,
469
486
  paths: [],
470
487
  repoStack: fp.stack ?? [],
471
488
  });
472
489
  const ranked = selected.slice(0, 5);
473
490
 
491
+ const taskPaths = Array.isArray(task?.paths) ? task.paths : [];
492
+ const matchingLessons = lessons.filter((lesson) => {
493
+ const lessonGlobs = Array.isArray(lesson.paths) ? lesson.paths : [];
494
+ if (taskPaths.some((p) => lessonGlobs.some((glob) => matchGlob(glob, p)))) return true;
495
+ if (lessonGlobs.some((glob) => prompt.includes(glob.replace(/\/\*\*$/, '')))) return true;
496
+ const kw = Array.isArray(lesson.keywords) ? lesson.keywords : [];
497
+ return kw.some((k) => taskTokenSet.has(String(k).toLowerCase()));
498
+ });
499
+
474
500
  const lines = [
475
501
  `# Task Brief: ${task?.id ?? 'task'}`,
476
502
  '',
@@ -480,6 +506,13 @@ async function writeTaskBrief(workDir, task) {
480
506
  ...(ranked.length > 0
481
507
  ? ranked.map((r) => `- ${r.skill.name} (score ${r.score}): ${r.skill.description ?? ''}`)
482
508
  : ['- (none matched)']),
509
+ ...(matchingLessons.length > 0
510
+ ? [
511
+ '',
512
+ '## Lessons',
513
+ ...matchingLessons.map((l) => `- ${formatLessonParagraph(l)}`),
514
+ ]
515
+ : []),
483
516
  '',
484
517
  ];
485
518
  const briefPath = join(workDir, STATE_DIR, 'brief.md');
@@ -497,9 +530,12 @@ async function writeTaskBrief(workDir, task) {
497
530
  */
498
531
  export async function materialiseArm(workDir, armSpec, task = {}) {
499
532
  const ablate = armSpec.ablate ?? parseAblationArmName(armSpec.name)?.ablate ?? null;
533
+ const variant = armSpec.variant ?? parseVariantArmName(armSpec.name)?.variant ?? null;
500
534
  const base = armSpec.base ?? (BUILTIN_ARMS.has(armSpec.name) ? armSpec.name : null);
501
535
  if (ablate) {
502
536
  await ablateSectionInWorkspace(workDir, ablate);
537
+ } else if (variant) {
538
+ await applyVariantInWorkspace(workDir, variant);
503
539
  } else if (base === 'bare') {
504
540
  await stripBareArm(workDir);
505
541
  } else if (base === 'current') {
@@ -559,6 +595,31 @@ export async function prepareIsolatedWorkspace(root, baseSha, armSpec, task = {}
559
595
  throw new Error(`tar unpack failed for ${baseSha}: ${tarRes.stderr.toString('utf8').trim()}`);
560
596
  }
561
597
 
598
+ const rootLessonsDir = join(root, 'docs', 'lessons');
599
+ const rootLessonFiles = (await readdir(rootLessonsDir).catch(() => [])).filter((f) => f.endsWith('.md'));
600
+ if (rootLessonFiles.length > 0) {
601
+ const destLessonsDir = join(workDir, 'docs', 'lessons');
602
+ await mkdir(destLessonsDir, { recursive: true });
603
+ for (const file of rootLessonFiles) {
604
+ const target = join(destLessonsDir, file);
605
+ if (!existsSync(target)) {
606
+ const text = await readFile(join(rootLessonsDir, file), 'utf8');
607
+ await writeFile(target, text, 'utf8');
608
+ }
609
+ }
610
+ }
611
+
612
+ const rootProfilesDir = join(root, STATE_DIR, 'profiles');
613
+ const rootProfileFiles = (await readdir(rootProfilesDir).catch(() => [])).filter((f) => f.endsWith('.md'));
614
+ if (rootProfileFiles.length > 0) {
615
+ const destProfilesDir = join(workDir, STATE_DIR, 'profiles');
616
+ await mkdir(destProfilesDir, { recursive: true });
617
+ for (const file of rootProfileFiles) {
618
+ const text = await readFile(join(rootProfilesDir, file), 'utf8');
619
+ await writeFile(join(destProfilesDir, file), text, 'utf8');
620
+ }
621
+ }
622
+
562
623
  const { env } = await materialiseArm(workDir, armSpec, task);
563
624
 
564
625
  await runCmd('git', ['init', '-q'], { cwd: workDir });
@@ -598,66 +659,28 @@ function buildAdapterArgs(adapter, { prompt, turns, model, analyticsLog }) {
598
659
  }
599
660
 
600
661
  async function executeAdapter(adapter, workDir, task, { vaultRoot, model, maxTurns, env, timeoutMs, slotDir }) {
601
- if (adapter.id === 'noop') {
602
- return parseAdapterUsage(adapter, '');
603
- }
604
-
662
+ if (adapter.id === 'noop') return { ...parseAdapterUsage(adapter, ''), transcript: '' };
605
663
  if (adapter.id === 'gold') {
606
- const goldPatchPath = join(vaultRoot, task.id, 'gold.patch');
607
- const goldPatch = await readFile(goldPatchPath);
664
+ const goldPatch = await readFile(join(vaultRoot, task.id, 'gold.patch'));
608
665
  if (goldPatch.length > 0) {
609
- const applyRes = await runCmd('git', ['apply', '-'], {
610
- cwd: workDir,
611
- input: goldPatch,
612
- timeoutMs,
613
- });
666
+ const applyRes = await runCmd('git', ['apply', '-'], { cwd: workDir, input: goldPatch, timeoutMs });
614
667
  if (applyRes.code !== 0) {
615
- await runCmd('git', ['apply', '--3way', '-'], {
616
- cwd: workDir,
617
- input: goldPatch,
618
- timeoutMs,
619
- });
668
+ await runCmd('git', ['apply', '--3way', '-'], { cwd: workDir, input: goldPatch, timeoutMs });
620
669
  }
621
670
  }
622
- return parseAdapterUsage(adapter, '');
671
+ return { ...parseAdapterUsage(adapter, ''), transcript: '' };
623
672
  }
624
673
 
625
674
  const analyticsLog = join(slotDir, 'analytics.jsonl');
626
- const args = buildAdapterArgs(adapter, {
627
- prompt: String(task.prompt ?? ''),
628
- turns: maxTurns,
629
- model,
630
- analyticsLog,
631
- });
632
-
633
- const outcome = await runCmd(adapter.bin, args, {
634
- cwd: workDir,
635
- env,
636
- timeoutMs,
637
- });
638
- const analyticsText =
639
- adapter.format === 'analytics-log'
640
- ? await readFile(analyticsLog, 'utf8').catch(() => '')
641
- : '';
642
-
643
- return parseAdapterUsage(adapter, outcome.stdout.toString('utf8'), analyticsText);
675
+ const args = buildAdapterArgs(adapter, { prompt: String(task.prompt ?? ''), turns: maxTurns, model, analyticsLog });
676
+ const outcome = await runCmd(adapter.bin, args, { cwd: workDir, env, timeoutMs });
677
+ const stdoutText = outcome.stdout.toString('utf8');
678
+ const analyticsText = adapter.format === 'analytics-log' ? await readFile(analyticsLog, 'utf8').catch(() => '') : '';
679
+ return { ...parseAdapterUsage(adapter, stdoutText, analyticsText), transcript: `${stdoutText}\n${analyticsText}` };
644
680
  }
645
681
 
646
- async function loadTasks(root, filterIds = []) {
647
- const tasksDir = join(root, STATE_DIR, 'bench', 'tasks');
648
- const entries = (await readdir(tasksDir).catch(() => []))
649
- .filter((f) => f.endsWith('.json'))
650
- .sort();
651
-
652
- const filterSet = new Set(filterIds);
653
- const tasks = [];
654
- for (const entry of entries) {
655
- const data = JSON.parse(await readFile(join(tasksDir, entry), 'utf8'));
656
- if (filterSet.size > 0 && !filterSet.has(data.id)) continue;
657
- tasks.push(data);
658
- }
659
- return tasks.sort((a, b) => a.id.localeCompare(b.id));
660
- }
682
+ // loadTasks lives in tasks.mjs (the advice engine became a second reader, so the loader
683
+ // was extracted rather than copied — ADR-002).
661
684
 
662
685
  function runIdFor(harness, armName, rep) {
663
686
  return `${harness}-${armName}-r${rep}`;
@@ -776,6 +799,9 @@ export async function runBench(
776
799
  }
777
800
  }
778
801
 
802
+ const regRes = await loadRegistry().catch(() => ({ registry: { skills: [] } }));
803
+ const registrySkills = regRes?.registry?.skills ?? [];
804
+ const knownSkillNames = new Set(registrySkills.map((s) => s.name));
779
805
  const parentDir = await mkdtemp(join(tmpdir(), 'bench-batch-'));
780
806
  try {
781
807
  const records = await mapPool(plannedRuns, concurrency, async (item) => {
@@ -787,7 +813,7 @@ export async function runBench(
787
813
  { parentDir },
788
814
  );
789
815
  try {
790
- const { usage, costUsd } = await executeAdapter(adapter, workDir, item.task, {
816
+ const { usage, costUsd, transcript } = await executeAdapter(adapter, workDir, item.task, {
791
817
  vaultRoot,
792
818
  model: resolvedModel,
793
819
  maxTurns,
@@ -801,6 +827,13 @@ export async function runBench(
801
827
  initialHead,
802
828
  timeoutMs,
803
829
  });
830
+ const expectedSkills = matchSkillsForPaths(registrySkills, grade.goldFiles ?? []);
831
+ const openedSkills =
832
+ adapter.id === 'gold'
833
+ ? expectedSkills
834
+ : adapter.id === 'noop'
835
+ ? []
836
+ : extractOpenedSkills(transcript, knownSkillNames);
804
837
 
805
838
  const record = {
806
839
  schemaVersion: 1,
@@ -814,6 +847,7 @@ export async function runBench(
814
847
  usage,
815
848
  costUsd,
816
849
  diff: grade.diff,
850
+ skills: { expected: expectedSkills, opened: openedSkills },
817
851
  probe: null,
818
852
  };
819
853
 
@@ -0,0 +1,38 @@
1
+ // Mined task descriptors, read back from the tool state's `bench/tasks/` directory (plan 0003 R01).
2
+ //
3
+ // Extracted from bench/run.mjs on its second real reader (ADR-002): the R40 advice engine
4
+ // needs the two facts a run record does not carry — the prompt, which classifies the task,
5
+ // and the gold stat, which says how many files the change touched. A second private loader
6
+ // beside the first would drift the first time one learned a filter the other lacked.
7
+ //
8
+ // Deterministic by construction: the directory is read sorted, the filter is exact, and no
9
+ // part of this touches a model, a network or a clock.
10
+ import { readdir, readFile } from 'node:fs/promises';
11
+ import { join, resolve } from 'node:path';
12
+ import { CONFIG_DIR } from '../name.mjs';
13
+
14
+ const STATE_DIR = `.${CONFIG_DIR}`;
15
+
16
+ /**
17
+ * Load mined benchmark task descriptors.
18
+ *
19
+ * @param {string} root the repository
20
+ * @param {string[]} [filterIds] exact task ids to keep; empty means load them all
21
+ * @returns descriptor objects sorted by id (id, base, sourceCommit, prompt, testCmd,
22
+ * f2p, p2p, gold {files, linesAdded, linesRemoved}, vault)
23
+ */
24
+ export async function loadTasks(root, filterIds = []) {
25
+ const tasksDir = join(resolve(root), STATE_DIR, 'bench', 'tasks');
26
+ const entries = (await readdir(tasksDir).catch(() => []))
27
+ .filter((f) => f.endsWith('.json'))
28
+ .sort();
29
+
30
+ const filterSet = new Set(filterIds);
31
+ const tasks = [];
32
+ for (const entry of entries) {
33
+ const data = JSON.parse(await readFile(join(tasksDir, entry), 'utf8'));
34
+ if (filterSet.size > 0 && !filterSet.has(data.id)) continue;
35
+ tasks.push(data);
36
+ }
37
+ return tasks.sort((a, b) => a.id.localeCompare(b.id));
38
+ }