simt-dev 0.7.0 → 0.8.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 (235) hide show
  1. package/CHANGELOG.md +191 -870
  2. package/README.md +188 -213
  3. package/bin/simt.mjs +19 -55
  4. package/docs/decisions/ADR-014-workspace-is-configuration.md +2 -2
  5. package/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +1 -1
  6. package/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +1 -1
  7. package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +1 -1
  8. package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +1 -1
  9. package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
  10. package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +1 -1
  11. package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +1 -1
  12. package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +1 -1
  13. package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
  14. package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
  15. package/docs/decisions/ADR-118-the-bench-corpus-is-keyed-on-history-is-append-only-and-is-portable-as-a-sealed-document.md +91 -0
  16. package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
  17. package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
  18. package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
  19. package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
  20. package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
  21. package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
  22. package/docs/decisions/ADR-126-the-mcp-surface-stays-a-reader-so-there-is-no-write-path-to-build.md +68 -0
  23. package/docs/decisions/ADR-127-the-mcp-tool-list-is-written-by-hand-and-roots-is-not-asked-for.md +65 -0
  24. package/docs/decisions/ADR-128-a-research-plan-is-a-plan-and-the-tracker-reads-it.md +42 -0
  25. package/ecosystems/bun.json +6 -0
  26. package/ecosystems/cargo.json +25 -0
  27. package/ecosystems/composer.json +6 -0
  28. package/ecosystems/dart.json +24 -0
  29. package/ecosystems/deno.json +21 -0
  30. package/ecosystems/devcontainers.json +6 -0
  31. package/ecosystems/docker-compose.json +6 -0
  32. package/ecosystems/docker.json +6 -0
  33. package/ecosystems/dotnet.json +25 -0
  34. package/ecosystems/elixir.json +23 -0
  35. package/ecosystems/gitsubmodule.json +6 -0
  36. package/ecosystems/go.json +25 -0
  37. package/ecosystems/gradle.json +13 -0
  38. package/ecosystems/maven.json +23 -0
  39. package/ecosystems/node.json +25 -0
  40. package/ecosystems/python.json +20 -0
  41. package/ecosystems/ruby.json +23 -0
  42. package/ecosystems/swift.json +25 -0
  43. package/ecosystems/terraform.json +6 -0
  44. package/ecosystems/uv.json +6 -0
  45. package/harnesses/antigravity.json +162 -0
  46. package/harnesses/claude.json +206 -0
  47. package/harnesses/codex.json +136 -0
  48. package/harnesses/copilot.json +88 -0
  49. package/harnesses/cursor.json +140 -0
  50. package/harnesses/deepseek.json +99 -0
  51. package/harnesses/gemini.json +122 -0
  52. package/harnesses/opencode.json +173 -0
  53. package/package.json +12 -2
  54. package/rules/core.md +7 -0
  55. package/rules/mdc/intake-questions.mdc +8 -0
  56. package/rules/mdc/slice-commit.mdc +57 -4
  57. package/src/advice/advice.mjs +3 -2
  58. package/src/agenthooks/prompt.mjs +5 -2
  59. package/src/agenthooks/render.mjs +86 -180
  60. package/src/agenthooks/shim.mjs +1 -1
  61. package/src/agents/render.mjs +15 -18
  62. package/src/api.mjs +31 -0
  63. package/src/argv.mjs +48 -3
  64. package/src/bench/ablate.mjs +3 -4
  65. package/src/bench/adapters.json +163 -105
  66. package/src/bench/adapters.mjs +466 -0
  67. package/src/bench/arms.mjs +333 -0
  68. package/src/bench/grade.mjs +33 -73
  69. package/src/bench/graders.mjs +42 -0
  70. package/src/bench/mine.mjs +121 -475
  71. package/src/bench/probe-ledger.mjs +149 -0
  72. package/src/bench/probes.catalog.mjs +918 -0
  73. package/src/bench/probes.mjs +79 -1064
  74. package/src/bench/render.mjs +111 -0
  75. package/src/bench/report.mjs +120 -962
  76. package/src/bench/run.mjs +185 -802
  77. package/src/bench/variants.mjs +4 -5
  78. package/src/bench/workspace.mjs +160 -0
  79. package/src/brief/assemble.mjs +48 -15
  80. package/src/ci/dependabot.mjs +25 -55
  81. package/src/ci/workflow.mjs +30 -7
  82. package/src/cli.mjs +45 -0
  83. package/src/commands/agent.mjs +4 -13
  84. package/src/commands/agenthook.mjs +70 -40
  85. package/src/commands/bench.mjs +191 -18
  86. package/src/commands/brief.mjs +5 -4
  87. package/src/commands/ci.mjs +13 -14
  88. package/src/commands/compare.mjs +42 -42
  89. package/src/commands/completions.mjs +2 -6
  90. package/src/commands/decide.mjs +4 -4
  91. package/src/commands/distill.mjs +4 -3
  92. package/src/commands/docs.mjs +88 -0
  93. package/src/commands/doctor.mjs +11 -7
  94. package/src/commands/evidence.mjs +6 -9
  95. package/src/commands/explain.mjs +0 -1
  96. package/src/commands/gate.mjs +26 -25
  97. package/src/commands/graph.mjs +2 -3
  98. package/src/commands/harness.mjs +6 -5
  99. package/src/commands/help.mjs +168 -19
  100. package/src/commands/hooks.mjs +119 -49
  101. package/src/commands/ignore.mjs +13 -8
  102. package/src/commands/impact.mjs +8 -15
  103. package/src/commands/index.mjs +318 -193
  104. package/src/commands/init-scan.mjs +90 -0
  105. package/src/commands/init.mjs +124 -105
  106. package/src/commands/lease.mjs +73 -25
  107. package/src/commands/lessons.mjs +10 -9
  108. package/src/commands/local.mjs +3 -3
  109. package/src/commands/map.mjs +24 -14
  110. package/src/commands/mcp.mjs +2 -18
  111. package/src/commands/pack.mjs +9 -6
  112. package/src/commands/plan.mjs +6 -5
  113. package/src/commands/plugin.mjs +2 -2
  114. package/src/commands/prbody.mjs +3 -3
  115. package/src/commands/repoindex.mjs +12 -11
  116. package/src/commands/review.mjs +2 -2
  117. package/src/commands/rules.mjs +37 -16
  118. package/src/commands/shared.mjs +14 -19
  119. package/src/commands/skills.mjs +33 -32
  120. package/src/commands/skillscompile.mjs +7 -8
  121. package/src/commands/skillsinstall.mjs +7 -6
  122. package/src/commands/slice.mjs +220 -74
  123. package/src/commands/spec.mjs +167 -0
  124. package/src/commands/status.mjs +27 -18
  125. package/src/commands/sync.mjs +27 -25
  126. package/src/commands/tests.mjs +18 -21
  127. package/src/commands/tokens.mjs +21 -26
  128. package/src/commands/undo.mjs +5 -15
  129. package/src/commands/upgrade.mjs +15 -7
  130. package/src/commands/why.mjs +15 -21
  131. package/src/compare/hooksstate.mjs +2 -6
  132. package/src/compare/learn.mjs +3 -1
  133. package/src/compare/row.mjs +0 -2
  134. package/src/context.mjs +64 -0
  135. package/src/decisions/index-doc.mjs +46 -0
  136. package/src/decisions/index.mjs +3 -2
  137. package/src/diff.mjs +1 -1
  138. package/src/distill/distill.mjs +14 -1
  139. package/src/distill/engine.mjs +5 -1
  140. package/src/docs/commands.mjs +65 -0
  141. package/src/ecosystem/records.mjs +219 -0
  142. package/src/explain.mjs +31 -0
  143. package/src/gates/index.mjs +385 -0
  144. package/src/graph/compile.mjs +8 -4
  145. package/src/graph/load.mjs +6 -2
  146. package/src/graph/spec.mjs +10 -8
  147. package/src/harness/records.mjs +143 -0
  148. package/src/hooks/capability.mjs +7 -5
  149. package/src/hooks/dispatcher.mjs +209 -60
  150. package/src/hooks/gates.mjs +10 -43
  151. package/src/hooks/resolve.mjs +4 -14
  152. package/src/hooks/shim.mjs +5 -2
  153. package/src/hooks/where.mjs +2 -7
  154. package/src/identity.mjs +31 -12
  155. package/src/index/coverage.mjs +58 -0
  156. package/src/index/extract.mjs +6 -16
  157. package/src/index/gate.mjs +1 -1
  158. package/src/index/govern.mjs +26 -9
  159. package/src/index/impact.mjs +4 -8
  160. package/src/index/map.mjs +14 -15
  161. package/src/index/open.mjs +173 -0
  162. package/src/index/persist.mjs +224 -57
  163. package/src/index/store.mjs +82 -39
  164. package/src/index/tests.mjs +2 -6
  165. package/src/journal.mjs +94 -20
  166. package/src/kernel/card.mjs +37 -63
  167. package/src/kernel/checks.mjs +4 -10
  168. package/src/lease.mjs +102 -7
  169. package/src/ledger/index.mjs +150 -14
  170. package/src/local/client.mjs +4 -6
  171. package/src/locate.mjs +33 -9
  172. package/src/manifest/schema.mjs +213 -0
  173. package/src/mcp/paginate.mjs +33 -0
  174. package/src/mcp/resources.mjs +7 -10
  175. package/src/mcp/schemas.mjs +512 -0
  176. package/src/mcp/server.mjs +119 -25
  177. package/src/mcp/tools-facts.mjs +326 -0
  178. package/src/mcp/tools-read.mjs +221 -0
  179. package/src/mcp/tools.mjs +4 -967
  180. package/src/measure/corpus.mjs +150 -0
  181. package/src/measure/metrics.mjs +236 -0
  182. package/src/measure/records.mjs +211 -0
  183. package/src/measure/session.mjs +3 -2
  184. package/src/measure/stats.mjs +538 -0
  185. package/src/measure/task-schema.mjs +125 -0
  186. package/src/measure/tasks.mjs +69 -0
  187. package/src/memory/decide.mjs +6 -15
  188. package/src/memory/lesson-text.mjs +61 -0
  189. package/src/memory/lessons.mjs +29 -82
  190. package/src/name.mjs +7 -0
  191. package/src/node-version.mjs +37 -0
  192. package/src/output/index.mjs +209 -10
  193. package/src/packs/manage.mjs +32 -12
  194. package/src/packs.mjs +20 -2
  195. package/src/plan/index.mjs +1 -1
  196. package/src/plugins.mjs +22 -16
  197. package/src/prbody.mjs +7 -11
  198. package/src/projection/loaders.mjs +7 -692
  199. package/src/projection/lock.mjs +11 -1
  200. package/src/projection/manifest.mjs +29 -468
  201. package/src/projection/mdc.mjs +7 -8
  202. package/src/projection/project.mjs +126 -221
  203. package/src/projection/region.mjs +561 -0
  204. package/src/projection/rules.mjs +20 -550
  205. package/src/projection/surfaces.mjs +43 -210
  206. package/src/projection/targets.mjs +5 -5
  207. package/src/repo-root.mjs +31 -0
  208. package/src/resolve/eval.mjs +2 -1
  209. package/src/resolve/fingerprint.mjs +19 -22
  210. package/src/resolve/resolve.mjs +1 -41
  211. package/src/review/critic.mjs +10 -13
  212. package/src/run.mjs +98 -0
  213. package/src/secrets.mjs +14 -22
  214. package/src/state/events.mjs +129 -0
  215. package/src/status-snapshot.mjs +233 -0
  216. package/src/status.mjs +2 -6
  217. package/src/sys/git.mjs +82 -0
  218. package/src/sys/proc.mjs +151 -0
  219. package/src/testing/parsers.mjs +265 -0
  220. package/src/testing/patches.mjs +23 -0
  221. package/src/testing/paths.mjs +49 -0
  222. package/src/testing/runners.mjs +186 -0
  223. package/src/testing/vault.mjs +68 -0
  224. package/src/text/frontmatter.mjs +49 -0
  225. package/src/text/tokens.mjs +52 -0
  226. package/src/toolwords.mjs +3 -0
  227. package/src/util/fs.mjs +20 -0
  228. package/src/util/sort.mjs +12 -0
  229. package/src/workspace.mjs +58 -11
  230. package/src/write/plan.mjs +82 -0
  231. package/templates/plan/tracker.json +1 -0
  232. package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +0 -82
  233. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +0 -49
  234. package/docs/decisions/README.md +0 -600
  235. package/src/bench/tasks.mjs +0 -36
