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