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
@@ -8,9 +8,10 @@
8
8
  // rewritten.
9
9
 
10
10
  import { createHash } from 'node:crypto';
11
- import { readFile, readdir } from 'node:fs/promises';
11
+ import { readFile, readdir, writeFile } from 'node:fs/promises';
12
12
  import { join } from 'node:path';
13
13
 
14
+ import { readLockedVariants } from '../kernel/variants.mjs';
14
15
  import { fingerprint } from '../resolve/fingerprint.mjs';
15
16
  import { desiredRegion, provenanceDigest, LOCAL_PROFILE_DIR } from './rules.mjs';
16
17
  import { readManifest } from './manifest.mjs';
@@ -29,11 +30,25 @@ const MANIFEST_KEYS = ['mode', 'budget', 'shared', 'surfaces', 'gates'];
29
30
  * body that was written and not a body for a different set of surfaces
30
31
  */
31
32
  export async function computeLock(root, flags = {}) {
32
- const desired = await desiredRegion(root, flags);
33
- return {
34
- inputs: await inputDigest(root),
35
- outputs: createHash('sha256').update(desired.body).digest('hex'),
36
- };
33
+ const variants =
34
+ flags?.variants !== undefined ? flags.variants : await readLockedVariants(root);
35
+ const desired = await desiredRegion(root, { ...flags, variants });
36
+ const inputs = await inputDigest(root, variants);
37
+ const outputs = createHash('sha256').update(desired.body).digest('hex');
38
+ const hasVariants =
39
+ variants && typeof variants === 'object' && Object.keys(variants).length > 0;
40
+ return hasVariants ? { inputs, outputs, variants } : { inputs, outputs };
41
+ }
42
+
43
+ /**
44
+ * Record locked variant winners in `simt-dev.lock.json` alongside updated digests.
45
+ */
46
+ export async function writeLockVariants(root, nextVariants, flags = {}) {
47
+ const existing = await readLockedVariants(root);
48
+ const merged = { ...existing, ...(nextVariants ?? {}) };
49
+ const lock = await computeLock(root, { ...flags, variants: merged });
50
+ await writeFile(join(root, LOCK_FILE), `${JSON.stringify(lock, null, 2)}\n`, 'utf8');
51
+ return lock;
37
52
  }
38
53
 
