simt-dev 0.7.1 → 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 (186) hide show
  1. package/CHANGELOG.md +36 -1
  2. package/README.md +169 -80
  3. package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
  4. package/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +1 -1
  5. package/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +1 -1
  6. package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +1 -1
  7. package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +1 -1
  8. package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
  9. package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +1 -1
  10. package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +1 -1
  11. package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +1 -1
  12. package/docs/decisions/ADR-126-the-mcp-surface-stays-a-reader-so-there-is-no-write-path-to-build.md +68 -0
  13. package/docs/decisions/ADR-127-the-mcp-tool-list-is-written-by-hand-and-roots-is-not-asked-for.md +65 -0
  14. package/docs/decisions/ADR-128-a-research-plan-is-a-plan-and-the-tracker-reads-it.md +42 -0
  15. package/ecosystems/bun.json +6 -0
  16. package/ecosystems/cargo.json +25 -0
  17. package/ecosystems/composer.json +6 -0
  18. package/ecosystems/dart.json +24 -0
  19. package/ecosystems/deno.json +21 -0
  20. package/ecosystems/devcontainers.json +6 -0
  21. package/ecosystems/docker-compose.json +6 -0
  22. package/ecosystems/docker.json +6 -0
  23. package/ecosystems/dotnet.json +25 -0
  24. package/ecosystems/elixir.json +23 -0
  25. package/ecosystems/gitsubmodule.json +6 -0
  26. package/ecosystems/go.json +25 -0
  27. package/ecosystems/gradle.json +13 -0
  28. package/ecosystems/maven.json +23 -0
  29. package/ecosystems/node.json +25 -0
  30. package/ecosystems/python.json +20 -0
  31. package/ecosystems/ruby.json +23 -0
  32. package/ecosystems/swift.json +25 -0
  33. package/ecosystems/terraform.json +6 -0
  34. package/ecosystems/uv.json +6 -0
  35. package/harnesses/antigravity.json +162 -0
  36. package/harnesses/claude.json +206 -0
  37. package/harnesses/codex.json +136 -0
  38. package/harnesses/copilot.json +88 -0
  39. package/harnesses/cursor.json +140 -0
  40. package/harnesses/deepseek.json +99 -0
  41. package/harnesses/gemini.json +122 -0
  42. package/harnesses/opencode.json +173 -0
  43. package/package.json +3 -1
  44. package/rules/mdc/intake-questions.mdc +8 -0
  45. package/src/advice/advice.mjs +3 -2
  46. package/src/agenthooks/render.mjs +86 -180
  47. package/src/agents/render.mjs +15 -18
  48. package/src/api.mjs +7 -5
  49. package/src/argv.mjs +19 -0
  50. package/src/bench/ablate.mjs +3 -4
  51. package/src/bench/adapters.json +155 -113
  52. package/src/bench/adapters.mjs +466 -0
  53. package/src/bench/arms.mjs +333 -0
  54. package/src/bench/grade.mjs +12 -26
  55. package/src/bench/graders.mjs +42 -0
  56. package/src/bench/mine.mjs +17 -535
  57. package/src/bench/probe-ledger.mjs +149 -0
  58. package/src/bench/probes.catalog.mjs +918 -0
  59. package/src/bench/probes.mjs +15 -1037
  60. package/src/bench/render.mjs +111 -0
  61. package/src/bench/report.mjs +116 -971
  62. package/src/bench/run.mjs +164 -881
  63. package/src/bench/variants.mjs +4 -5
  64. package/src/bench/workspace.mjs +160 -0
  65. package/src/brief/assemble.mjs +11 -13
  66. package/src/ci/dependabot.mjs +25 -55
  67. package/src/commands/agent.mjs +1 -9
  68. package/src/commands/agenthook.mjs +66 -35
  69. package/src/commands/bench.mjs +2 -2
  70. package/src/commands/ci.mjs +11 -13
  71. package/src/commands/docs.mjs +88 -0
  72. package/src/commands/gate.mjs +10 -13
  73. package/src/commands/harness.mjs +3 -3
  74. package/src/commands/help.mjs +44 -3
  75. package/src/commands/hooks.mjs +20 -20
  76. package/src/commands/ignore.mjs +11 -7
  77. package/src/commands/impact.mjs +4 -8
  78. package/src/commands/index.mjs +49 -9
  79. package/src/commands/init-scan.mjs +90 -0
  80. package/src/commands/init.mjs +122 -104
  81. package/src/commands/lease.mjs +67 -18
  82. package/src/commands/map.mjs +10 -5
  83. package/src/commands/mcp.mjs +2 -17
  84. package/src/commands/repoindex.mjs +8 -8
  85. package/src/commands/rules.mjs +8 -4
  86. package/src/commands/shared.mjs +12 -16
  87. package/src/commands/skills.mjs +5 -6
  88. package/src/commands/skillscompile.mjs +3 -3
  89. package/src/commands/skillsinstall.mjs +5 -4
  90. package/src/commands/slice.mjs +199 -64
  91. package/src/commands/spec.mjs +78 -62
  92. package/src/commands/status.mjs +24 -12
  93. package/src/commands/sync.mjs +14 -14
  94. package/src/commands/tests.mjs +12 -12
  95. package/src/commands/tokens.mjs +19 -24
  96. package/src/commands/upgrade.mjs +8 -4
  97. package/src/commands/why.mjs +6 -13
  98. package/src/compare/hooksstate.mjs +2 -6
  99. package/src/compare/learn.mjs +3 -1
  100. package/src/decisions/index.mjs +3 -2
  101. package/src/docs/commands.mjs +65 -0
  102. package/src/ecosystem/records.mjs +219 -0
  103. package/src/explain.mjs +31 -0
  104. package/src/gates/index.mjs +385 -0
  105. package/src/graph/compile.mjs +8 -4
  106. package/src/graph/load.mjs +2 -1
  107. package/src/graph/spec.mjs +10 -8
  108. package/src/harness/records.mjs +143 -0
  109. package/src/hooks/capability.mjs +5 -3
  110. package/src/hooks/dispatcher.mjs +26 -32
  111. package/src/hooks/gates.mjs +12 -2
  112. package/src/hooks/resolve.mjs +2 -7
  113. package/src/hooks/where.mjs +2 -7
  114. package/src/identity.mjs +2 -11
  115. package/src/index/coverage.mjs +58 -0
  116. package/src/index/extract.mjs +6 -16
  117. package/src/index/gate.mjs +1 -1
  118. package/src/index/govern.mjs +26 -9
  119. package/src/index/impact.mjs +4 -8
  120. package/src/index/map.mjs +14 -15
  121. package/src/index/open.mjs +173 -0
  122. package/src/index/persist.mjs +224 -57
  123. package/src/index/store.mjs +82 -39
  124. package/src/index/tests.mjs +2 -6
  125. package/src/journal.mjs +16 -11
  126. package/src/kernel/card.mjs +37 -63
  127. package/src/kernel/checks.mjs +4 -10
  128. package/src/lease.mjs +102 -7
  129. package/src/ledger/index.mjs +124 -17
  130. package/src/mcp/paginate.mjs +33 -0
  131. package/src/mcp/schemas.mjs +512 -0
  132. package/src/mcp/server.mjs +49 -9
  133. package/src/mcp/tools-facts.mjs +326 -0
  134. package/src/mcp/tools-read.mjs +221 -0
  135. package/src/mcp/tools.mjs +4 -1005
  136. package/src/{bench → measure}/corpus.mjs +1 -1
  137. package/src/measure/metrics.mjs +236 -0
  138. package/src/measure/records.mjs +211 -0
  139. package/src/measure/stats.mjs +538 -0
  140. package/src/measure/task-schema.mjs +125 -0
  141. package/src/measure/tasks.mjs +69 -0
  142. package/src/memory/decide.mjs +6 -15
  143. package/src/memory/lesson-text.mjs +61 -0
  144. package/src/memory/lessons.mjs +29 -82
  145. package/src/name.mjs +7 -0
  146. package/src/output/index.mjs +122 -31
  147. package/src/packs/manage.mjs +15 -9
  148. package/src/plan/index.mjs +1 -1
  149. package/src/plugins.mjs +22 -16
  150. package/src/prbody.mjs +7 -11
  151. package/src/projection/loaders.mjs +7 -692
  152. package/src/projection/manifest.mjs +10 -435
  153. package/src/projection/mdc.mjs +7 -8
  154. package/src/projection/project.mjs +126 -221
  155. package/src/projection/region.mjs +561 -0
  156. package/src/projection/rules.mjs +16 -547
  157. package/src/projection/surfaces.mjs +43 -210
  158. package/src/projection/targets.mjs +5 -5
  159. package/src/repo-root.mjs +13 -1
  160. package/src/resolve/eval.mjs +2 -1
  161. package/src/resolve/fingerprint.mjs +0 -7
  162. package/src/resolve/resolve.mjs +1 -41
  163. package/src/review/critic.mjs +8 -12
  164. package/src/run.mjs +98 -0
  165. package/src/secrets.mjs +14 -22
  166. package/src/state/events.mjs +129 -0
  167. package/src/status-snapshot.mjs +233 -0
  168. package/src/status.mjs +2 -6
  169. package/src/sys/git.mjs +82 -0
  170. package/src/{bench/process.mjs → sys/proc.mjs} +28 -5
  171. package/src/testing/parsers.mjs +265 -0
  172. package/src/testing/patches.mjs +23 -0
  173. package/src/testing/paths.mjs +49 -0
  174. package/src/testing/runners.mjs +186 -0
  175. package/src/testing/vault.mjs +68 -0
  176. package/src/text/frontmatter.mjs +49 -0
  177. package/src/text/tokens.mjs +52 -0
  178. package/src/toolwords.mjs +1 -1
  179. package/src/util/fs.mjs +20 -0
  180. package/src/util/sort.mjs +12 -0
  181. package/src/workspace.mjs +3 -1
  182. package/src/write/plan.mjs +82 -0
  183. package/templates/plan/tracker.json +1 -0
  184. package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +0 -82
  185. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +0 -49
  186. package/src/bench/tasks.mjs +0 -36