@@ -0,0 +1,69 @@
1
+ // Mined task descriptors, read back from the tool state's `bench/tasks/` directory.
2
+ //
3
+ // Extracted from bench/run.mjs on its second real reader (ADR-002): the advice engine
4
+ // needs the two facts a run record does not carry — the prompt, which classifies the
5
+ // task, and the gold stat, which says how many files the change touched. A second private
6
+ // loader beside the first would drift the first time one learned a filter the other
7
+ // lacked.
8
+ //
9
+ // Every descriptor passes through `task-schema.mjs`, so a reader never has to guess
10
+ // whether a field is present: an unreadable task is refused with its file named, and a v1
11
+ // descriptor is normalised rather than skipped. Deterministic by construction: the
12
+ // directory is read sorted, the filter is exact, and nothing here touches a model, a
13
+ // network or a clock.
14
+ import { readdir, readFile } from 'node:fs/promises';
15
+ import { join, resolve } from 'node:path';
16
+
17
+ import { STATE_DIR } from '../name.mjs';
18
+ import { normaliseTask } from './task-schema.mjs';
19
+
20
+ /** The directory a repository's mined tasks live in. */
21
+ export function tasksDir(root) {
22
+ return join(resolve(root), STATE_DIR, 'bench', 'tasks');
23
+ }
24
+
25
+ /** The file one task descriptor lives in. */
26
+ export function taskPath(root, id) {
27
+ return join(tasksDir(root), `${id}.json`);
28
+ }
29
+
30
+ /**
31
+ * Read one task descriptor by id.
32
+ *
33
+ * @param {string} root
34
+ * @param {string} id
35
+ * @returns {Promise<object|null>} the v2 descriptor, or null when the file is absent
36
+ */
37
+ export async function readTask(root, id) {
38
+ const text = await readFile(taskPath(root, id), 'utf8').catch(() => null);
39
+ if (text === null) return null;
40
+ return normaliseTask(JSON.parse(text), { source: taskPath(root, id) });
41
+ }
42
+
43
+ /**
44
+ * Load mined benchmark task descriptors.
45
+ *
46
+ * @param {string} root the repository
47
+ * @param {string[]} [filterIds] exact task ids to keep; empty means load them all
48
+ * @returns {Promise<object[]>} v2 descriptors sorted by id (id, base, prompt, stratum,
49
+ * oracle, f2p, p2p, gold {files, linesAdded, linesRemoved}, vault)
50
+ */
51
+ export async function loadTasks(root, filterIds = []) {
52
+ const dir = tasksDir(root);
53
+ const entries = (await readdir(dir).catch(() => [])).filter((file) => file.endsWith('.json')).sort();
54
+
55
+ const filterSet = new Set(filterIds);
56
+ const tasks = [];
57
+ for (const entry of entries) {
58
+ const file = join(dir, entry);
59
+ let raw;
60
+ try {
61
+ raw = JSON.parse(await readFile(file, 'utf8'));
62
+ } catch (error) {
63
+ throw new Error(`${file}: ${error.message ?? error}`);
64
+ }
65
+ if (filterSet.size > 0 && !filterSet.has(String(raw?.id ?? ''))) continue;
66
+ tasks.push(normaliseTask(raw, { source: file }));
67
+ }
68
+ return tasks.sort((a, b) => a.id.localeCompare(b.id));
69
+ }
@@ -3,14 +3,12 @@
3
3
  // frontmatter first for `rules why` and `why`, and fail lint on partial