39
54
  /** Read a lock, or null when the file is not there. A file that does not parse is an
@@ -52,13 +67,19 @@ export async function readLock(root) {
52
67
  if (typeof lock?.inputs !== 'string' || typeof lock?.outputs !== 'string') {
53
68
  return { lock: null, error: 'must be an object with inputs and outputs' };
54
69
  }
70
+ if (
71
+ lock.variants !== undefined &&
72
+ (typeof lock.variants !== 'object' || lock.variants === null || Array.isArray(lock.variants))
73
+ ) {
74
+ return { lock: null, error: 'variants must be an object' };
75
+ }
55
76
  return { lock, error: null };
56
77
  } catch (error) {
57
78
  return { lock: null, error: error.message };
58
79
  }
59
80
  }
60
81
 
61
- async function inputDigest(root) {
82
+ async function inputDigest(root, variants = {}) {
62
83
  const hash = createHash('sha256');
63
84
  hash.update(`provenance:${await provenanceDigest()}\n`);
64
85
  const { stack, facts } = await fingerprint(root);
@@ -89,5 +110,16 @@ async function inputDigest(root) {
89
110
  } catch (error) {
90
111
  if (error.code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error;
91
112
  }
113
+ const variantKeys =
114
+ variants && typeof variants === 'object' ? Object.keys(variants).sort() : [];
115
+ if (variantKeys.length > 0) {
116
+ const winners = {};
117
+ for (const key of variantKeys) {
118
+ const entry = variants[key];
119
+ winners[key] =
120
+ typeof entry === 'string' ? entry : (entry?.winner ?? entry?.variant ?? null);
121
+ }
122
+ hash.update(`variants:${JSON.stringify(winners)}\n`);
123
+ }
92
124
  return hash.digest('hex');
93
125
  }
@@ -35,6 +35,7 @@ import { commandCard, CARD_LIMIT_BYTES } from '../kernel/card.mjs';
35
35
  import { packToBudget } from '../kernel/budget.mjs';
36
36
  import { filterInferable, inferableDefect, inferableProofs } from '../kernel/inferability.mjs';
37
37
  import { checkDefect, foldChecks } from '../kernel/checks.mjs';
38
+ import { foldVariants, readLockedVariants, variantsDefect } from '../kernel/variants.mjs';
38
39
  import { BASELINES } from './baselines.mjs';
39
40
  import { CLI } from '../name.mjs';
40
41
  import { installedSkills } from './disclosure.mjs';
@@ -403,12 +404,14 @@ async function ruleBodies(root, manifest, flags) {
403
404
  for (const p of renderProblems) problems.push(`${rule.file}: ${p}`);
404
405
  if (renderProblems.length > 0) continue;
405
406
 
407
+ const variantFolded = foldVariants(body, rule.id ?? '', flags?.variants ?? null);
408
+
406
409
  // One canonical body, one target per selected surface. The body is identical
407
410
  // everywhere; only the frontmatter the editor reads before any rule text
408
411
  // differs. Without this loop a repo in a Claude Code session receives
409
412
  // AGENTS.md and none of the always-on process rules, because those lived only
410
413
  // in a directory only Cursor reads.
411
- bodies.push({ rule, body });
414
+ bodies.push({ rule, body: variantFolded.body });
412
415
  }
413
416
 
414
417
  return { resolved, bodies, problems, notes, skipped };
@@ -449,12 +452,14 @@ function toolRule(rule) {
449
452
 
450
453
  export async function desiredRegion(root, flags) {
451
454
  const { stack, facts, manifest } = await fingerprint(root);
455
+ const lockedVariants =
456
+ flags?.variants !== undefined ? flags.variants : await readLockedVariants(root);
452
457
  const all = await loadRules(root);
453
458
  const { selected, fallback, notes } = select(all, stack, facts);
454
459
  const { resolved, bodies, problems, notes: ruleNotes } = await ruleBodies(
455
460
  root,
456
461
  manifest,
457
- flags ?? {},
462
+ { ...(flags ?? {}), variants: lockedVariants },
458
463
  );
459
464
  notes.push(...ruleNotes);
460
465
 
@@ -476,7 +481,8 @@ export async function desiredRegion(root, flags) {
476
481
  // selected surface cannot narrow, the file would be the only home for a rule that
477
482
  // reader cannot reach — so it stays, and says why.
478
483
  if (narrowing) {
479
- scoped.push(r);
484
+ const vf = foldVariants(r.body, r.id ?? '', lockedVariants);
485
+ scoped.push(vf.changed ? { ...r, body: vf.body } : r);
480
486
  continue;
481
487
  }
482
488
  inRegion.push(r);
@@ -521,18 +527,27 @@ export async function desiredRegion(root, flags) {
521
527
  // which lines the reader already knows.
522
528
  const inferable = [];
523
529
  const shipped = [];
530
+ const variantsSelected = [];
524
531
  let enforced = 0;
525
532
  for (const r of packed) {
526
- const filtered = filterInferable(r.body, proofs);
533
+ const variantFolded = foldVariants(r.body, r.id ?? '', lockedVariants);
534
+ const filtered = filterInferable(variantFolded.body, proofs);
527
535
  const folded = foldChecks(filtered.body);
528
536
  enforced += folded.enforced.length;
529
- if (!filtered.changed && !folded.changed) {
537
+ variantsSelected.push(...variantFolded.selected);
538
+ if (!variantFolded.changed && !filtered.changed && !folded.changed) {
530
539
  shipped.push(r);
531
540
  continue;
532
541
  }
533
542
  for (const x of filtered.removed) inferable.push({ rule: r.id, text: x.text, expr: x.expr });
534
543
  shipped.push({ ...r, body: folded.body });
535
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
+ }
536
551
  if (enforced > 0) {
537
552
  notes.push(
538
553
  `enforced: ${enforced} line(s) left the region because a checker runs them`,
@@ -1046,7 +1061,11 @@ export async function loadRules(root = null) {
1046
1061
  // it travels the field that already reaches `check` and `rules list`. The
1047
1062
  // frontmatter defect wins, because a rule that cannot say what it applies to cannot
1048
1063
  // be reasoned about at all.
1049
- error: describeDefect(meta) ?? inferableDefect(body) ?? checkDefect(body),
1064
+ error:
1065
+ describeDefect(meta) ??
1066
+ inferableDefect(body) ??
1067
+ checkDefect(body) ??
1068
+ variantsDefect(body, meta['simt-id'] ?? ''),
1050
1069
  });
1051
1070
  }
1052
1071
  return out;
@@ -1081,6 +1100,8 @@ export async function packageRegions(root, flags, rootProfiles = null) {
1081
1100
  if (packages.length === 0) return { regions: [], notes };
1082
1101
 
1083
1102
  const rules = await loadRules(root);
1103
+ const lockedVariants =
1104
+ flags?.variants !== undefined ? flags.variants : await readLockedVariants(root);
1084
1105
  const sha = await provenanceDigest();
1085
1106
  // The ROOT's proofs, not the package's. The rule set is the root's, so which lines it
1086
1107
  // already proves is a fact about the repository rather than about the directory a file
@@ -1094,9 +1115,12 @@ export async function packageRegions(root, flags, rootProfiles = null) {
1094
1115
  const delta = selected.filter((r) => !shared.includes(r.id));
1095
1116
  if (delta.length === 0) continue;
1096
1117
  const shipped = delta.map((r) => {
1097
- const filtered = filterInferable(r.body, proofs);
1118
+ const variantFolded = foldVariants(r.body, r.id ?? '', lockedVariants);
1119
+ const filtered = filterInferable(variantFolded.body, proofs);
1098
1120
  const folded = foldChecks(filtered.body);
1099
- return filtered.changed || folded.changed ? { ...r, body: folded.body } : r;
1121
+ return variantFolded.changed || filtered.changed || folded.changed
1122
+ ? { ...r, body: folded.body }
1123
+ : r;
1100
1124
  });
1101
1125
  regions.push({
1102
1126
  path: pkg.path,
@@ -0,0 +1,46 @@
1
+ // Where a skill's body lives, and how a path the registry names becomes a file.
2
+ //
3
+ // Extracted from src/commands/skills.mjs when the distiller (plan 0003 R38) became
4
+ // the second real caller: two commands that resolve the same body differently would
5
+ // print two answers to one question, and ADR-002 refuses the third copy rather than
6
+ // the second use.
7
+ //
8
+ // Sources are declared as `repo/path`, so finding one needs the workspace that
9
+ // CONTAINS the repos. That used to be `resolve(here, '..', '..', '..')`, which is
10
+ // right in the checkout and wrong in `node_modules` - so every source resolved to a
11
+ // path that could not exist and `skills` failed with 38 ENOENT lines. Now it comes
12
+ // from configuration, and `null` is a legitimate answer meaning "this installation
13
+ // has no workspace" (ADR-014).
14
+ //
15
+ // A second location exists and is not a fallback: the repo being read is itself a
16
+ // location for a declared source, matched on bytes rather than on hope (ADR-015).
17
+ // The canonical source is still tried first, so a workspace body always wins.
18
+
19
+ import { join } from 'node:path';
20
+ import { existsSync } from 'node:fs';
21
+
22
+ import { locateSource, sourceHeldLocally } from './source.mjs';
23
+
24
+ export async function sourcePath(skill, workspaceRoot, root) {
25
+ const canonical = skill.sources?.find((s) => s.role === 'canonical') ?? skill.sources?.[0];
26
+
27
+ if (canonical) {
28
+ const found = await locateSource(canonical, { workspaceRoot, root });
29
+ if (found.path) return found.path;
30
+ }
31
+
32
+ const held = await sourceHeldLocally(skill, root, { workspaceRoot });
33
+ if (held && !held.ambiguous) return held.path;
34
+
35
+ // A skill with no canonical source, or one whose bytes match nothing, has no
36
+ // body. Say so rather than printing an empty section.
37
+ return canonical && workspaceRoot ? join(workspaceRoot, canonical.repo, canonical.path) : null;
38
+ }
39
+
40
+ /**
41
+ * The file a source path points at. A path may be a directory that carries its
42
+ * body as SKILL.md, the Agent Skills layout; SKILL.md wins when both exist.
43
+ */
44
+ export function bodyFile(full) {
45
+ return existsSync(join(full, 'SKILL.md')) ? join(full, 'SKILL.md') : full;
46
+ }