@@ -18,7 +18,7 @@ import { dirname, join, resolve } from 'node:path';
18
18
 
19
19
  import { STATE_DIR } from '../name.mjs';
20
20
  import { packageVersion } from '../package-version.mjs';
21
- import { resolveVaultDir } from './mine.mjs';
21
+ import { resolveVaultDir } from '../testing/vault.mjs';
22
22
 
23
23
  /** The document kind stamp, so a reader can refuse a file that is not a corpus. */
24
24
  export const CORPUS_KIND = 'simt-bench-corpus';
@@ -0,0 +1,236 @@
1
+ // Derived per-run metrics: what a record costs, and how good it is.
2
+ //
3
+ // Split from the statistics beside it (review 0003 Q78): a metric is a function of one
4
+ // record, a statistical test is a function of a population of them, and a reader looking
5
+ // for the cost model should not have to open the bootstrap.
6
+ //
7
+ // The cost model distinguishes three answers because a missing cost must never read as a
8
+ // zero cost: `priced` from the token table, `reported` by the harness, `missing` when
9
+ // neither exists (review 0003 Q46).
10
+ import { readFile } from 'node:fs/promises';
11
+ import { join, resolve } from 'node:path';
12
+
13
+ import { LOCAL_DIR } from '../state.mjs';
14
+
15
+ function normalizePriceEntry(raw, label) {
16
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
17
+ throw new Error(`${label} must be a JSON object with {in, cache_write, cache_read, out}`);
18
+ }
19
+ const pIn = Number(raw.in ?? raw.input ?? 0);
20
+ const pCw = Number(raw.cache_write ?? raw.cacheWrite ?? 0);
21
+ const pCr = Number(raw.cache_read ?? raw.cacheRead ?? 0);
22
+ const pOut = Number(raw.out ?? raw.output ?? 0);
23
+ for (const [key, value] of [
24
+ ['in', pIn],
25
+ ['cache_write', pCw],
26
+ ['cache_read', pCr],
27
+ ['out', pOut],
28
+ ]) {
29
+ if (!Number.isFinite(value) || value < 0) {
30
+ throw new Error(`${label}.${key} must be a non-negative number`);
31
+ }
32
+ }
33
+ return { in: pIn, cache_write: pCw, cache_read: pCr, out: pOut };
34
+ }
35
+
36
+ function isFlatPriceObject(obj) {
37
+ return (
38
+ 'in' in obj ||
39
+ 'input' in obj ||
40
+ 'cache_write' in obj ||
41
+ 'cacheWrite' in obj ||
42
+ 'cache_read' in obj ||
43
+ 'cacheRead' in obj ||
44
+ 'out' in obj ||
45
+ 'output' in obj
46
+ );
47
+ }
48
+
49
+ /**
50
+ * Load `.simt/local/bench/prices.json` if present.
51
+ *
52
+ * Supports either a flat `{in, cache_write, cache_read, out}` object (prices per 1M
53
+ * tokens) or a map keyed by model/harness name with an optional `"default"` entry.
54
+ *
55
+ * @param {string} root
56
+ * @returns {Promise<null|{kind: 'flat'|'map', entry?: object, entries?: object}>}
57
+ */
58
+ export async function loadPrices(root) {
59
+ // The pre-move path is a fallback for one release (review 0003 Q19).
60
+ const pricesPath = join(resolve(root), LOCAL_DIR, 'bench', 'prices.json');
61
+ const legacyPath = join(resolve(root), '.simt', 'bench', 'prices.json');
62
+ let rawText;
63
+ try {
64
+ rawText = await readFile(pricesPath, 'utf8');
65
+ } catch (error) {
66
+ if (error && error.code === 'ENOENT') {
67
+ try {
68
+ rawText = await readFile(legacyPath, 'utf8');
69
+ } catch (legacyError) {
70
+ if (legacyError && legacyError.code === 'ENOENT') return null;
71
+ throw legacyError;
72
+ }
73
+ } else throw error;
74
+ }
75
+
76
+ let parsed;
77
+ try {
78
+ parsed = JSON.parse(rawText);
79
+ } catch (error) {
80
+ throw new Error(`invalid JSON in ${pricesPath}: ${error.message}`);
81
+ }
82
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
83
+ throw new Error(`${pricesPath} must be a JSON object`);
84
+ }
85
+
86
+ if (isFlatPriceObject(parsed)) {
87
+ return { kind: 'flat', entry: normalizePriceEntry(parsed, pricesPath) };
88
+ }
89
+
90
+ const entries = {};
91
+ for (const [key, value] of Object.entries(parsed)) {
92
+ entries[key] = normalizePriceEntry(value, `${pricesPath}[${JSON.stringify(key)}]`);
93
+ }
94
+ return { kind: 'map', entries };
95
+ }
96
+
97
+ function resolvePriceEntry(prices, run) {
98
+ if (!prices) return null;
99
+ if (prices.kind === 'flat') return normalizePriceEntry(prices.entry, 'prices');
100
+ if (prices.kind === 'map') {
101
+ const entry =
102
+ (run?.model && prices.entries[run.model]) ||
103
+ (run?.harness && prices.entries[run.harness]) ||
104
+ prices.entries.default ||
105
+ null;
106
+ return entry ? normalizePriceEntry(entry, 'prices') : null;
107
+ }
108
+ if (isFlatPriceObject(prices)) {
109
+ return normalizePriceEntry(prices, 'prices');
110
+ }
111
+ const entry =
112
+ (run?.model && prices[run.model]) || (run?.harness && prices[run.harness]) || prices.default || null;
113
+ return entry ? normalizePriceEntry(entry, 'prices') : null;
114
+ }
115
+
116
+ /**
117
+ * Compute billed cost in USD for a single run record.
118
+ *
119
+ * Returns `{usd, source}` where `usd` is null when the cost is unknown: an arm whose
120
+ * prices are absent must not report a free run.
121
+ *
122
+ * @param {object} run
123
+ * @param {object|null} [prices]
124
+ * @returns {{usd: number|null, source: 'priced'|'reported'|'missing'}}
125
+ */
126
+ export function computeRunCost(run, prices = null) {
127
+ const usage = run?.usage ?? {};
128
+ const input = Number(usage.input ?? 0);
129
+ const cacheWrite = Number(usage.cacheWrite ?? usage.cache_write ?? 0);
130
+ const cacheRead = Number(usage.cacheRead ?? usage.cache_read ?? 0);
131
+ const output = Number(usage.output ?? 0);
132
+ const totalTokens = input + cacheWrite + cacheRead + output;
133
+
134
+ const entry = resolvePriceEntry(prices, run);
135
+ if (entry) {
136
+ if (totalTokens === 0 && Number(run?.costUsd ?? 0) > 0) {
137
+ return { usd: Number(run.costUsd), source: 'reported' };
138
+ }
139
+ return {
140
+ usd:
141
+ (input * entry.in + cacheWrite * entry.cache_write + cacheRead * entry.cache_read + output * entry.out) /
142
+ 1_000_000,
143
+ source: 'priced',
144
+ };
145
+ }
146
+ const reported = run?.costUsd;
147
+ if (typeof reported === 'number' && Number.isFinite(reported)) {
148
+ return { usd: reported, source: 'reported' };
149
+ }
150
+ return { usd: null, source: 'missing' };
151
+ }
152
+
153
+ /** Whether a run attempt counts as resolved: hidden tests pass AND declared gates pass. */
154
+ export function isRunResolved(run) {
155
+ return Boolean(run?.resolved && (run?.gatesPassed ?? true));
156
+ }
157
+
158
+ /**
159
+ * Quality score Q in [0, 1]: 0.5 hidden tests + 0.2 gates + 0.2 probe + 0.1 diff hygiene.
160
+ *
161
+ * @param {object} run
162
+ * @returns {number}
163
+ */
164
+ export function computeRunQuality(run) {
165
+ const testsScore = run?.resolved ? 1 : 0;
166
+ const gatesScore = (run?.gatesPassed ?? true) ? 1 : 0;
167
+
168
+ let probeScore = 1;
169
+ if (run?.probe !== null && run?.probe !== undefined) {
170
+ if (typeof run.probe === 'boolean') probeScore = run.probe ? 1 : 0;
171
+ else if (typeof run.probe === 'number' && Number.isFinite(run.probe)) {
172
+ probeScore = Math.max(0, Math.min(1, run.probe));
173
+ } else if (typeof run.probe === 'object') probeScore = run.probe.passed ? 1 : 0;
174
+ }
175
+
176
+ const files = Number(run?.diff?.files ?? 0);
177
+ const outside = Number(run?.diff?.outsideBlastRadius ?? 0);
178
+ let hygieneScore = 1;
179
+ if (outside > 0) {
180
+ hygieneScore = files > 0 ? Math.max(0, Math.min(1, (files - outside) / files)) : 0;
181
+ }
182
+
183
+ return (5 * testsScore + 2 * gatesScore + 2 * probeScore + hygieneScore) / 10;
184
+ }
185
+
186
+ /**
187
+ * Evaluate one record into the row an arm aggregation and a paired test consume.
188
+ *
189
+ * @param {object} run
190
+ * @param {object|null} [prices]
191
+ * @returns {object}
192
+ */
193
+ export function evaluateRunRecord(run, prices = null) {
194
+ const usage = run?.usage ?? {};
195
+ const input = Number(usage.input ?? 0);
196
+ const cacheWrite = Number(usage.cacheWrite ?? usage.cache_write ?? 0);
197
+ const cacheRead = Number(usage.cacheRead ?? usage.cache_read ?? 0);
198
+ const output = Number(usage.output ?? 0);
199
+ const turns = Number(usage.turns ?? 0);
200
+ const toolCalls = Number(usage.toolCalls ?? usage.tool_calls ?? 0);
201
+ const R = isRunResolved(run) ? 1 : 0;
202
+ const Q = computeRunQuality(run);
203
+ const cost = computeRunCost(run, prices);
204
+ const cacheDenom = input + cacheRead;
205
+ const cacheHitRatio = cacheDenom > 0 ? cacheRead / cacheDenom : 0;
206
+ // A v2 experiment is the conditions, a v1 experiment was a label. Both reduce to one
207
+ // string, so grouping by experiment keeps working across a stored corpus.
208
+ const experiment = run?.experiment;
209
+ const experimentKey =
210
+ typeof experiment === 'string'
211
+ ? experiment
212
+ : experiment && typeof experiment === 'object'
213
+ ? [experiment.simtVersion ?? '-', experiment.rulesDigest ?? '-', experiment.corpusDigest ?? '-'].join('/')
214
+ : '';
215
+
216
+ return {
217
+ task: String(run?.task ?? ''),
218
+ stratum: String(run?.stratum ?? 'unknown'),
219
+ arm: String(run?.arm ?? 'default'),
220
+ harness: String(run?.harness ?? ''),
221
+ model: String(run?.model ?? ''),
222
+ experiment: experimentKey,
223
+ rep: Number(run?.rep ?? 1),
224
+ R,
225
+ Q,
226
+ C: cost.usd,
227
+ costSource: cost.source,
228
+ turns,
229
+ toolCalls,
230
+ input,
231
+ cacheWrite,
232
+ cacheRead,
233
+ output,
234
+ cacheHitRatio,
235
+ };
236
+ }
@@ -0,0 +1,211 @@
1
+ // Stored benchmark run records, schema v2.
2
+ //
3
+ // A v1 record said what an arm scored and nothing about the conditions it scored under.
4
+ // Two records from different rule text, different corpora or different harness versions
5
+ // were indistinguishable, so a report could compare them and call the difference an
6
+ // effect (review 0003 Q78). The record now carries the experiment that produced it, and
7
+ // the run id names the model whenever a model other than the harness default was used:
8
+ // two models driven through one harness used to overwrite each other's run directory.
9
+ //
10
+ // Loading is tolerant in one direction only: a record that does not parse is skipped and
11
+ // reported as a warning, because one corrupt file is not an empty corpus. A reader that
12
+ // answers "no runs" for a directory of unreadable files hides the corruption entirely.
13
+ import { readdir, readFile } from 'node:fs/promises';
14
+ import { createHash } from 'node:crypto';
15
+ import { arch, cpus, platform } from 'node:os';
16
+ import { join, resolve } from 'node:path';
17
+
18
+ import { LOCAL_DIR } from '../state.mjs';
19
+ import { packageVersion } from '../package-version.mjs';
20
+
21
+ export const RECORD_SCHEMA_VERSION = 2;
22
+
23
+ /** Directory a run's records are stored under, with the pre-move path as a fallback. */
24
+ export function runsDir(root) {
25
+ return join(resolve(root), LOCAL_DIR, 'bench', 'runs');
26
+ }
27
+
28
+ function legacyRunsDir(root) {
29
+ return join(resolve(root), '.simt', 'bench', 'runs');
30
+ }
31
+
32
+ /**
33
+ * The run directory name for one arm and repetition.
34
+ *
35
+ * The model is part of the name when it is a real choice. A `default` model (or a
36
+ * harness whose model is itself) adds nothing to the name, and a name that changes for
37
+ * every run would make the stored path useless to a reader.
38
+ *
39
+ * @param {{harness: string, model?: string|null, arm: string, rep: number}} spec
40
+ * @returns {string}
41
+ */
42
+ export function runIdFor({ harness, model, arm, rep }) {
43
+ const named =
44
+ typeof model === 'string' && model.length > 0 && model !== 'default' && model !== harness;
45
+ return `${harness}-${named ? `${model}-` : ''}${arm}-r${rep}`;
46
+ }
47
+
48
+ /**
49
+ * The conditions a run was produced under, recorded so a report can refuse to pool
50
+ * records that were not comparable.
51
+ *
52
+ * @param {{simtVersion?: string, rulesDigest?: string|null, adapterVersion?: string|null,
53
+ * corpusDigest?: string|null, seed?: number|null, isolation?: string}} input
54
+ * @returns {object}
55
+ */
56
+ export function experimentOf({
57
+ simtVersion = packageVersion(),
58
+ rulesDigest = null,
59
+ adapterVersion = null,
60
+ corpusDigest = null,
61
+ seed = null,
62
+ isolation = 'none',
63
+ } = {}) {
64
+ return {
65
+ simtVersion,
66
+ rulesDigest,
67
+ adapterVersion,
68
+ corpusDigest,
69
+ seed,
70
+ isolation,
71
+ hardware: `${platform()}/${arch()}/${cpus().length}cpu`,
72
+ };
73
+ }
74
+
75
+ /**
76
+ * A digest of the corpus a run was measured against.
77
+ *
78
+ * It is the sorted task ids and their base commits, hashed: a re-mine that changes which
79
+ * tasks exist, or where they start, changes the digest, and a report can say the two
80
+ * records were not measured on the same corpus.
81
+ *
82
+ * @param {object[]} tasks normalised task descriptors
83
+ * @returns {string}
84
+ */
85
+ export function corpusDigestOf(tasks = []) {
86
+ const lines = [...tasks]
87
+ .map((task) => `${task.id}\0${task.base}`)
88
+ .sort()
89
+ .join('\n');
90
+ return createHash('sha256').update(lines).digest('hex').slice(0, 16);
91
+ }
92
+
93
+ /**
94
+ * Build one record.
95
+ *
96
+ * `status` is the record's own claim: `ok` measured something, `error` did not. An error
97
+ * record keeps the run's identity so the report can exclude it by name rather than by
98
+ * looking for a missing field.
99
+ *
100
+ * @param {object} input
101
+ * @returns {object}
102
+ */
103
+ export function runRecord({
104
+ task,
105
+ arm,
106
+ harness,
107
+ model,
108
+ rep,
109
+ measured = true,
110
+ isolation = 'none',
111
+ experiment = null,
112
+ status = 'ok',
113
+ error = null,
114
+ agent = null,
115
+ resolved = false,
116
+ gatesPassed = false,
117
+ f2pPassed = [],
118
+ usage = null,
119
+ costUsd = null,
120
+ diff = { files: 0, outsideBlastRadius: 0 },
121
+ skills = null,
122
+ probe = null,
123
+ }) {
124
+ const record = {
125
+ schemaVersion: RECORD_SCHEMA_VERSION,
126
+ task: String(task?.id ?? task ?? ''),
127
+ stratum: String(task?.stratum ?? 'unknown'),
128
+ arm,
129
+ harness,
130
+ model,
131
+ rep,
132
+ measured: Boolean(measured),
133
+ isolation,
134
+ experiment,
135
+ status,
136
+ agent,
137
+ resolved: Boolean(resolved),
138
+ gatesPassed: Boolean(gatesPassed),
139
+ f2pPassed: [...f2pPassed],
140
+ usage: usage ?? { input: 0, cacheWrite: 0, cacheRead: 0, output: 0, turns: 0, toolCalls: 0 },
141
+ costUsd: typeof costUsd === 'number' && Number.isFinite(costUsd) ? costUsd : null,
142
+ diff,
143
+ skills,
144
+ probe,
145
+ };
146
+ if (error !== null) record.error = String(error);
147
+ return record;
148
+ }
149
+
150
+ /**
151
+ * Read every stored record, and report the files it could not read.
152
+ *
153
+ * @param {string} root
154
+ * @returns {Promise<{records: object[], warnings: string[]}>}
155
+ */
156
+ export async function readRunRecords(root) {
157
+ let base = runsDir(root);
158
+ let entries = await readdir(base, { withFileTypes: true }).catch(() => null);
159
+ if (entries === null) {
160
+ base = legacyRunsDir(root);
161
+ entries = await readdir(base, { withFileTypes: true }).catch(() => []);
162
+ }
163
+ const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name));
164
+
165
+ const records = [];
166
+ const warnings = [];
167
+ const readOne = async (file) => {
168
+ let text;
169
+ try {
170
+ text = await readFile(file, 'utf8');
171
+ } catch (error) {
172
+ warnings.push(`could not read ${file}: ${error.message ?? error}`);
173
+ return;
174
+ }
175
+ try {
176
+ const record = JSON.parse(text);
177
+ if (!record || typeof record !== 'object' || Array.isArray(record)) {
178
+ warnings.push(`skipped ${file}: not a record object`);
179
+ return;
180
+ }
181
+ records.push(record);
182
+ } catch (error) {
183
+ warnings.push(`skipped ${file}: ${error.message ?? error}`);
184
+ }
185
+ };
186
+
187
+ for (const entry of sorted) {
188
+ if (entry.name.startsWith('.')) continue;
189
+ const full = join(base, entry.name);
190
+ if (entry.isDirectory()) {
191
+ const files = (await readdir(full).catch(() => []))
192
+ .filter((file) => file.endsWith('.json') && !file.startsWith('.'))
193
+ .sort();
194
+ for (const file of files) await readOne(join(full, file));
195
+ } else if (entry.isFile() && entry.name.endsWith('.json')) {
196
+ await readOne(full);
197
+ }
198
+ }
199
+ return { records, warnings };
200
+ }
201
+
202
+ /**
203
+ * Every stored record. A caller that has nowhere to put a warning gets the records and
204
+ * no warnings; the report is the caller that shows them.
205
+ *
206
+ * @param {string} root
207
+ * @returns {Promise<object[]>}
208
+ */
209
+ export async function loadRunRecords(root) {
210
+ return (await readRunRecords(root)).records;
211
+ }