atris 3.71.1 → 3.71.3

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,680 @@
1
+ // Campaign recursive self-improvement (rsi): distills mission traces into
2
+ // per-member reading gains, reward-per-dollar stats, and plain-language
3
+ // lessons that feed the next round's planner.
4
+
5
+ 'use strict';
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const { buildTrace } = require('./campaign-trace');
10
+ const { getCampaign, listCampaigns, openCampaignStore } = require('./campaign-store');
11
+ const rsiRecord = require('./rsi-record');
12
+ const { verifyCodeProvenance } = require('./mission-code-provenance');
13
+
14
+ function round(value, places = 4) {
15
+ const scale = 10 ** places;
16
+ return Math.round(value * scale) / scale;
17
+ }
18
+
19
+ function fmt(value) {
20
+ return typeof value === 'number' && Number.isFinite(value) ? String(round(value, 4)) : 'n/a';
21
+ }
22
+
23
+ function clip(text, max = 90) {
24
+ const value = String(text || '').replace(/\u2014/g, ', ').replace(/\s+/g, ' ').trim();
25
+ return value.length > max ? `${value.slice(0, max - 3)}...` : value;
26
+ }
27
+
28
+ function dedupeLessons(lines) {
29
+ const seen = new Set();
30
+ const out = [];
31
+ for (const raw of lines || []) {
32
+ const line = String(raw || '').replace(/\u2014/g, ', ').trim();
33
+ if (!line.startsWith('- ')) continue;
34
+ const key = line.toLowerCase();
35
+ if (seen.has(key)) continue;
36
+ seen.add(key);
37
+ out.push(line);
38
+ }
39
+ return out;
40
+ }
41
+
42
+ function aggregateMembers(missions) {
43
+ const byMember = new Map();
44
+ for (const m of missions) {
45
+ const name = m.member || 'unknown';
46
+ if (!byMember.has(name)) {
47
+ byMember.set(name, {
48
+ member: name,
49
+ missions: 0,
50
+ kept: 0,
51
+ failed: 0,
52
+ reverted: 0,
53
+ judge_passed: 0,
54
+ repeat_failures: 0,
55
+ failure_streak: 0,
56
+ reward: 0,
57
+ cost_usd: 0,
58
+ wasted_cost_usd: 0,
59
+ reward_per_dollar: null,
60
+ zero_reward_kept: 0,
61
+ });
62
+ }
63
+ const entry = byMember.get(name);
64
+ entry.missions += 1;
65
+ const cost = typeof m.cost_usd === 'number' && Number.isFinite(m.cost_usd) ? m.cost_usd : 0;
66
+ entry.cost_usd = round(entry.cost_usd + cost, 4);
67
+ if (m.outcome === 'kept') {
68
+ entry.kept += 1;
69
+ entry.failure_streak = 0;
70
+ } else if (m.outcome) {
71
+ entry.failed += 1;
72
+ if (m.outcome === 'reverted') entry.reverted += 1;
73
+ entry.failure_streak += 1;
74
+ if (entry.failure_streak >= 2) entry.repeat_failures += 1;
75
+ entry.wasted_cost_usd = round(entry.wasted_cost_usd + cost, 4);
76
+ }
77
+ if (typeof m.reward === 'number' && Number.isFinite(m.reward)) {
78
+ entry.reward = round(entry.reward + m.reward, 4);
79
+ if (m.reward > 0) entry.judge_passed += 1;
80
+ } else if (m.reading_after === 1 && m.outcome === 'kept') {
81
+ entry.judge_passed += 1;
82
+ }
83
+ if (m.outcome === 'kept' && m.reward !== null && m.reward <= 0) {
84
+ entry.zero_reward_kept += 1;
85
+ }
86
+ }
87
+ const members = [...byMember.values()].map((entry) => {
88
+ const { failure_streak, ...clean } = entry;
89
+ const ovr = computeMemberOverall(clean);
90
+ return {
91
+ ...clean,
92
+ reward_per_dollar: clean.cost_usd > 0 ? round(clean.reward / clean.cost_usd, 4) : null,
93
+ overall: ovr.overall,
94
+ status: ovr.status,
95
+ note: ovr.note,
96
+ };
97
+ });
98
+ members.sort((a, b) => ((b.overall || 0) - (a.overall || 0)) || (b.reward - a.reward) || (b.kept - a.kept) || a.member.localeCompare(b.member));
99
+ return members;
100
+ }
101
+
102
+ // Compute a deterministic 0-99 overall rating from four observable signals:
103
+ // follow-through, result quality, learning after a miss, and retry cost discipline.
104
+ function computeMemberOverall(stats = {}) {
105
+ const missions = Number(stats.missions || 0);
106
+ if (missions <= 0) {
107
+ return { overall: null, status: 'unproven', note: 'no missions yet' };
108
+ }
109
+ const kept = Number(stats.kept || 0);
110
+ const reverted = Number(stats.reverted || 0);
111
+ const judgePassed = Number(
112
+ stats.judgePassed !== undefined
113
+ ? stats.judgePassed
114
+ : stats.judge_passed !== undefined
115
+ ? stats.judge_passed
116
+ : 0
117
+ );
118
+ const repeatFailures = Number(
119
+ stats.repeatFailures !== undefined
120
+ ? stats.repeatFailures
121
+ : stats.repeat_failures !== undefined
122
+ ? stats.repeat_failures
123
+ : Math.max(0, missions - kept - 1)
124
+ );
125
+ const wastedCostUsd = Number(
126
+ stats.wastedCostUsd !== undefined
127
+ ? stats.wastedCostUsd
128
+ : stats.wasted_cost_usd !== undefined
129
+ ? stats.wasted_cost_usd
130
+ : 0
131
+ );
132
+
133
+ const keptRatio = kept / missions;
134
+ const followThrough = keptRatio * 24 + Math.min(6, kept * 1.2);
135
+ const qualityRatio = Math.min(1, (judgePassed / Math.max(1, missions)) * 1.2 + keptRatio * 0.25);
136
+ const quality = qualityRatio * 20;
137
+ const learning = Math.max(-12, 12 - repeatFailures * 1.4 - reverted * 2.5);
138
+ const costDiscipline = Math.max(-10, 8 - wastedCostUsd * 0.9);
139
+
140
+ let score = Math.round(35 + followThrough + quality + learning + costDiscipline);
141
+ score = Math.max(40, Math.min(99, score));
142
+
143
+ // Proof integrity caps: unproven volume cannot cross 89; repeat misses cannot cross 84.
144
+ if (kept === 0) score = Math.min(score, 58);
145
+ else if (kept < 3) score = Math.min(score, 89);
146
+ else if (kept < 5 || repeatFailures > 0 || wastedCostUsd > 0) score = Math.min(score, 96);
147
+ if (repeatFailures >= 5) score = Math.min(score, 68);
148
+ // A completed job is follow-through. It is not evidence of improvement
149
+ // until an outside judge measures a gain.
150
+ if (judgePassed <= 0) score = Math.min(score, 75);
151
+
152
+ let note = `${kept}/${missions} kept`;
153
+ if (repeatFailures >= 2) {
154
+ note = `${repeatFailures} repeat misses`;
155
+ } else if (wastedCostUsd >= 2) {
156
+ note = `$${wastedCostUsd.toFixed(2)} wasted on failed runs`;
157
+ } else if (kept < 3) {
158
+ note = `early form (${kept}/${missions} kept)`;
159
+ } else if (kept === missions && judgePassed > 0) {
160
+ note = 'clean first-try wins';
161
+ } else if (keptRatio >= 0.8) {
162
+ note = 'steady follow-through';
163
+ }
164
+ if (kept > 0 && judgePassed <= 0) note = 'no judge progress yet';
165
+
166
+ return {
167
+ overall: score,
168
+ status: judgePassed <= 0 ? 'unproven' : kept < 3 ? 'proving' : score >= 90 ? 'elite' : score >= 75 ? 'solid' : 'thrashing',
169
+ note,
170
+ };
171
+ }
172
+
173
+ function summarizeTrace(events) {
174
+ const trace = buildTrace(Array.isArray(events) ? events : [], { redact: false });
175
+ if (trace.error) return { error: trace.error };
176
+
177
+ const campaign_id = trace.header.campaign_id;
178
+ const title = trace.header.title || trace.header.campaign_id;
179
+ const state = trace.header.final.state;
180
+ const reading = trace.header.final.reading;
181
+ const spent_usd = Number((trace.header.final.budget && trace.header.final.budget.spent) || 0);
182
+
183
+ const missions = trace.rows.map((row) => ({
184
+ campaign_id: row.campaign_id,
185
+ mission_id: row.action.mission_id,
186
+ member: row.action.member || 'unknown',
187
+ engine: row.action.engine || null,
188
+ execution_engines: row.execution_engines,
189
+ receipt_path: row.receipt_path,
190
+ started_at: row.started_at,
191
+ finished_at: row.at,
192
+ objective: row.action.objective || null,
193
+ outcome: row.outcome || null,
194
+ cost_usd: typeof row.cost_usd === 'number' ? row.cost_usd : 0,
195
+ reading_before: row.reading_before,
196
+ reading_after: row.reading_after,
197
+ reward: typeof row.reward === 'number' ? row.reward : null,
198
+ }));
199
+
200
+ const total_reward = round(
201
+ missions.reduce((sum, m) => sum + (typeof m.reward === 'number' ? m.reward : 0), 0),
202
+ 4
203
+ );
204
+ const reward_per_dollar = spent_usd > 0 ? round(total_reward / spent_usd, 4) : null;
205
+ const members = aggregateMembers(missions);
206
+
207
+ const rawLessons = [];
208
+ for (const m of missions) {
209
+ if (typeof m.reward === 'number' && m.reward > 0) {
210
+ rawLessons.push(
211
+ `- ${m.member} gained +${round(m.reward, 4)} reading (${fmt(m.reading_before)} to ${fmt(m.reading_after)}) for $${m.cost_usd.toFixed(2)} on ${m.campaign_id}: ${clip(m.objective || m.mission_id, 90)}`
212
+ );
213
+ }
214
+ if (m.outcome === 'kept' && m.reward !== null && m.reward <= 0) {
215
+ rawLessons.push(
216
+ `- ${m.member} finished ${m.mission_id} on ${m.campaign_id} with ${m.reward === 0 ? 'no reading gain' : `reading change ${round(m.reward, 4)}`}; tie verify directly to a failing judge check`
217
+ );
218
+ }
219
+ if (m.outcome && m.outcome !== 'kept') {
220
+ rawLessons.push(
221
+ `- ${m.member} had ${m.mission_id} end ${m.outcome} on ${m.campaign_id}; keep scope to one checkable slice`
222
+ );
223
+ }
224
+ }
225
+
226
+ const top = members.find((m) => m.reward > 0);
227
+ if (top) {
228
+ rawLessons.push(
229
+ `- top member by reading gain: ${top.member} (+${round(top.reward, 4)} reading across ${top.kept}/${top.missions} kept, $${top.cost_usd.toFixed(2)} spent)`
230
+ );
231
+ }
232
+
233
+ return {
234
+ campaign_id,
235
+ title,
236
+ state,
237
+ reading,
238
+ spent_usd,
239
+ total_reward,
240
+ reward_per_dollar,
241
+ missions,
242
+ members,
243
+ lessons: dedupeLessons(rawLessons),
244
+ };
245
+ }
246
+
247
+ function summarizeStoreRsi(store, campaignId = null) {
248
+ const resolvedStore = typeof store === 'string' ? openCampaignStore(store) : store;
249
+ const views = campaignId
250
+ ? [getCampaign(resolvedStore, campaignId)].filter(Boolean)
251
+ : listCampaigns(resolvedStore);
252
+
253
+ const summaries = [];
254
+ for (const v of views) {
255
+ if (!v || v.error || !v.id) continue;
256
+ const events = resolvedStore.read(v.id);
257
+ if (!events || !events.length) continue;
258
+ const summary = summarizeTrace(events);
259
+ if (!summary.error) summaries.push(summary);
260
+ }
261
+
262
+ const campaigns_count = summaries.length;
263
+ const completed_count = summaries.filter((s) => s.state === 'done').length;
264
+ const total_reward = round(summaries.reduce((sum, s) => sum + s.total_reward, 0), 4);
265
+ const total_spent_usd = round(summaries.reduce((sum, s) => sum + s.spent_usd, 0), 4);
266
+ const reward_per_dollar = total_spent_usd > 0 ? round(total_reward / total_spent_usd, 4) : null;
267
+ const allMissions = summaries.flatMap((s) => s.missions);
268
+ const mergedMembers = aggregateMembers(allMissions);
269
+ const dedupedLessons = dedupeLessons(summaries.flatMap((s) => s.lessons));
270
+
271
+ return {
272
+ campaigns_count,
273
+ completed_count,
274
+ total_reward,
275
+ total_spent_usd,
276
+ reward_per_dollar,
277
+ campaigns: summaries,
278
+ members: mergedMembers,
279
+ missions: allMissions,
280
+ lessons: dedupedLessons,
281
+ };
282
+ }
283
+
284
+ function appendDedupeLessons(filePath, headerText, lessons) {
285
+ const clean = dedupeLessons(lessons);
286
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
287
+ if (!fs.existsSync(filePath)) {
288
+ fs.writeFileSync(filePath, headerText);
289
+ }
290
+ if (!clean.length) return;
291
+ const existing = fs.readFileSync(filePath, 'utf8');
292
+ const known = new Set(
293
+ existing
294
+ .split('\n')
295
+ .map((line) => line.trim())
296
+ .filter((line) => line.startsWith('- '))
297
+ .map((line) => line.toLowerCase())
298
+ );
299
+ const fresh = clean.filter((line) => !known.has(line.toLowerCase()));
300
+ if (!fresh.length) return;
301
+ const prefix = existing.endsWith('\n') ? '' : '\n';
302
+ fs.appendFileSync(filePath, `${prefix}${fresh.join('\n')}\n`);
303
+ }
304
+
305
+ // These rows feed code-revision learning, which treats a file list as complete.
306
+ // A campaign's measured gain alone says nothing about files or the runner that
307
+ // actually executed. Keep those gains in the campaign view when proof is absent.
308
+ function campaignCodeEvidence(root, mission) {
309
+ const engines = mission.execution_engines;
310
+ const supported = new Set(['codex', 'devin', 'cursor', 'agy', 'claude']);
311
+ if (!Array.isArray(engines) || !engines.length || !engines.every((engine) => supported.has(engine))) return null;
312
+ if (new Set(engines).size !== 1) return null;
313
+ const receiptPath = mission.receipt_path;
314
+ if (typeof receiptPath !== 'string' || !receiptPath) return null;
315
+ try {
316
+ const runs = fs.realpathSync(path.join(root, 'atris', 'runs'));
317
+ const file = fs.realpathSync(path.resolve(root, receiptPath));
318
+ if (!file.startsWith(runs + path.sep)) return null;
319
+ const receipt = JSON.parse(fs.readFileSync(file, 'utf8'));
320
+ if (receipt.schema !== 'atris.mission_receipt.v1' || receipt.mission_id !== mission.mission_id) return null;
321
+ const proof = receipt.code_provenance;
322
+ if (!verifyCodeProvenance(root, proof).ok || !proof.files.length) return null;
323
+ const opened = Date.parse(mission.started_at);
324
+ const finished = Date.parse(receipt.at);
325
+ const ended = Date.parse(mission.finished_at);
326
+ if (![opened, finished, ended].every(Number.isFinite) || finished < opened || finished > ended) return null;
327
+ return {
328
+ engine: engines[0], files: proof.files, baseCommit: proof.base_commit,
329
+ openedAt: mission.started_at, finishedAt: receipt.at,
330
+ };
331
+ } catch {
332
+ return null;
333
+ }
334
+ }
335
+
336
+ function syncCampaignAttempts(root, rsi) {
337
+ try {
338
+ const nodes = rsiRecord.latestNodes(root).filter((n) => n && n.lane === 'campaign');
339
+ const unfinished = new Map(nodes
340
+ .filter((n) => n.outcome && n.outcome.status === 'running' && n.context && n.context.ground)
341
+ .map((n) => [n.context.ground, n]));
342
+ const existing = new Map(
343
+ nodes
344
+ .filter((n) => n.outcome && n.outcome.status !== 'running' && n.context && n.context.ground)
345
+ .map((n) => [n.context.ground, n])
346
+ );
347
+ for (const c of rsi.campaigns || []) {
348
+ for (const m of c.missions || []) {
349
+ if (m.outcome !== 'kept' && m.outcome !== 'reverted') continue;
350
+ // The before snapshot runs while the next judge is still due. Wait
351
+ // for its measured reward so deduplication cannot freeze skipped proof.
352
+ if (typeof m.reward !== 'number' || !Number.isFinite(m.reward)) continue;
353
+ const evidence = campaignCodeEvidence(root, m);
354
+ if (!evidence) continue;
355
+ const ground = `${c.campaign_id}/${m.mission_id}`;
356
+ const recorded = existing.get(ground);
357
+ if (recorded && recorded.outcome.verify !== 'skipped') continue;
358
+ const judge =
359
+ m.reading_after === 1 || (m.reward !== null && m.reward > 0)
360
+ ? true
361
+ : m.outcome === 'reverted' && m.reading_after !== null
362
+ ? false
363
+ : null;
364
+ const files = evidence.files;
365
+ const rewardTag = m.reward !== null ? ` (reward ${m.reward >= 0 ? '+' : ''}${m.reward})` : '';
366
+ const reason = `${m.member} on ${c.campaign_id}: ${String(m.objective || '').slice(0, 60)}${rewardTag}`;
367
+ // Keep this mapping here: older installed recorders expose only the
368
+ // open/finish protocol, and mapping must succeed before opening a node.
369
+ const outcome = {
370
+ status: m.outcome === 'kept' ? 'shipped' : files.length ? 'failed' : 'nothing',
371
+ verify: judge === true ? 'pass' : judge === false ? 'fail' : 'skipped',
372
+ reason,
373
+ };
374
+ // Older snapshots finished before the judge ran. Refresh only when
375
+ // the judge supplies proof, keeping the original attempt identity.
376
+ if (recorded && outcome.verify === 'skipped') continue;
377
+ const pending = recorded || unfinished.get(ground);
378
+ if (pending && (!pending.action || pending.action.engine !== evidence.engine || pending.action.policy_id !== 'p-0000')) continue;
379
+ // This is observed history, not a policy choice made now. Record the
380
+ // real code completion time so later human corrections remain visible.
381
+ const node = rsiRecord.recordObservedAttempt(root, {
382
+ lane: 'campaign', ground, ...evidence, ...(pending ? { nodeId: pending.id } : {}),
383
+ outcome: { ...outcome, files, cost_usd: typeof m.cost_usd === 'number' ? m.cost_usd : null },
384
+ });
385
+ if (node) existing.set(ground, { ...pending, id: node, outcome });
386
+ }
387
+ }
388
+ } catch {
389
+ // Best-effort ledger sync
390
+ }
391
+ }
392
+
393
+ function writeRsiSnapshot(root, store, campaignId = null) {
394
+ const activeStore = store || openCampaignStore(root);
395
+ const rsi = summarizeStoreRsi(activeStore, campaignId);
396
+
397
+ const stateFile = path.join(root, '.atris', 'state', 'campaign-rsi.json');
398
+ fs.mkdirSync(path.dirname(stateFile), { recursive: true });
399
+ fs.writeFileSync(stateFile, JSON.stringify({ updated_at: new Date().toISOString(), ...rsi }, null, 2) + '\n');
400
+
401
+ const globalLessonsFile = path.join(root, 'atris', 'campaigns', 'lessons.md');
402
+ appendDedupeLessons(
403
+ globalLessonsFile,
404
+ '# Campaign RSI Lessons\n\nDistilled from campaign traces across rounds.\n',
405
+ rsi.lessons
406
+ );
407
+
408
+ for (const c of rsi.campaigns) {
409
+ if (!campaignId && (!c.lessons || !c.lessons.length)) continue;
410
+ const cid = c.campaign_id;
411
+ const campaignLessonsFile = path.join(root, 'atris', 'campaigns', cid, 'lessons.md');
412
+ appendDedupeLessons(
413
+ campaignLessonsFile,
414
+ `# Lessons: ${c.title}\n\nDistilled from campaign traces across rounds.\n`,
415
+ c.lessons
416
+ );
417
+ const progressFile = path.join(root, 'atris', 'campaigns', cid, 'progress.md');
418
+ const kept = (c.missions || []).filter((m) => m.outcome === 'kept').length;
419
+ const failed = (c.missions || []).filter((m) => m.outcome && m.outcome !== 'kept').length;
420
+ const progressLines = [
421
+ `# Progress: ${c.title}`,
422
+ '',
423
+ `- state: ${c.state} (reading ${c.reading !== null && c.reading !== undefined ? c.reading : 'n/a'})`,
424
+ `- tried: ${c.missions.length} mission${c.missions.length === 1 ? '' : 's'} (${kept} kept, ${failed} failed)`,
425
+ `- members: ${(c.members || []).map((m) => `${m.member} ${m.overall !== null ? `${m.overall} ovr` : 'unproven'}`).join(', ') || 'none yet'}`,
426
+ '',
427
+ '## Learned',
428
+ ...((c.lessons && c.lessons.length) ? c.lessons : ['- none yet']),
429
+ '',
430
+ ];
431
+ fs.writeFileSync(progressFile, progressLines.join('\n'));
432
+ }
433
+
434
+ const scoreboardFile = path.join(root, 'atris', 'campaigns', 'scoreboard.md');
435
+ fs.mkdirSync(path.dirname(scoreboardFile), { recursive: true });
436
+ const scoreboardLines = [
437
+ '# Campaign Scoreboard',
438
+ '',
439
+ `- campaigns: ${rsi.campaigns_count} (${rsi.completed_count} done)`,
440
+ `- total reading gain: +${rsi.total_reward}`,
441
+ `- total spent: $${rsi.total_spent_usd.toFixed(2)}`,
442
+ '',
443
+ '## Member Overall Ratings',
444
+ ...((rsi.members && rsi.members.length)
445
+ ? rsi.members.map((m) => `- ${m.member}: ${m.overall !== null ? `${m.overall} ovr` : 'unproven'} (${m.status || 'proving'}) · ${m.kept}/${m.missions} kept · +${m.reward} reading · $${m.cost_usd.toFixed(2)} · ${m.note || ''}`)
446
+ : ['- none yet']),
447
+ '',
448
+ '## Latest Lessons',
449
+ ...((rsi.lessons && rsi.lessons.length) ? rsi.lessons.slice(-10) : ['- none yet']),
450
+ '',
451
+ ];
452
+ fs.writeFileSync(scoreboardFile, scoreboardLines.join('\n'));
453
+
454
+ const membersDir = path.join(root, 'atris', 'campaigns', 'members');
455
+ fs.mkdirSync(membersDir, { recursive: true });
456
+ for (const m of rsi.members || []) {
457
+ if (!m || !m.member) continue;
458
+ const memberFile = path.join(membersDir, `${m.member}.md`);
459
+ const memberMissions = (rsi.missions || []).filter((row) => row && row.member === m.member);
460
+ const memberLessons = (rsi.lessons || []).filter((line) => line.toLowerCase().includes(m.member.toLowerCase()));
461
+ const cardLines = [
462
+ `# Member Card: ${m.member}`,
463
+ '',
464
+ `- overall: ${m.overall !== null ? `${m.overall} ovr` : 'unproven'} (${m.status || 'proving'})`,
465
+ `- form: ${m.note || 'unproven'}`,
466
+ `- record: ${m.kept}/${m.missions} kept (${m.failed || 0} failed, ${m.repeat_failures || 0} repeat misses)`,
467
+ `- reading gain: +${m.reward}`,
468
+ `- cost: $${m.cost_usd.toFixed(2)} ($${(m.wasted_cost_usd || 0).toFixed(2)} on failed runs)`,
469
+ '',
470
+ '## Recent Missions',
471
+ ...(memberMissions.length
472
+ ? memberMissions.slice(-8).map((row) => `- ${row.mission_id}: ${row.outcome || row.status}${typeof row.reward === 'number' ? ` · reward ${row.reward >= 0 ? '+' : ''}${row.reward}` : ''}`)
473
+ : ['- none yet']),
474
+ '',
475
+ '## Lessons',
476
+ ...(memberLessons.length ? memberLessons.slice(-6) : ['- none yet']),
477
+ '',
478
+ ];
479
+ fs.writeFileSync(memberFile, cardLines.join('\n'));
480
+ }
481
+
482
+ syncCampaignAttempts(root, rsi);
483
+
484
+ return rsi;
485
+ }
486
+
487
+ function formatRsiReport(rsi) {
488
+ const lines = [
489
+ `campaign rsi: ${rsi.campaigns_count} campaign${rsi.campaigns_count === 1 ? '' : 's'} (${rsi.completed_count} done) · +${rsi.total_reward} reading · $${rsi.total_spent_usd.toFixed(2)} spent`,
490
+ 'members:',
491
+ ];
492
+ if (rsi.members && rsi.members.length) {
493
+ for (const m of rsi.members) {
494
+ const ovrText = typeof m.overall === 'number' ? `${m.overall} ovr` : 'unproven';
495
+ const noteText = m.note ? ` · ${m.note}` : '';
496
+ lines.push(
497
+ ` ${m.member.padEnd(16)} ${ovrText} · +${m.reward} reading · ${m.kept}/${m.missions} kept · $${m.cost_usd.toFixed(2)}${noteText}`
498
+ );
499
+ }
500
+ } else {
501
+ lines.push(' none yet');
502
+ }
503
+ lines.push('lessons:');
504
+ const topLessons = (rsi.lessons || []).slice(-8);
505
+ if (topLessons.length) {
506
+ for (const lesson of topLessons) {
507
+ lines.push(` ${lesson}`);
508
+ }
509
+ } else {
510
+ lines.push(' - none yet');
511
+ }
512
+ return lines.join('\n');
513
+ }
514
+
515
+ function missionRewardMap(events) {
516
+ const map = new Map();
517
+ if (!events) return map;
518
+ const summary = Array.isArray(events) ? summarizeTrace(events) : events;
519
+ if (!summary || summary.error || !Array.isArray(summary.missions)) return map;
520
+ for (const m of summary.missions) {
521
+ map.set(m.mission_id, {
522
+ reward: m.reward,
523
+ reading_before: m.reading_before,
524
+ reading_after: m.reading_after,
525
+ });
526
+ }
527
+ return map;
528
+ }
529
+
530
+ function selectNextCampaigns(store, rsiSummary = null) {
531
+ const { listCampaigns } = require('./campaign-store');
532
+ const { rankCampaignMembers } = require('./campaign-loop');
533
+ const rsi = rsiSummary || summarizeStoreRsi(store);
534
+ const topStoreMember = (rsi.members && rsi.members[0] && rsi.members[0].member) || 'executor';
535
+ const activeStates = new Set(['queued', 'running', 'resting']);
536
+ const open = listCampaigns(store).filter((v) => v && !v.error && activeStates.has(v.state));
537
+ const rows = open.map((v) => {
538
+ const trace = summarizeTrace(store.read(v.id));
539
+ const reading = typeof trace.reading === 'number'
540
+ ? trace.reading
541
+ : (v.last_judge && typeof v.last_judge.reading === 'number' ? v.last_judge.reading : 0);
542
+ const ranked = rankCampaignMembers(v, rsi);
543
+ const benched = ranked.filter((r) => r.benched).map((r) => r.member);
544
+ const warm = ranked.find((r) => !r.benched) || ranked[0] || { member: v.owner, overall: 75 };
545
+ const failedCount = (v.missions || []).filter((m) => m.status === 'ended' && m.outcome !== 'kept').length;
546
+ const recommendation = warm.overall < 65
547
+ ? `add seat ${topStoreMember} (top ovr) to replace cold lineup (${ranked.map((r) => `${r.member} ${r.overall} ovr`).join(', ')})`
548
+ : benched.length
549
+ ? `bench ${benched.join(', ')}; route next mission to ${warm.member} (${warm.overall} ovr)`
550
+ : `run next round with ${warm.member} (${warm.overall} ovr)`;
551
+ return {
552
+ id: v.id,
553
+ title: v.title,
554
+ state: v.state,
555
+ reading: Math.round(reading * 10000) / 10000,
556
+ failed_missions: failedCount,
557
+ lead_member: warm.member,
558
+ lead_overall: warm.overall,
559
+ benched_members: benched,
560
+ recommendation,
561
+ };
562
+ });
563
+ return rows.sort((a, b) => {
564
+ if (b.reading !== a.reading) return b.reading - a.reading;
565
+ return a.failed_missions - b.failed_missions;
566
+ });
567
+ }
568
+
569
+ function formatNextCampaigns(list) {
570
+ if (!Array.isArray(list) || !list.length) {
571
+ return 'next campaigns: none open (all campaigns done or paused)';
572
+ }
573
+ const lines = [`next campaigns: ${list.length} open`];
574
+ for (const item of list) {
575
+ lines.push(` ${item.id} (${item.state} · reading ${item.reading}) -> ${item.recommendation}`);
576
+ }
577
+ return lines.join('\n');
578
+ }
579
+
580
+ async function runOvernightRsi(root, options = {}) {
581
+ const { openCampaignStore } = require('./campaign-store');
582
+ const store = options.store || openCampaignStore(root);
583
+ const rounds = options.rounds === undefined ? 1 : Number(options.rounds);
584
+ const dryRun = Boolean(options.dryRun);
585
+ const stepFn = typeof options.stepCampaign === 'function' ? options.stepCampaign : null;
586
+ if (!Number.isSafeInteger(rounds) || rounds < 1) throw new Error('rounds must be a positive whole number');
587
+ if (!dryRun && !stepFn) throw new Error('overnight rsi needs a campaign runner');
588
+ const rsiDir = path.join(root, '.atris', 'state', 'rsi');
589
+ fs.mkdirSync(rsiDir, { recursive: true });
590
+ const logFile = path.join(rsiDir, 'overnight.jsonl');
591
+ const cycles = [];
592
+ const visited = new Set();
593
+ const stopped = new Set();
594
+ for (let i = 1; i <= rounds; i += 1) {
595
+ const before = writeRsiSnapshot(root, store);
596
+ const queue = selectNextCampaigns(store, before)
597
+ .filter((item) => (!options.campaignId || item.id === options.campaignId) && !stopped.has(item.id));
598
+ if (!queue.length) break;
599
+ if (queue.every((item) => visited.has(item.id))) visited.clear();
600
+ const topStoreMember = (before.members && before.members[0] && before.members[0].member) || 'executor';
601
+ const top = queue.find((item) => !visited.has(item.id));
602
+ visited.add(top.id);
603
+ const autoSeated = [];
604
+ let step = null;
605
+ if (!dryRun) {
606
+ // The command calls this only after taking the selected campaign's lock.
607
+ const prepare = () => {
608
+ if (top.lead_overall < 65 && top.lead_member !== topStoreMember) {
609
+ store.append({
610
+ campaign_id: top.id,
611
+ type: 'seat_filled',
612
+ actor: 'auto-improver',
613
+ data: { member: topStoreMember, role: 'cli' },
614
+ });
615
+ autoSeated.push(`${top.id}:${topStoreMember}`);
616
+ }
617
+ };
618
+ try {
619
+ step = await stepFn(top.id, top, prepare);
620
+ if (!step || typeof step !== 'object') step = { ok: false, error: 'campaign runner returned no result' };
621
+ else step = { ok: true, ...step };
622
+ } catch (error) {
623
+ step = { ok: false, error: String(error.message || error) };
624
+ }
625
+ if (!step.ok || step.stop) stopped.add(top.id);
626
+ }
627
+ const after = writeRsiSnapshot(root, store);
628
+ const receipt = {
629
+ at: new Date().toISOString(),
630
+ cycle: i,
631
+ dry_run: dryRun,
632
+ campaigns_open: queue.length,
633
+ top_campaign: top ? top.id : null,
634
+ lead_member: top ? top.lead_member : null,
635
+ lead_overall: top ? top.lead_overall : null,
636
+ auto_seated: autoSeated,
637
+ recommendation: top ? top.recommendation : 'all campaigns done',
638
+ step,
639
+ total_reward: after.total_reward,
640
+ completed_count: after.completed_count,
641
+ };
642
+ fs.appendFileSync(logFile, `${JSON.stringify(receipt)}\n`);
643
+ cycles.push(receipt);
644
+ }
645
+ const finalRsi = summarizeStoreRsi(store);
646
+ return {
647
+ ok: cycles.every((cycle) => !cycle.step || cycle.step.ok),
648
+ rounds: cycles.length,
649
+ cycles,
650
+ next_campaigns: selectNextCampaigns(store, finalRsi),
651
+ rsi: finalRsi,
652
+ };
653
+ }
654
+
655
+ function formatOvernightRsi(result) {
656
+ const lines = [
657
+ `overnight rsi: ${result.rounds} cycle${result.rounds === 1 ? '' : 's'} · +${result.rsi.total_reward} reading · ${result.rsi.completed_count}/${result.rsi.campaigns_count} done`,
658
+ ];
659
+ for (const c of result.cycles) {
660
+ const outcome = c.dry_run ? 'preview' : !c.step || !c.step.ok
661
+ ? `failed: ${(c.step && (c.step.error || c.step.reason)) || 'no result'}`
662
+ : c.step.stop ? `stopped: ${c.step.reason}` : `did: ${c.step.did || 'campaign step'}`;
663
+ lines.push(` cycle ${c.cycle}: ${c.top_campaign || 'no open campaign'} · ${outcome}`);
664
+ }
665
+ lines.push(formatNextCampaigns(result.next_campaigns));
666
+ return lines.join('\n');
667
+ }
668
+
669
+ module.exports = {
670
+ computeMemberOverall,
671
+ formatNextCampaigns,
672
+ formatOvernightRsi,
673
+ formatRsiReport,
674
+ missionRewardMap,
675
+ runOvernightRsi,
676
+ selectNextCampaigns,
677
+ summarizeStoreRsi,
678
+ summarizeTrace,
679
+ writeRsiSnapshot,
680
+ };
@@ -56,4 +56,12 @@ function acquireRunLock(home, campaignId, { pid = process.pid, host = os.hostnam
56
56
  return { ok: false, holder: readHolder(file) };
57
57
  }
58
58
 
59
- module.exports = { acquireRunLock, lockPath };
59
+ // Who drives this campaign right now, or null. Same rules as acquireRunLock, nothing written.
60
+ function liveHolder(home, campaignId, { host = os.hostname(), isAlive = alive } = {}) {
61
+ const holder = readHolder(lockPath(home, campaignId));
62
+ if (!holder) return null;
63
+ if (holder.host !== host) return holder;
64
+ return isAlive(holder.pid) ? holder : null;
65
+ }
66
+
67
+ module.exports = { acquireRunLock, liveHolder, lockPath };