4
4
  // supersessions stated only in prose.
5
5
 
6
- import { execFile } from 'node:child_process';
7
6
  import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
8
7
  import { join } from 'node:path';
9
- import { promisify } from 'node:util';
10
8
  import { recordCreatedFile } from '../journal.mjs';
11
9
  import { decisionsDir } from '../decisions/dir.mjs';
12
-
13
- const exec = promisify(execFile);
10
+ import { parseFrontmatter } from '../text/frontmatter.mjs';
11
+ import { gitText } from '../sys/git.mjs';
14
12
 
15
13
  export const DECISION_KEYS = ['simt-affects', 'simt-supersedes'];
16
14
 
@@ -39,13 +37,6 @@ function unquote(value) {
39
37
  : t;
40
38
  }
41
39
 
42
- /** Strip leading YAML frontmatter (`---\n...\n---`) from a markdown document. */
43
- export function splitFrontmatter(text) {
44
- const raw = String(text ?? '');
45
- const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(raw);
46
- return match ? { front: match[1], body: raw.slice(match[0].length) } : { front: null, body: raw };
47
- }
48
-
49
40
  function parseInlineStringList(inner) {
50
41
  return inner.trim() ? inner.split(',').map(unquote).filter(Boolean) : [];
51
42
  }
@@ -93,7 +84,7 @@ function normaliseSupersedesEntry(obj, errors) {
93
84
 
94
85
  /** Parse `simt-affects` and `simt-supersedes` from an ADR's YAML frontmatter. */
95
86
  export function parseDecisionFrontmatter(text) {
96
- const { front } = splitFrontmatter(text);
87
+ const { front } = parseFrontmatter(text);
97
88
  if (front === null) {
98
89
  return { hasFrontmatter: false, hasAffects: false, hasSupersedes: false, affects: [], supersedes: [], errors: [] };
99
90
  }
@@ -255,7 +246,7 @@ export async function diffAffectedPaths(root) {
255
246
  ['diff', '--name-only'],
256
247
  ]) {
257
248
  try {
258
- const { stdout } = await exec('git', args, { cwd: root });
249
+ const stdout = await gitText(root, args);
259
250
  addLines(stdout);
260
251
  } catch {
261
252
  // Ignore if not a git repo or HEAD absent.
@@ -263,7 +254,7 @@ export async function diffAffectedPaths(root) {
263
254
  }
264
255
  if (paths.size === 0) {
265
256
  try {
266
- const { stdout } = await exec('git', ['diff-tree', '--no-commit-id', '--name-only', '-r', 'HEAD'], { cwd: root });
257
+ const stdout = await gitText(root, ['diff-tree', '--no-commit-id', '--name-only', '-r', 'HEAD']);
267
258
  addLines(stdout);
268
259
  } catch {
269
260
  // Ignore.
@@ -455,7 +446,7 @@ export function lintDecision(text, { file = 'ADR-000.md', historicalCutoff = 0 }
455
446
  const num = id ? Number(id.replace(/^ADR-/, '')) : null;
456
447
 
457
448
  const fm = parseDecisionFrontmatter(text);
458
- const { body } = splitFrontmatter(text);
449
+ const { body } = parseFrontmatter(text);
459
450
  const findings = fm.errors.map((err) => ({
460
451
  file,
461
452
  id,
@@ -0,0 +1,61 @@
1
+ // Pure helpers for a lesson record: its slug, its capped fix diff, and evidence equality.
2
+ //
3
+ // They touch no file and no clock, which is why they are separable from the store
4
+ // (review 0003 Q80).
5
+
6
+
7
+ export function slugify(text) {
8
+ const slug = String(text ?? '')
9
+ .toLowerCase()
10
+ .replace(/^not ok\s+\d+\s*-?\s*/i, '')
11
+ .replace(/^(?:error|assertionerror|fail|revert|blocked)[:\s-]*/i, '')
12
+ .replace(/[^a-z0-9]+/g, '-')
13
+ .replace(/^-+|-+$/g, '')
14
+ .slice(0, 48)
15
+ .replace(/-+$/g, '');
16
+ return slug || 'lesson';
17
+ }
18
+
19
+ /**
20
+ * Read all candidate records from `.simt/lessons.jsonl`.
21
+ *
22
+ * @param {string} root
23
+ * @returns {Promise<{candidates: Array<object>, errors: string[]}>}
24
+ */
25
+
26
+
27
+ /**
28
+ * Truncate an unbounded diff to the evidence budget, keeping the head: the hunk
29
+ * headers that name the files are the part a reader acts on.
30
+ *
31
+ * @param {string} diff
32
+ * @returns {string}
33
+ */
34
+ export function capFixDiff(diff) {
35
+ const text = String(diff ?? '');
36
+ if (text.length <= FIX_DIFF_BUDGET) return text;
37
+ return `${text.slice(0, FIX_DIFF_BUDGET)}\n… (truncated to ${FIX_DIFF_BUDGET} chars)`;
38
+ }
39
+
40
+
41
+ export function sameEvidence(a = [], b = []) {
42
+ if (a.length !== b.length) return false;
43
+ return a.every((v, i) => v === b[i]);
44
+ }
45
+
46
+ /**
47
+ * Append one deterministic candidate to the lessons ledger, deduplicating by
48
+ * `(kind, signature, evidence)`.
49
+ *
50
+ * The stored text is redacted before anything else: candidates keep the raw
51
+ * commands that failed, and a command can carry a token. Redaction runs before
52
+ * the signature is hashed so the same failure redacts to the same signature
53
+ * (review 0003 Q36).
54
+ *
55
+ * @param {string} root
56
+ * @param {object} candidate
57
+ * @returns {Promise<object>}
58
+ */
59
+
60
+ /** The cap on a stored fix diff, so one large commit cannot bloat a lesson. */
61
+ export const FIX_DIFF_BUDGET = 20000;
@@ -11,25 +11,23 @@
11
11
  // `simt lessons review` promotes a candidate to `docs/lessons/NNNN-<slug>.md` with the
12
12
  // Appendix A frontmatter (`simt-trigger`, `simt-evidence`, `simt-expires`).
13
13
 
14
+ import { FIX_DIFF_BUDGET, capFixDiff, sameEvidence, slugify } from './lesson-text.mjs';
15
+ import { noteEvent } from '../state/events.mjs';
14
16
  import { createHash } from 'node:crypto';
15
- import { execFile } from 'node:child_process';
16
17
  import { mkdir, open, readdir, readFile, writeFile } from 'node:fs/promises';
17
18
  import { dirname, join } from 'node:path';
18
- import { promisify } from 'node:util';
19
19
 
20
20
  import { postDeliveryJson } from '../agenthooks/render.mjs';
21
21
  import { lessonPaths } from '../index/govern.mjs';
22
22
  import { recordCreatedFile } from '../journal.mjs';
23
23
  import { redactText } from '../secrets.mjs';
24
24
  import { localRel, readStateFile } from '../state.mjs';
25
- import { splitFrontmatter } from './decide.mjs';
26
-
27
- const exec = promisify(execFile);
25
+ import { parseFrontmatter } from '../text/frontmatter.mjs';
26
+ import { LEDGER_FILE_LEGACY, LEDGER_FILE_REL } from '../name.mjs';
27
+ import { git } from '../sys/git.mjs';
28
28
 
29
29
  export const LESSONS_FILE = localRel('lessons.jsonl');
30
30
  const LESSONS_FILE_LEGACY = '.simt/lessons.jsonl';
31
- const LEDGER_REL = localRel('ledger.jsonl');
32
- const LEDGER_REL_LEGACY = '.simt/ledger.jsonl';
33
31
  export const LESSONS_DIR = 'docs/lessons';
34
32
  export const LESSON_KEYS = new Set(['simt-trigger', 'simt-evidence', 'simt-expires']);
35
33
 
@@ -38,7 +36,6 @@ export const LESSON_KEYS = new Set(['simt-trigger', 'simt-evidence', 'simt-expir
38
36
  * capped so one generated-file revert cannot bloat every read of the ledger.
39
37
  * Truncation keeps the head (the hunk headers that name the files) and says so.
40
38
  */
41
- const FIX_DIFF_BUDGET = 20000;
42
39
 
43
40
  /**
44
41
  * The default expiry is relative to the promotion, not a date in the source:
@@ -167,25 +164,6 @@ function extractKeywords(text, paths = []) {
167
164
  }
168
165
  return [...words];
169
166
  }
170
-
171
- function slugify(text) {
172
- const slug = String(text ?? '')
173
- .toLowerCase()
174
- .replace(/^not ok\s+\d+\s*-?\s*/i, '')
175
- .replace(/^(?:error|assertionerror|fail|revert|blocked)[:\s-]*/i, '')
176
- .replace(/[^a-z0-9]+/g, '-')
177
- .replace(/^-+|-+$/g, '')
178
- .slice(0, 48)
179
- .replace(/-+$/g, '');
180
- return slug || 'lesson';
181
- }
182
-
183
- /**
184
- * Read all candidate records from `.simt/lessons.jsonl`.
185
- *
186
- * @param {string} root
187
- * @returns {Promise<{candidates: Array<object>, errors: string[]}>}
188
- */
189
167
  export async function readLessonCandidates(root) {
190
168
  // The pre-move path is a fallback for one release (review 0003 Q19).
191
169
  let text;
@@ -255,38 +233,6 @@ export function lessonIdFor({ kind, signature, evidence = [], cmd = null }) {
255
233
  .digest('hex');
256
234
  return `l-${digest.slice(0, 12)}`;
257
235
  }
258
-
259
- /**
260
- * Truncate an unbounded diff to the evidence budget, keeping the head: the hunk
261
- * headers that name the files are the part a reader acts on.
262
- *
263
- * @param {string} diff
264
- * @returns {string}
265
- */
266
- function capFixDiff(diff) {
267
- const text = String(diff ?? '');
268
- if (text.length <= FIX_DIFF_BUDGET) return text;
269
- return `${text.slice(0, FIX_DIFF_BUDGET)}\n… (truncated to ${FIX_DIFF_BUDGET} chars)`;
270
- }
271
-
272
- function sameEvidence(a = [], b = []) {
273
- if (a.length !== b.length) return false;
274
- return a.every((v, i) => v === b[i]);
275
- }
276
-
277
- /**
278
- * Append one deterministic candidate to the lessons ledger, deduplicating by
279
- * `(kind, signature, evidence)`.
280
- *
281
- * The stored text is redacted before anything else: candidates keep the raw
282
- * commands that failed, and a command can carry a token. Redaction runs before
283
- * the signature is hashed so the same failure redacts to the same signature
284
- * (review 0003 Q36).
285
- *
286
- * @param {string} root
287
- * @param {object} candidate
288
- * @returns {Promise<object>}
289
- */
290
236
  export async function appendLessonCandidate(root, candidate) {
291
237
  const { candidates } = await readLessonCandidates(root);
292
238
  const kind = String(candidate.kind ?? 'fail-then-fix');
@@ -343,21 +289,15 @@ async function diffBetweenTrees(root, fromRef, toRef) {
343
289
  }
344
290
  try {
345
291
  const [namesOut, diffOut] = await Promise.all([
346
- exec('git', ['diff', '--name-only', fromRef, toRef, '--', '.', ':(exclude).simt'], {
347
- cwd: root,
348
- maxBuffer: 8 << 20,
349
- }),
350
- exec('git', ['diff', '--unified=3', fromRef, toRef, '--', '.', ':(exclude).simt'], {
351
- cwd: root,
352
- maxBuffer: 8 << 20,
353
- }),
292
+ git(root, ['diff', '--name-only', fromRef, toRef, '--', '.', ':(exclude).simt'], { maxBuffer: 8 << 20 }),
293
+ git(root, ['diff', '--unified=3', fromRef, toRef, '--', '.', ':(exclude).simt'], { maxBuffer: 8 << 20 }),
354
294
  ]);
355
- const paths = namesOut.stdout
295
+ const paths = namesOut
356
296
  .split('\n')
357
297
  .map((s) => s.trim())
358
298
  .filter(Boolean)
359
299
  .sort();
360
- return { paths, diff: diffOut.stdout.trim() };
300
+ return { paths, diff: diffOut.trim() };
361
301
  } catch {
362
302
  return { paths: [], diff: '' };
363
303
  }
@@ -371,7 +311,8 @@ async function diffBetweenTrees(root, fromRef, toRef) {
371
311
  * @param {{failEntry: object, passEntry: object, failOutput?: string, fixDiff?: string, paths?: string[]}} params
372
312
  * @returns {Promise<object>}
373
313
  */
374
- export async function recordFailThenFix(root, { failEntry, passEntry, failOutput, fixDiff, paths }) {
314
+ export async function recordFailThenFix(root, { failEntry, passEntry, failOutput, fixDiff, paths
315
+ }) {
375
316
  const rawError = failOutput ?? failEntry?.errorLine ?? failEntry?.summary ?? failEntry?.cmd ?? '';
376
317
  const errorLine = normaliseErrorLine(failEntry?.errorLine ?? firstErrorLine(rawError));
377
318
  const signature = failEntry?.signature ?? errorSignature(errorLine);
@@ -386,7 +327,7 @@ export async function recordFailThenFix(root, { failEntry, passEntry, failOutput
386
327
  resolvedDiff ??= treeDiff.diff;
387
328
  }
388
329
 
389
- return appendLessonCandidate(root, {
330
+ const record = await appendLessonCandidate(root, {
390
331
  kind: 'fail-then-fix',
391
332
  cmd: passEntry?.cmd ?? failEntry?.cmd ?? null,
392
333
  signature,
@@ -396,6 +337,13 @@ export async function recordFailThenFix(root, { failEntry, passEntry, failOutput
396
337
  paths: resolvedPaths,
397
338
  fixDiff: resolvedDiff,
398
339
  });
340
+ // The lessons file is the reader's view; the log is what a UI can tail (Q73).
341
+ await noteEvent(root, {
342
+ kind: 'lesson.captured',
343
+ subject: passEntry?.cmd ?? failEntry?.cmd ?? null,
344
+ data: { id: record?.id ?? null, signature, tree: fixTree },
345
+ });
346
+ return record;
399
347
  }
400
348
 
401
349
  /**
@@ -410,7 +358,7 @@ export async function captureFromLedger(root, entries = null) {
410
358
  let list = entries;
411
359
  if (!Array.isArray(list)) {
412
360
  try {
413
- const { text } = await readStateFile(root, LEDGER_REL, [LEDGER_REL_LEGACY]);
361
+ const { text } = await readStateFile(root, localRel(LEDGER_FILE_REL), [LEDGER_FILE_LEGACY]);
414
362
  list = (text ?? '')
415
363
  .split('\n')
416
364
  .map((s) => s.trim())
@@ -453,12 +401,11 @@ export async function captureRevertedCommits(root, options = {}) {
453
401
  const limit = options.limit ?? 50;
454
402
  let stdout = '';
455
403
  try {
456
- const res = await exec(
457
- 'git',
404
+ stdout = await git(
405
+ root,
458
406
  ['log', '--no-merges', `-n`, String(limit), '--format=%H%x00%s%x00%b%x1e'],
459
- { cwd: root, maxBuffer: 8 << 20 },
407
+ { maxBuffer: 8 << 20 },
460
408
  );
461
- stdout = res.stdout;
462
409
  } catch {
463
410
  return [];
464
411
  }
@@ -482,16 +429,16 @@ export async function captureRevertedCommits(root, options = {}) {
482
429
  let paths = [];
483
430
  let fixDiff = '';
484
431
  try {
485
- const [namesRes, diffRes] = await Promise.all([
486
- exec('git', ['diff-tree', '--no-commit-id', '--name-only', '-r', sha], { cwd: root, maxBuffer: 8 << 20 }),
487
- exec('git', ['show', '--format=', '--unified=3', sha], { cwd: root, maxBuffer: 8 << 20 }),
432
+ const [namesOut, diffOut] = await Promise.all([
433
+ git(root, ['diff-tree', '--no-commit-id', '--name-only', '-r', sha], { maxBuffer: 8 << 20 }),
434
+ git(root, ['show', '--format=', '--unified=3', sha], { maxBuffer: 8 << 20 }),
488
435
  ]);
489
- paths = namesRes.stdout
436
+ paths = namesOut
490
437
  .split('\n')
491
438
  .map((s) => s.trim())
492
439
  .filter(Boolean)
493
440
  .sort();
494
- fixDiff = diffRes.stdout.trim();
441
+ fixDiff = diffOut.trim();
495
442
  } catch {
496
443
  // ignore diff failure
497
444
  }
@@ -634,7 +581,7 @@ export function renderLessonMarkdown(candidate, options = {}) {
634
581
  * @returns {object}
635
582
  */
636
583
  export function parseLessonFile(id, text, now = new Date()) {
637
- const { front: rawFront, body: rawBody } = splitFrontmatter(text);
584
+ const { front: rawFront, body: rawBody } = parseFrontmatter(text);
638
585
  const front = rawFront ?? '';
639
586
  const body = rawBody.trim();
640
587
 
package/src/name.mjs CHANGED
@@ -46,6 +46,13 @@ export const LOCK_LEGACY = 'simt-dev.lock.json';
46
46
  * markers, and the provenance header inside them. It follows the command, like the
47
47
  * manifest: the region is the command's footprint in a file the repo owns.
48
48
  */
49
+ /** The ledger's pre-STATE_DIR home. Read, never written: state written under the old
50
+ * name is still read by the ledger and the lesson sources. */
51
+ export const LEDGER_FILE_LEGACY = '.simt/ledger.jsonl';
52
+
53
+ /** The ledger the evidence is appended to, under the config directory. */
54
+ export const LEDGER_FILE_REL = 'ledger.jsonl';
55
+
49
56
  export const MARKER_PREFIX = 'simt';
50
57
 
51
58
  /** What the region was namespaced under, and still is in every adopted repo. */
@@ -0,0 +1,37 @@
1
+ // The runtime floor, in one place both entry points read (review 0003 Q54).
2
+ //
3
+ // `package.json` declares `engines.node` and `pyproject.toml` declares a python
4
+ // version; both are advisory to the installer, so an older runtime reached the CLI
5
+ // and failed somewhere inside it — a syntax error, a missing API — instead of the one
6
+ // sentence that says what to install. This module is deliberately tiny and has no
7
+ // imports: the bootstrap that calls it must not load the tool that cannot run.
8
+
9
+ /** The oldest Node this package runs on. Mirrors `package.json` `engines.node`. */
10
+ export const MIN_NODE = '22.20.0';
11
+
12
+ /**
13
+ * Whether `current` is at least `minimum`, compared component by component.
14
+ *
15
+ * A string compare is wrong ("9.0.0" > "22.20.0"), and `process.versions.node` may
16
+ * carry a pre-release suffix that `Number()` would turn into NaN, so the numeric
17
+ * prefix of each component is what counts.
18
+ *
19
+ * @param {string} current a version such as `22.20.0` or `23.1.0-rc.1`
20
+ * @param {string} [minimum]
21
+ * @returns {boolean}
22
+ */
23
+ export function satisfiesMinimum(current, minimum = MIN_NODE) {
24
+ const parse = (value) =>
25
+ String(value)
26
+ .split('.')
27
+ .map((part) => Number.parseInt(part, 10))
28
+ .map((part) => (Number.isFinite(part) ? part : 0));
29
+ const have = parse(current);
30
+ const want = parse(minimum);
31
+ for (let i = 0; i < Math.max(have.length, want.length); i += 1) {
32
+ const a = have[i] ?? 0;
33
+ const b = want[i] ?? 0;
34
+ if (a !== b) return a > b;
35
+ }
36
+ return true;
37
+ }