ruvnet-brain 4.0.4 → 4.0.5

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.
@@ -0,0 +1,459 @@
1
+ import { createHash } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { spawnSync } from 'node:child_process';
6
+ import {
7
+ INVARIANT,
8
+ LOAD_BEARING,
9
+ MUTANT_RESULT_FILES,
10
+ PORTFOLIO_RESULT_FILES,
11
+ RESULT_FILE,
12
+ ROOT,
13
+ TRAP,
14
+ VERDICT,
15
+ aggregate,
16
+ } from './learning-replay-contract.mjs';
17
+
18
+ const KEEP_RUNS = 14;
19
+ const PROOF_SCHEMA = 2;
20
+ const PROOF_ROOT = path.join(ROOT, 'data', 'learning-replay-transcripts');
21
+ const sha256 = (value) => createHash('sha256').update(value).digest('hex');
22
+ const HOST_HOME_PATHS = [...new Set([os.homedir(), process.env.HOME].filter(Boolean))]
23
+ .sort((left, right) => right.length - left.length);
24
+ export function redactHostPaths(value) {
25
+ return HOST_HOME_PATHS.reduce(
26
+ (text, home) => text.split(home).join('$HOME'),
27
+ String(value || ''),
28
+ );
29
+ }
30
+ const remove = (target) => {
31
+ try { fs.rmSync(target, { recursive: true, force: true }); } catch { /* already gone */ }
32
+ };
33
+
34
+ export function headSha(repo = ROOT) {
35
+ const result = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' });
36
+ return result.status === 0 ? result.stdout.trim() : null;
37
+ }
38
+
39
+ export function checkSourceIdentity({ repo = ROOT, loadBearing = LOAD_BEARING } = {}) {
40
+ const sha = headSha(repo);
41
+ if (!sha) return { clean: false, sha: null, why: 'source is not a readable Git commit' };
42
+ const status = spawnSync('git', [
43
+ 'status', '--porcelain', '--untracked-files=all', '--', ...loadBearing,
44
+ ], { cwd: repo, encoding: 'utf8' });
45
+ if (status.status !== 0) {
46
+ return { clean: false, sha, why: `source identity check failed: ${status.stderr || status.status}` };
47
+ }
48
+ const dirty = status.stdout.split('\n').map((value) => value.trim()).filter(Boolean);
49
+ return dirty.length
50
+ ? { clean: false, sha, why: `load-bearing source has uncommitted changes: ${dirty.join(', ')}`, dirty }
51
+ : { clean: true, sha, why: `clean source commit ${sha}` };
52
+ }
53
+
54
+ function inside(parent, child) {
55
+ const relative = path.relative(path.resolve(parent), path.resolve(child));
56
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
57
+ }
58
+
59
+ function execRecord(execution) {
60
+ if (!execution) return null;
61
+ return {
62
+ ran: execution.ran === true,
63
+ argv: Array.isArray(execution.argv) ? execution.argv.map(redactHostPaths) : null,
64
+ exit: Number.isInteger(execution.exit) ? execution.exit : null,
65
+ exitOk: execution.exitOk === true,
66
+ retrieved: execution.retrieved === true,
67
+ why: redactHostPaths(execution.why).slice(0, 400),
68
+ output: redactHostPaths(execution.output).slice(0, 400),
69
+ };
70
+ }
71
+
72
+ function armRecord(arm, treated) {
73
+ if (!arm || typeof arm !== 'object') return null;
74
+ const record = {
75
+ class: arm.class || 'none',
76
+ subcommandCorrect: arm.subcommandCorrect === true,
77
+ command: redactHostPaths(arm.command).slice(0, 800),
78
+ exec: execRecord(arm.exec),
79
+ model: arm.modelUsed || arm.model || null,
80
+ };
81
+ if (treated) {
82
+ record.forcedCommand = arm.forcedCommand === true;
83
+ record.lessonIndex = Number.isInteger(arm.lessonIndex) ? arm.lessonIndex : -1;
84
+ record.firstToolIndex = Number.isInteger(arm.firstToolIndex) ? arm.firstToolIndex : -1;
85
+ record.lessonBeforeFirstToolCall = arm.lessonBeforeFirstToolCall === true;
86
+ } else {
87
+ record.lessonDelivered = arm.lessonDelivered === true;
88
+ }
89
+ return record;
90
+ }
91
+
92
+ function writeProof({ sourceSha, trap, mutant = null, run, arm, record, proofRoot = PROOF_ROOT }) {
93
+ const envelope = { schema: PROOF_SCHEMA, sourceSha, trap, mutant, run, arm, record };
94
+ const bytes = `${JSON.stringify(envelope, null, 2)}\n`;
95
+ const hash = sha256(bytes);
96
+ const file = path.join(proofRoot, sourceSha, `${hash}.json`);
97
+ fs.mkdirSync(path.dirname(file), { recursive: true });
98
+ if (!fs.existsSync(file)) fs.writeFileSync(file, bytes);
99
+ return { path: path.relative(ROOT, file), sha256: hash, sourceSha, mutant };
100
+ }
101
+
102
+ function sameJson(left, right) {
103
+ return JSON.stringify(left) === JSON.stringify(right);
104
+ }
105
+
106
+ function verifyProof({ ref, artifactArm, sourceSha, trap, mutant, run, arm, repo }) {
107
+ if (!ref || typeof ref !== 'object' || !ref.path || !ref.sha256 || !ref.sourceSha) {
108
+ return { ok: false, why: `${arm} transcript reference is incomplete` };
109
+ }
110
+ const file = path.resolve(repo, ref.path);
111
+ if (!inside(repo, file)) return { ok: false, why: `${arm} transcript escapes repository` };
112
+ if (!fs.existsSync(file)) return { ok: false, why: `${arm} transcript does not exist` };
113
+ const bytes = fs.readFileSync(file);
114
+ if (sha256(bytes) !== ref.sha256) return { ok: false, why: `${arm} transcript hash mismatch` };
115
+ let envelope;
116
+ try { envelope = JSON.parse(bytes.toString('utf8')); }
117
+ catch (error) { return { ok: false, why: `${arm} transcript is unparseable: ${error.message}` }; }
118
+ if (ref.sourceSha !== sourceSha || envelope.sourceSha !== sourceSha) {
119
+ return { ok: false, why: `${arm} transcript source identity mismatch` };
120
+ }
121
+ if ((ref.mutant ?? null) !== mutant || (envelope.mutant ?? null) !== mutant) {
122
+ return { ok: false, why: `${arm} transcript mutant identity mismatch` };
123
+ }
124
+ if (envelope.schema !== PROOF_SCHEMA || envelope.trap !== trap
125
+ || envelope.run !== run || envelope.arm !== arm || !envelope.record) {
126
+ return { ok: false, why: `${arm} transcript envelope identity mismatch` };
127
+ }
128
+ const duplicate = { ...artifactArm };
129
+ delete duplicate.transcript;
130
+ if (!sameJson(duplicate, envelope.record)) {
131
+ return { ok: false, why: `${arm} causal run data does not match hashed transcript` };
132
+ }
133
+ return { ok: true, record: envelope.record, file };
134
+ }
135
+
136
+ function oracleInput({ row, treated, control }) {
137
+ return {
138
+ i: row.i,
139
+ treatedClass: treated.class,
140
+ controlClass: control.class,
141
+ lessonBeforeFirstToolCall: treated.lessonBeforeFirstToolCall,
142
+ treatedSubcommandCorrect: treated.subcommandCorrect,
143
+ treatedExecOk: treated.exec?.exitOk === true,
144
+ treatedRetrieved: treated.exec?.retrieved === true,
145
+ treatedExecWhy: treated.exec?.why || null,
146
+ controlWorked: control.exec?.exitOk === true && control.exec?.retrieved === true,
147
+ error: row.error || null,
148
+ treated,
149
+ control,
150
+ };
151
+ }
152
+
153
+ function verifyRuns(artifact, repo) {
154
+ if (!Array.isArray(artifact.runs)) return { ok: false, why: 'runs are missing' };
155
+ const inputs = [];
156
+ for (let index = 0; index < artifact.runs.length; index++) {
157
+ const row = artifact.runs[index];
158
+ if (!row?.treated || !row?.control) return { ok: false, why: `run ${index + 1} lacks a treated or control arm` };
159
+ const run = Number.isInteger(row.i) ? row.i : index + 1;
160
+ const treated = verifyProof({
161
+ ref: row.treated.transcript,
162
+ artifactArm: row.treated,
163
+ sourceSha: artifact.sha,
164
+ trap: artifact.trap,
165
+ mutant: artifact.mutant || null,
166
+ run,
167
+ arm: 'treated',
168
+ repo,
169
+ });
170
+ if (!treated.ok) return treated;
171
+ const control = verifyProof({
172
+ ref: row.control.transcript,
173
+ artifactArm: row.control,
174
+ sourceSha: artifact.sha,
175
+ trap: artifact.trap,
176
+ mutant: artifact.mutant || null,
177
+ run,
178
+ arm: 'control',
179
+ repo,
180
+ });
181
+ if (!control.ok) return control;
182
+ inputs.push(oracleInput({ row, treated: treated.record, control: control.record }));
183
+ }
184
+ return { ok: true, inputs };
185
+ }
186
+
187
+ function aggregateMismatch(artifact, recomputed) {
188
+ const fields = [
189
+ 'verdict', 'n', 'passes', 'fails', 'unknowns', 'controlTokenRuns',
190
+ 'controlWorkedRuns', 'treatedTokenRuns', 'rate',
191
+ ];
192
+ for (const field of fields) {
193
+ if (artifact[field] !== recomputed[field]) return `${field} does not match recomputed evidence`;
194
+ }
195
+ const gates = ['treatedSubcommandRuns', 'treatedExecutedRuns', 'treatedRetrievedRuns'];
196
+ for (const field of gates) {
197
+ if (artifact.executionGate?.[field] !== recomputed[field]) {
198
+ return `executionGate.${field} does not match recomputed evidence`;
199
+ }
200
+ }
201
+ for (let index = 0; index < recomputed.runs.length; index++) {
202
+ const claimed = artifact.runs[index];
203
+ const actual = recomputed.runs[index];
204
+ if (claimed.verdict !== actual.verdict || claimed.why !== actual.why) {
205
+ return `run ${actual.i} verdict/reason does not match recomputed evidence`;
206
+ }
207
+ }
208
+ return null;
209
+ }
210
+
211
+ function currency(artifact, repo, maxAgeDays) {
212
+ if (!artifact.sha) return { ok: false, why: 'artifact states no SHA' };
213
+ const head = headSha(repo);
214
+ if (head && artifact.sha !== head) {
215
+ const ancestor = spawnSync('git', ['merge-base', '--is-ancestor', artifact.sha, head], { cwd: repo });
216
+ if (ancestor.status !== 0) return { ok: false, why: `artifact source ${artifact.sha} is not an ancestor of HEAD` };
217
+ const diff = spawnSync('git', [
218
+ 'diff', '--name-only', `${artifact.sha}..${head}`, '--', ...LOAD_BEARING,
219
+ ], { cwd: repo, encoding: 'utf8' });
220
+ const changed = diff.status === 0
221
+ ? diff.stdout.split('\n').map((value) => value.trim()).filter(Boolean)
222
+ : [];
223
+ if (changed.length) return { ok: false, why: `load-bearing files changed: ${changed.join(', ')}` };
224
+ }
225
+ const age = artifact.at ? (Date.now() - Date.parse(artifact.at)) / 86_400_000 : Infinity;
226
+ if (!(age <= maxAgeDays)) return { ok: false, why: `artifact is older than ${maxAgeDays} days` };
227
+ return { ok: true };
228
+ }
229
+
230
+ export function checkArtifact({ file = RESULT_FILE, repo = ROOT, maxAgeDays = 14 } = {}) {
231
+ if (!fs.existsSync(file)) return { status: VERDICT.UNKNOWN, why: 'result artifact does not exist' };
232
+ let artifact;
233
+ try { artifact = JSON.parse(fs.readFileSync(file, 'utf8')); }
234
+ catch (error) { return { status: VERDICT.UNKNOWN, why: `result artifact unparseable: ${error.message}` }; }
235
+ if (artifact.invariant !== INVARIANT) return { status: VERDICT.UNKNOWN, why: 'artifact invariant mismatch' };
236
+ const source = checkSourceIdentity({ repo });
237
+ if (!source.clean) return { status: VERDICT.UNKNOWN, why: source.why, artifact };
238
+ const current = currency(artifact, repo, maxAgeDays);
239
+ if (!current.ok) return { status: VERDICT.UNKNOWN, why: current.why, artifact };
240
+ const evidence = verifyRuns(artifact, repo);
241
+ if (!evidence.ok) return { status: VERDICT.UNKNOWN, why: evidence.why, artifact };
242
+ const recomputed = aggregate(evidence.inputs);
243
+ if (recomputed.verdict === VERDICT.UNKNOWN) {
244
+ return { status: VERDICT.UNKNOWN, why: recomputed.why, artifact, recomputed };
245
+ }
246
+ const mismatch = aggregateMismatch(artifact, recomputed);
247
+ if (mismatch) return { status: VERDICT.FAIL, why: mismatch, artifact, recomputed };
248
+ return {
249
+ status: recomputed.verdict,
250
+ why: `${recomputed.verdict} — ${recomputed.passes}/${recomputed.n} recomputed runs on ${artifact.sha.slice(0, 8)}`,
251
+ artifact,
252
+ recomputed,
253
+ };
254
+ }
255
+
256
+ function causalMutantCheck(artifact, recomputed, mutant) {
257
+ if (recomputed.unknowns !== 0) return 'mutant contains UNKNOWN runs';
258
+ if (recomputed.verdict !== VERDICT.FAIL || recomputed.passes !== 0 || recomputed.n < 1) {
259
+ return 'mutant did not fail with zero passes';
260
+ }
261
+ for (const run of recomputed.runs) {
262
+ if (run.treated?.exec?.ran !== true || run.control?.exec?.ran !== true) {
263
+ return 'mutant arms were not both executed';
264
+ }
265
+ if (run.treatedClass === 'none' || run.controlClass === 'none') return 'mutant arms are not comparable';
266
+ if (!/treated arm produced|lesson was NOT|RETRIEVED NOTHING|did not execute|WRONG SUBCOMMAND/i.test(run.why)) {
267
+ return `mutant failure reason is not causal: ${run.why}`;
268
+ }
269
+ }
270
+ const learned = recomputed.runs.some((run) => run.treated?.lessonBeforeFirstToolCall === true
271
+ || run.treated?.lessonDelivered === true || run.control?.lessonDelivered === true
272
+ || run.treatedClass === 'flagged' || run.controlClass === 'flagged'
273
+ || run.treated?.exec?.retrieved === true || run.control?.exec?.retrieved === true);
274
+ if (learned) return `${mutant}: learned effect survived`;
275
+ if (!['delete-lesson', 'brain-off-treated'].includes(mutant)) return 'unexpected mutant identity';
276
+ return null;
277
+ }
278
+
279
+ export function checkMutantArtifacts({
280
+ files = MUTANT_RESULT_FILES,
281
+ repo = ROOT,
282
+ maxAgeDays = 14,
283
+ } = {}) {
284
+ const checked = [];
285
+ for (const trap of [TRAP.MEMORY_SEARCH, TRAP.POST_TASK]) {
286
+ for (const mutant of ['delete-lesson', 'brain-off-treated']) {
287
+ const file = files[trap]?.[mutant];
288
+ if (!file) return { status: VERDICT.UNKNOWN, why: `${trap}/${mutant}: artifact absent`, checked };
289
+ const result = checkArtifact({ file, repo, maxAgeDays });
290
+ if (result.status === VERDICT.UNKNOWN) return { ...result, why: `${trap}/${mutant}: ${result.why}`, checked };
291
+ if (result.artifact?.mutant !== mutant || result.artifact?.trap !== trap) {
292
+ return { status: VERDICT.FAIL, why: `${trap}/${mutant}: artifact identity mismatch`, checked };
293
+ }
294
+ const causal = causalMutantCheck(result.artifact, result.recomputed, mutant);
295
+ if (causal) return { status: VERDICT.FAIL, why: `${trap}/${mutant}: ${causal}`, checked };
296
+ checked.push(`${trap}/${mutant}`);
297
+ }
298
+ }
299
+ return { status: VERDICT.PASS, why: 'all four mutants failed for the expected causal reason', checked };
300
+ }
301
+
302
+ export function checkPortfolio({
303
+ files = PORTFOLIO_RESULT_FILES,
304
+ mutantFiles = MUTANT_RESULT_FILES,
305
+ repo = ROOT,
306
+ maxAgeDays = 14,
307
+ } = {}) {
308
+ const artifacts = [];
309
+ for (const trap of [TRAP.MEMORY_SEARCH, TRAP.POST_TASK]) {
310
+ const result = checkArtifact({ file: files[trap], repo, maxAgeDays });
311
+ if (result.status !== VERDICT.PASS) return { ...result, why: `${trap}: ${result.why}`, artifacts };
312
+ const metrics = result.recomputed;
313
+ const artifact = result.artifact;
314
+ if (artifact.trap !== trap || metrics.n < 3 || metrics.passes < 2
315
+ || metrics.controlTokenRuns !== 0 || metrics.controlWorkedRuns !== 0) {
316
+ return { status: VERDICT.FAIL, why: `${trap}: recomputed evidence lacks N>=3 causal separation`, artifacts };
317
+ }
318
+ if (artifact.promotion?.projectCount < 2 || artifact.promotion?.promoted !== true
319
+ || new Set(artifact.promotion?.sourceProjects || []).size < 2) {
320
+ return { status: VERDICT.FAIL, why: `${trap}: cross-project promotion is unproven`, artifacts };
321
+ }
322
+ if (artifact.refresh?.lessonSurvived !== true) {
323
+ return { status: VERDICT.FAIL, why: `${trap}: lesson did not survive refresh`, artifacts };
324
+ }
325
+ artifacts.push({ artifact, recomputed: metrics });
326
+ }
327
+ if (artifacts[0].artifact.record?.lessonId === artifacts[1].artifact.record?.lessonId
328
+ || artifacts[0].artifact.taskHash === artifacts[1].artifact.taskHash) {
329
+ return { status: VERDICT.FAIL, why: 'portfolio traps are not independent', artifacts };
330
+ }
331
+ const mutants = checkMutantArtifacts({ files: mutantFiles, repo, maxAgeDays });
332
+ if (mutants.status !== VERDICT.PASS) return { ...mutants, why: `portfolio mutants: ${mutants.why}`, artifacts };
333
+ return { status: VERDICT.PASS, why: 'portfolio recomputed both traps and all causal mutants', artifacts, mutants };
334
+ }
335
+
336
+ export function cleanupFixtureDaemons(dirs) {
337
+ const base = path.resolve(dirs.base);
338
+ const processes = spawnSync('ps', ['-axo', 'pid=,command='], {
339
+ encoding: 'utf8',
340
+ timeout: 10_000,
341
+ });
342
+ if (processes.status !== 0) return { found: 0, stopped: 0, errors: ['process census failed'] };
343
+ const matches = String(processes.stdout || '').split('\n').flatMap((line) => {
344
+ const match = line.match(/^\s*(\d+)\s+(.+)$/);
345
+ if (!match) return [];
346
+ const pid = Number(match[1]);
347
+ const command = match[2];
348
+ return command.includes('daemon start --foreground') && command.includes('--workspace')
349
+ && command.includes(base) && pid !== process.pid ? [{ pid }] : [];
350
+ });
351
+ const errors = [];
352
+ let stopped = 0;
353
+ for (const match of matches) {
354
+ try { process.kill(match.pid, 'SIGTERM'); stopped++; }
355
+ catch (error) { if (error?.code !== 'ESRCH') errors.push(`${match.pid}: ${error.message}`); }
356
+ }
357
+ return { found: matches.length, stopped, errors };
358
+ }
359
+
360
+ function manifestFor(directory) {
361
+ try {
362
+ const manifest = JSON.parse(fs.readFileSync(path.join(directory, 'run-manifest.json'), 'utf8'));
363
+ const time = Date.parse(manifest.createdAt);
364
+ if (!Number.isFinite(time) || !Number.isFinite(Number(manifest.sequence))) return null;
365
+ return { directory, time, sequence: Number(manifest.sequence) };
366
+ } catch { return null; }
367
+ }
368
+
369
+ export function pruneArchive(dirs, { keepRuns = KEEP_RUNS, preservePaths = [] } = {}) {
370
+ cleanupFixtureDaemons(dirs);
371
+ for (const entry of fs.readdirSync(dirs.base, { withFileTypes: true })) {
372
+ if (['transcripts', 'run-manifest.json'].includes(entry.name)) continue;
373
+ remove(path.join(dirs.base, entry.name));
374
+ }
375
+ const root = path.dirname(dirs.base);
376
+ const protectedFiles = preservePaths.map((value) => path.resolve(value));
377
+ const runs = fs.readdirSync(root, { withFileTypes: true })
378
+ .filter((entry) => entry.isDirectory())
379
+ .map((entry) => manifestFor(path.join(root, entry.name)))
380
+ .filter(Boolean)
381
+ .sort((left, right) => left.time - right.time || left.sequence - right.sequence);
382
+ const candidates = runs.filter((run) => !protectedFiles.some((file) => inside(run.directory, file)));
383
+ const excess = Math.max(0, candidates.length - keepRuns);
384
+ for (const old of candidates.slice(0, excess)) remove(old.directory);
385
+ }
386
+
387
+ export function writeArtifact(file, aggregateResult, meta = {}) {
388
+ const sourceSha = headSha();
389
+ const trap = meta.trap || TRAP.MEMORY_SEARCH;
390
+ const mutant = meta.mutant || null;
391
+ const proofRoot = meta.proofRoot || PROOF_ROOT;
392
+ const runs = (aggregateResult.runs || []).map((run) => {
393
+ const treated = armRecord(run.treated, true);
394
+ const control = armRecord(run.control, false);
395
+ return {
396
+ i: run.i,
397
+ verdict: run.verdict,
398
+ why: run.why,
399
+ error: run.error || null,
400
+ treated: {
401
+ ...treated,
402
+ transcript: writeProof({ sourceSha, trap, mutant, run: run.i, arm: 'treated', record: treated, proofRoot }),
403
+ },
404
+ control: {
405
+ ...control,
406
+ transcript: writeProof({ sourceSha, trap, mutant, run: run.i, arm: 'control', record: control, proofRoot }),
407
+ },
408
+ };
409
+ });
410
+ const artifact = {
411
+ invariant: INVARIANT,
412
+ verdict: aggregateResult.verdict,
413
+ why: aggregateResult.why,
414
+ sha: sourceSha,
415
+ at: new Date().toISOString(),
416
+ host: meta.host || null,
417
+ model: meta.model || null,
418
+ modelResolved: runs.map((run) => run.treated.model).find(Boolean) || null,
419
+ mutant,
420
+ trap,
421
+ taskHash: meta.task ? sha256(meta.task) : null,
422
+ n: aggregateResult.n,
423
+ passes: aggregateResult.passes,
424
+ fails: aggregateResult.fails,
425
+ unknowns: aggregateResult.unknowns,
426
+ controlTokenRuns: aggregateResult.controlTokenRuns,
427
+ controlWorkedRuns: aggregateResult.controlWorkedRuns,
428
+ treatedTokenRuns: aggregateResult.treatedTokenRuns,
429
+ executionGate: {
430
+ treatedSubcommandRuns: aggregateResult.treatedSubcommandRuns,
431
+ treatedExecutedRuns: aggregateResult.treatedExecutedRuns,
432
+ treatedRetrievedRuns: aggregateResult.treatedRetrievedRuns,
433
+ },
434
+ rate: aggregateResult.rate,
435
+ threshold: '>=2/3',
436
+ costUsd: meta.costUsd != null ? +meta.costUsd.toFixed(4) : null,
437
+ wallSeconds: meta.wallMs != null ? +(meta.wallMs / 1000).toFixed(1) : null,
438
+ premise: meta.flag ? { verified: meta.flag.ok, evidence: meta.flag.evidence } : null,
439
+ record: meta.record ? {
440
+ lessonId: meta.record.lesson?.id,
441
+ key: meta.record.key,
442
+ storeExit: meta.record.storeExit,
443
+ readBackExit: meta.record.readBackExit,
444
+ ok: meta.record.ok,
445
+ } : null,
446
+ promotion: meta.record ? {
447
+ rule: 'ADR-G008 win twice',
448
+ projectCount: meta.record.projectCount,
449
+ sourceProjects: meta.record.sources?.map((source) => source.project) || [],
450
+ promoted: meta.record.promoted === true,
451
+ } : null,
452
+ seed: meta.seed ? { key: meta.seed.key, storeExit: meta.seed.storeExit, ok: meta.seed.ok } : null,
453
+ refresh: meta.refresh || null,
454
+ runs,
455
+ };
456
+ fs.mkdirSync(path.dirname(file), { recursive: true });
457
+ fs.writeFileSync(file, `${JSON.stringify(artifact, null, 2)}\n`);
458
+ return artifact;
459
+ }