atris 3.70.0 → 3.71.1

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 (79) hide show
  1. package/README.md +18 -0
  2. package/atris/skills/README.md +1 -0
  3. package/atris/skills/campaigns/SKILL.md +81 -0
  4. package/atris/skills/meta/SKILL.md +52 -8
  5. package/ax +226 -42
  6. package/bin/atris.js +38 -8
  7. package/commands/autoland.js +176 -7
  8. package/commands/autopilot.js +71 -11
  9. package/commands/campaign.js +779 -0
  10. package/commands/compile.js +19 -8
  11. package/commands/doctor.js +58 -0
  12. package/commands/dream.js +18 -3
  13. package/commands/engine.js +147 -5
  14. package/commands/improve.js +119 -9
  15. package/commands/init.js +18 -1
  16. package/commands/integrations.js +17 -5
  17. package/commands/interview.js +15 -3
  18. package/commands/land.js +22 -6
  19. package/commands/link.js +1 -0
  20. package/commands/member.js +359 -94
  21. package/commands/mission.js +249 -36
  22. package/commands/report.js +42 -8
  23. package/commands/run.js +9 -2
  24. package/commands/scout.js +9 -1
  25. package/commands/task.js +71 -17
  26. package/commands/team.js +212 -4
  27. package/commands/unknowns.js +15 -1
  28. package/commands/verify.js +46 -0
  29. package/commands/worktree.js +108 -15
  30. package/commands/write.js +12 -1
  31. package/commands/youtube.js +17 -2
  32. package/lib/accept-verify-gate.js +1 -0
  33. package/lib/account-bound.js +13 -0
  34. package/lib/auto-accept-certified.js +1 -0
  35. package/lib/autoland.js +13 -4
  36. package/lib/campaign-cadence.js +131 -0
  37. package/lib/campaign-core.js +330 -0
  38. package/lib/campaign-holdout.js +65 -0
  39. package/lib/campaign-loop.js +246 -0
  40. package/lib/campaign-missions.js +237 -0
  41. package/lib/campaign-new.js +251 -0
  42. package/lib/campaign-protect.js +75 -0
  43. package/lib/campaign-reading.js +147 -0
  44. package/lib/campaign-run-lock.js +59 -0
  45. package/lib/campaign-schema.js +196 -0
  46. package/lib/campaign-store.js +380 -0
  47. package/lib/campaign-sync.js +302 -0
  48. package/lib/campaign-trace.js +300 -0
  49. package/lib/conductor-artifacts.js +15 -1
  50. package/lib/daily-log.js +11 -1
  51. package/lib/dispatch-scout.js +18 -0
  52. package/lib/engine-ask.js +33 -3
  53. package/lib/engine-registry.js +2 -0
  54. package/lib/engine-resource.js +518 -0
  55. package/lib/fleet.js +125 -16
  56. package/lib/init-commit.js +80 -0
  57. package/lib/intents.js +8 -0
  58. package/lib/known-commands.js +1 -1
  59. package/lib/land-green.js +58 -7
  60. package/lib/landing-proof.js +352 -0
  61. package/lib/member-autoland.js +395 -18
  62. package/lib/member-board.js +7 -1
  63. package/lib/member-map-fold.js +126 -0
  64. package/lib/member-map-inbox.js +80 -0
  65. package/lib/member-progress.js +48 -1
  66. package/lib/member-report.js +127 -8
  67. package/lib/member-self-next-job.js +97 -0
  68. package/lib/member-task-pick.js +41 -14
  69. package/lib/member-up.js +249 -16
  70. package/lib/roster-prep.js +25 -2
  71. package/lib/runner-command.js +5 -0
  72. package/lib/task-db.js +47 -11
  73. package/lib/task-explanation.js +7 -0
  74. package/lib/task-list-keeper.js +8 -1
  75. package/lib/task-receipt.js +6 -1
  76. package/mcp/atris-mcp/index.mjs +212 -10
  77. package/package.json +2 -1
  78. package/scripts/det/engine-reading.js +37 -0
  79. package/scripts/det/ytnotes +29 -5
@@ -0,0 +1,246 @@
1
+ // The campaign loop: a campaign runs itself until its judge passes, its money
2
+ // runs out, or it needs a person. Each step asks decide what to do, then does
3
+ // it: run the judge, start claimed missions (several at once), or ask the
4
+ // owner's engine to propose the next missions. Campaigns decide, missions do.
5
+
6
+ 'use strict';
7
+
8
+ const path = require('path');
9
+ const crypto = require('crypto');
10
+ const { spawn } = require('child_process');
11
+ const { decide } = require('./campaign-core');
12
+ const { getCampaign, openCampaignStore } = require('./campaign-store');
13
+ const { defaultReserve, reconcileCampaign } = require('./campaign-missions');
14
+ const { judgeRanData, latestJudgeOutput } = require('./campaign-reading');
15
+
16
+ const TERMINAL = new Set(['stopped', 'complete']);
17
+
18
+ const CLI = path.join(__dirname, '..', 'bin', 'atris.js');
19
+ const PROPOSE_TIMEOUT_MS = 5 * 60 * 1000;
20
+
21
+ function claimKey(objective) {
22
+ return String(objective).toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim().slice(0, 160);
23
+ }
24
+
25
+ function proposalId(objective) {
26
+ const slug = String(objective).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40).replace(/-+$/, '');
27
+ return `m-${slug || 'mission'}-${crypto.randomBytes(3).toString('hex')}`;
28
+ }
29
+
30
+ // What the owner's engine reads before proposing: the goal, how it is judged,
31
+ // what has been tried, and how it went.
32
+ function proposalPrompt(view, count, judgeOutput = null, lessons = '') {
33
+ const tried = view.missions.slice(-12).map((m) => `- ${m.id}: ${m.status}${m.outcome ? ` (${m.outcome})` : ''}${m.cost_usd !== null ? `, $${m.cost_usd}` : ''}`).join('\n') || '- none yet';
34
+ const seats = view.seats.map((s) => s.member).filter(Boolean);
35
+ const members = [...new Set([view.owner, ...seats])];
36
+ return [
37
+ `You plan the next missions for an Atris campaign. Answer with JSON only.`,
38
+ ``,
39
+ `Campaign: ${view.title}`,
40
+ `Judge (the campaign is done when this command exits 0): ${view.judge}`,
41
+ `Last judge reading: ${view.last_judge ? `${view.last_judge.passed ? 'passed' : 'failed'}${typeof view.last_judge.reading === 'number' ? ` (${view.last_judge.reading} of 1)` : ''}: ${view.answer.text || ''}` : 'never run'}`,
42
+ judgeOutput ? `Last judge output:\n\`\`\`\n${judgeOutput.replace(/```/g, "'''")}\n\`\`\`` : '',
43
+ view.bet ? `Current bet: ${view.bet.text}${view.bet.wrong_if ? ` (wrong if ${view.bet.wrong_if})` : ''}` : '',
44
+ `Money left: $${view.budget.available.toFixed(2)} of $${view.budget.total.toFixed(2)}`,
45
+ `Members who can take a mission: ${members.join(', ')}`,
46
+ `Missions so far:`,
47
+ tried,
48
+ lessons ? `Lessons from earlier rounds (follow them):\n${lessons}` : '',
49
+ ``,
50
+ `Propose at most ${count} missions that move the judge toward passing. Each is one member, one run, one checkable result.`,
51
+ `Do not repeat a mission that was already kept. Prefer small, independent missions that can run at the same time.`,
52
+ `Reply with a JSON array and nothing else:`,
53
+ `[{"objective": "<one sentence, imperative>", "member": "<one of the members above>", "verify": "<shell command that exits 0 when this mission's result is real>", "estimate_usd": <dollars, small>}]`,
54
+ ].filter((line) => line !== '').join('\n');
55
+ }
56
+
57
+ function parseProposals(text, view, count) {
58
+ const match = String(text || '').match(/\[[\s\S]*\]/);
59
+ if (!match) return [];
60
+ let rows;
61
+ try {
62
+ rows = JSON.parse(match[0]);
63
+ } catch {
64
+ return [];
65
+ }
66
+ if (!Array.isArray(rows)) return [];
67
+ const members = new Set([view.owner, ...view.seats.map((s) => s.member)]);
68
+ return rows.slice(0, count)
69
+ .filter((row) => row && typeof row.objective === 'string' && row.objective.trim())
70
+ .map((row) => ({
71
+ objective: row.objective.trim().slice(0, 300),
72
+ member: members.has(row.member) ? row.member : view.owner,
73
+ verify: typeof row.verify === 'string' && row.verify.trim() ? row.verify.trim() : view.judge,
74
+ estimate_usd: typeof row.estimate_usd === 'number' && row.estimate_usd > 0 ? Math.min(row.estimate_usd, view.budget.available) : null,
75
+ }));
76
+ }
77
+
78
+ // Ask the owner's engine for proposals through the read-only ask runner.
79
+ async function askOwner(root, view, prompt) {
80
+ const { resolveEngineForMember } = require('./member-engine');
81
+ const { runEngineAskJobs } = require('./engine-ask');
82
+ const pick = resolveEngineForMember(view.owner, root) || {};
83
+ const engine = (pick.engine && pick.engine.id) || 'claude';
84
+ const [answer] = await runEngineAskJobs([{ engine, model: pick.model || '', prompt, label: `campaign ${view.id} plan` }], { root, timeoutMs: PROPOSE_TIMEOUT_MS });
85
+ return { text: (answer && answer.stdout) || '', engine, ok: Boolean(answer && answer.ok) };
86
+ }
87
+
88
+ // Run one atris command and resolve with its exit code and output.
89
+ function runCli(args, { cwd, env = process.env, onLine = null } = {}) {
90
+ return new Promise((resolve) => {
91
+ const child = spawn(process.execPath, [CLI, ...args], { cwd, env: { ...env, ATRIS_SKIP_UPDATE_CHECK: '1' }, stdio: ['ignore', 'pipe', 'pipe'] });
92
+ let out = '';
93
+ let err = '';
94
+ child.stdout.on('data', (chunk) => {
95
+ out += chunk;
96
+ if (onLine) String(chunk).split('\n').filter(Boolean).forEach(onLine);
97
+ });
98
+ child.stderr.on('data', (chunk) => { err += chunk; });
99
+ child.on('close', (code) => resolve({ code, out, err }));
100
+ });
101
+ }
102
+
103
+ function makeLoop(root, options = {}, deps = {}) {
104
+ const store = deps.store || openCampaignStore(root);
105
+ const log = deps.log || ((line) => console.log(line));
106
+ const parallel = Math.max(1, Number(options.parallel) || 3);
107
+ const maxTicks = Math.max(1, Number(options.maxTicks) || 3);
108
+ // Every mission gets an explicit time limit. Without one, a number in the
109
+ // objective ("437 tests, 475s") was read as the mission's time budget.
110
+ const missionMinutes = Math.max(1, Number(options.missionMinutes) || 40);
111
+ const runner = options.runner || 'auto';
112
+ const actor = options.actor || 'mission-lead';
113
+ const ask = deps.ask || askOwner;
114
+ const cli = deps.cli || ((args) => runCli(args, { cwd: root }));
115
+ const judge = deps.judge;
116
+
117
+ const view = (id) => getCampaign(store, id);
118
+ const append = (id, type, data, as = actor) => store.append({ campaign_id: id, type, actor: as, data });
119
+
120
+ async function propose(id, count = parallel) {
121
+ const current = view(id);
122
+ const prompt = proposalPrompt(current, count, latestJudgeOutput(store.read(id)), deps.lessons ? deps.lessons(id) : '');
123
+ const answer = await ask(root, current, prompt);
124
+ const proposals = parseProposals(answer.text, current, count);
125
+ // Planning costs money too. It rides on the first mission it produced as a
126
+ // step, so it is counted when that mission ends without making the judge
127
+ // due. A plan that produced nothing becomes its own ended mission.
128
+ const plan = { kind: 'plan', engine: answer.engine };
129
+ if (typeof answer.cost_usd === 'number') plan.cost_usd = answer.cost_usd;
130
+ proposals.forEach((proposal, index) => {
131
+ const missionId = proposalId(proposal.objective);
132
+ append(id, 'mission_proposed', { mission_id: missionId, member: proposal.member, objective: proposal.objective, verify: proposal.verify, estimate_usd: proposal.estimate_usd || undefined }, proposal.member);
133
+ append(id, 'mission_claimed', { mission_id: missionId, claim_key: claimKey(proposal.objective) }, proposal.member);
134
+ if (index === 0) append(id, 'step', { mission_id: missionId, ...plan }, current.owner);
135
+ });
136
+ if (!proposals.length) {
137
+ const planId = `plan-${crypto.randomBytes(3).toString('hex')}`;
138
+ append(id, 'mission_started', { mission_id: planId, member: current.owner, kind: 'plan' }, current.owner);
139
+ append(id, 'mission_ended', { mission_id: planId, outcome: 'failed', cost_usd: plan.cost_usd || 0, uncosted_runs: plan.cost_usd === undefined ? 1 : 0, engine: answer.engine }, current.owner);
140
+ }
141
+ return proposals;
142
+ }
143
+
144
+ // The proposal data behind each claimed mission (the fold keeps only ids).
145
+ function claimedWork(id) {
146
+ const current = view(id);
147
+ const proposals = new Map();
148
+ for (const event of store.read(id)) {
149
+ if (event.type === 'mission_proposed' && event.data.objective) proposals.set(event.data.mission_id, event.data);
150
+ }
151
+ return current.missions
152
+ .filter((m) => m.status === 'claimed' && proposals.has(m.id))
153
+ .map((m) => ({ ...proposals.get(m.id), mission_id: m.id, member: m.member || proposals.get(m.id).member || current.owner }));
154
+ }
155
+
156
+ async function startAndRun(id, work) {
157
+ const current = view(id);
158
+ const reserve = work.estimate_usd || defaultReserve(current);
159
+ const started = await cli([
160
+ 'mission', 'start', work.objective, '--mission-id', work.mission_id, '--owner', work.member,
161
+ '--runner', runner, '--verify', work.verify || current.judge, '--campaign', id, '--reserve', String(reserve),
162
+ '--max-wall', String(missionMinutes * 60), '--json',
163
+ ]);
164
+ if (started.code !== 0) {
165
+ const why = (started.out + started.err).trim().split('\n').pop();
166
+ log(` ${work.mission_id} did not start: ${why}`);
167
+ append(id, 'mission_vetoed', { mission_id: work.mission_id, reason: why.slice(0, 200) }, current.owner);
168
+ return { mission_id: work.mission_id, started: false };
169
+ }
170
+ log(` ${work.mission_id} started for ${work.member}: ${work.objective}`);
171
+ const ran = await cli(['mission', 'run', work.mission_id, '--max-ticks', String(maxTicks), '--complete-on-pass']);
172
+ const after = view(id).missions.find((m) => m.id === work.mission_id);
173
+ if (after && after.status !== 'ended') {
174
+ // The run stopped without finishing (tick cap, error): close it so its
175
+ // reserve comes back and its cost is counted.
176
+ await cli(['mission', 'stop', work.mission_id, '--reason', `campaign loop: run ended without finishing (exit ${ran.code})`]);
177
+ }
178
+ const final = view(id).missions.find((m) => m.id === work.mission_id) || {};
179
+ log(` ${work.mission_id} ${final.outcome || 'ended'} · $${(final.cost_usd || 0).toFixed(2)}`);
180
+ return { mission_id: work.mission_id, started: true, outcome: final.outcome || null };
181
+ }
182
+
183
+ // A campaign linked to the cloud trades events before and after every step,
184
+ // so a person's answer from a phone reaches this loop. A failed sync never
185
+ // stops the loop; deps.sync warns, keeps running offline, and resolves with
186
+ // how many events it pulled.
187
+ const sync = deps.sync || (async () => 0);
188
+
189
+ // One step, with a sync on each side.
190
+ async function step(id) {
191
+ await sync(id);
192
+ const result = await stepOnce(id);
193
+ await sync(id);
194
+ return result;
195
+ }
196
+
197
+ // One step. Returns { stop, reason } or { stop: false, did }.
198
+ async function stepOnce(id) {
199
+ // Missions that ended without telling the campaign give their reserve back first.
200
+ if (!deps.skipReconcile) {
201
+ try { reconcileCampaign(root, id, TERMINAL, { store }); } catch {}
202
+ }
203
+ const current = view(id);
204
+ if (!current) return { stop: true, reason: `no campaign ${id}` };
205
+ if (current.error) return { stop: true, reason: `campaign cannot be read: ${current.error}` };
206
+ const move = decide(current);
207
+ if (move.action === 'none') return { stop: true, reason: move.reason };
208
+ if (move.action === 'wait' && move.reason === 'open asks') return { stop: true, reason: `needs you: ${move.asks.length} open ask${move.asks.length === 1 ? '' : 's'}` };
209
+ if (move.action === 'run_judge') {
210
+ const reading = await judge(current.judge);
211
+ append(id, 'judge_ran', judgeRanData(reading), 'judge');
212
+ log(`judge ${reading.passed ? 'passed' : 'failed'}: ${reading.summary || `exit ${reading.exit}`}`);
213
+ return { stop: false, did: 'judge' };
214
+ }
215
+ if (move.action === 'start_mission' || (move.action === 'wait' && move.reason === 'budget')) {
216
+ const work = claimedWork(id).slice(0, parallel);
217
+ if (!work.length) return { stop: true, reason: 'claimed missions have no objective to run; start them by hand or veto them' };
218
+ log(`starting ${work.length} mission${work.length === 1 ? '' : 's'} at once`);
219
+ const results = await Promise.all(work.map((w) => startAndRun(id, w)));
220
+ if (!results.some((r) => r.started)) return { stop: true, reason: 'no claimed mission could start' };
221
+ return { stop: false, did: 'missions' };
222
+ }
223
+ if (move.action === 'wait') return { stop: true, reason: `waiting: ${move.reason} (started elsewhere)` };
224
+ log(`asking ${current.owner} to propose up to ${parallel} missions`);
225
+ const proposals = await propose(id, parallel);
226
+ if (!proposals.length) return { stop: true, reason: `${current.owner} proposed nothing usable` };
227
+ for (const p of proposals) log(` proposed for ${p.member}: ${p.objective}`);
228
+ return { stop: false, did: 'propose' };
229
+ }
230
+
231
+ async function run(id, maxSteps = 20) {
232
+ await sync(id);
233
+ for (let i = 0; i < maxSteps; i++) {
234
+ const result = await stepOnce(id);
235
+ // An answer pulled just now can unblock a step that stopped for a person.
236
+ const pulled = await sync(id);
237
+ if (result.stop && pulled > 0) continue;
238
+ if (result.stop) return { steps: i, reason: result.reason, campaign: view(id) };
239
+ }
240
+ return { steps: maxSteps, reason: `stopped after ${maxSteps} steps`, campaign: view(id) };
241
+ }
242
+
243
+ return { step, run, propose };
244
+ }
245
+
246
+ module.exports = { askOwner, makeLoop, parseProposals, proposalPrompt };
@@ -0,0 +1,237 @@
1
+ // Missions that belong to a campaign (build.md Phase 3). commands/mission.js
2
+ // calls these at start and on every save; a mission with no campaign_id never
3
+ // reaches the store, so plain missions run exactly as before.
4
+
5
+ 'use strict';
6
+
7
+ const { decide } = require('./campaign-core');
8
+ const { getCampaign, openCampaignStore } = require('./campaign-store');
9
+
10
+ const DEFAULT_RESERVE_USD = 5;
11
+ const CLOSED_STATES = new Set(['done', 'out_of_budget', 'paused']);
12
+
13
+ function storeFor(root, deps = {}) {
14
+ return deps.store || openCampaignStore(root);
15
+ }
16
+
17
+ function warn(message) {
18
+ process.stderr.write(`campaign: ${message}\n`);
19
+ }
20
+
21
+ // How much a new mission holds back: what missions in this campaign have cost
22
+ // on average, or $5 before any has ended. --reserve overrides.
23
+ function defaultReserve(view) {
24
+ const ended = view.missions.filter((m) => m.status === 'ended' && typeof m.cost_usd === 'number');
25
+ if (!ended.length) return DEFAULT_RESERVE_USD;
26
+ const average = ended.reduce((sum, m) => sum + m.cost_usd, 0) / ended.length;
27
+ return Math.max(0.01, Math.ceil(average * 100) / 100);
28
+ }
29
+
30
+ // Checked before a mission creates anything, so a refusal leaves nothing behind.
31
+ function checkCampaignStart(root, campaignId, options = {}, deps = {}) {
32
+ const store = storeFor(root, deps);
33
+ const view = getCampaign(store, campaignId);
34
+ if (!view) return { ok: false, error: `no campaign ${campaignId}. See them with: atris campaign` };
35
+ if (view.error) return { ok: false, error: `campaign ${campaignId} cannot be read: ${view.error}` };
36
+ if (CLOSED_STATES.has(view.state)) {
37
+ return { ok: false, error: `campaign ${campaignId} is ${view.state.replace(/_/g, ' ')}, so it takes no new missions` };
38
+ }
39
+ if (options.missionId) {
40
+ const owner = store.campaignForMission(options.missionId);
41
+ if (owner && owner !== campaignId) return { ok: false, error: `mission ${options.missionId} already belongs to campaign ${owner}` };
42
+ }
43
+ const reserve = options.reserveUsd !== undefined && options.reserveUsd !== null && options.reserveUsd !== ''
44
+ ? Number(options.reserveUsd)
45
+ : defaultReserve(view);
46
+ if (!(reserve >= 0) || !Number.isFinite(reserve)) return { ok: false, error: '--reserve takes dollars, for example --reserve 5' };
47
+ if (reserve > view.budget.available) {
48
+ return {
49
+ ok: false,
50
+ error: `campaign ${campaignId} has $${view.budget.available.toFixed(2)} left and this mission would hold $${reserve.toFixed(2)}. Add money with: atris campaign budget ${campaignId} --add <dollars>`,
51
+ };
52
+ }
53
+ return { ok: true, campaign: view, reserve };
54
+ }
55
+
56
+ // Hold the reserve in one transaction with the check, so missions starting at
57
+ // the same moment can never hold more than the campaign has left.
58
+ function reserveCampaignMission(root, campaignId, missionId, amount, actor, deps = {}) {
59
+ if (!(amount > 0)) return null;
60
+ const store = storeFor(root, deps);
61
+ return store.append({ campaign_id: campaignId, type: 'budget_reserved', actor, data: { mission_id: missionId, amount_usd: amount } }, {
62
+ check: (view) => {
63
+ if (view.error) throw Object.assign(new Error(`campaign ${campaignId} cannot be read: ${view.error}`), { code: 'campaign_unreadable' });
64
+ if (CLOSED_STATES.has(view.state)) throw Object.assign(new Error(`campaign ${campaignId} is ${view.state.replace(/_/g, ' ')}, so it takes no new missions`), { code: 'campaign_closed' });
65
+ if (amount > view.budget.available) {
66
+ throw Object.assign(new Error(`campaign ${campaignId} has $${view.budget.available.toFixed(2)} left and this mission would hold $${amount.toFixed(2)}. Add money with: atris campaign budget ${campaignId} --add <dollars>`), { code: 'campaign_tank' });
67
+ }
68
+ },
69
+ });
70
+ }
71
+
72
+ // Before each tick: a mission stops working when its campaign is done,
73
+ // paused, or out of money. Returns the pause reason, or null to keep going.
74
+ function campaignGate(root, mission, deps = {}) {
75
+ if (!mission || !mission.campaign_id) return null;
76
+ try {
77
+ const view = getCampaign(storeFor(root, deps), mission.campaign_id);
78
+ if (!view || view.error) return null;
79
+ if (CLOSED_STATES.has(view.state)) return `campaign-${view.state.replace(/_/g, '-')}`;
80
+ } catch {
81
+ return null;
82
+ }
83
+ return null;
84
+ }
85
+
86
+ function readMissionLedger(root) {
87
+ const file = require('path').join(root, '.atris', 'state', 'missions.jsonl');
88
+ const latest = new Map();
89
+ let text = '';
90
+ try { text = require('fs').readFileSync(file, 'utf8'); } catch { return latest; }
91
+ for (const line of text.split('\n')) {
92
+ if (!line.trim()) continue;
93
+ try {
94
+ const row = JSON.parse(line);
95
+ if (row && row.id) latest.set(row.id, row);
96
+ } catch {}
97
+ }
98
+ return latest;
99
+ }
100
+
101
+ // Missions the campaign still counts as running whose mission record says
102
+ // they are over, or that never got a record (a start that crashed after its
103
+ // reserve): end them in the campaign so their reserve comes back.
104
+ function reconcileCampaign(root, campaignId, terminalStatuses, deps = {}) {
105
+ const store = storeFor(root, deps);
106
+ const view = getCampaign(store, campaignId);
107
+ if (!view || view.error) return [];
108
+ const ledger = (deps.readLedger || readMissionLedger)(root);
109
+ const ended = [];
110
+ const reservedOnly = new Set();
111
+ for (const event of store.read(campaignId)) {
112
+ if (event.type === 'budget_reserved' && !view.missions.some((m) => m.id === event.data.mission_id)) reservedOnly.add(event.data.mission_id);
113
+ if (event.type === 'budget_released') reservedOnly.delete(event.data.mission_id);
114
+ }
115
+ for (const missionId of reservedOnly) {
116
+ if (ledger.has(missionId)) continue;
117
+ store.append({ campaign_id: campaignId, type: 'budget_released', actor: 'system', data: { mission_id: missionId, reason: 'mission was never created' } });
118
+ ended.push(missionId);
119
+ }
120
+ for (const m of view.missions) {
121
+ if (m.status !== 'running' || m.id.startsWith('plan-')) continue;
122
+ const record = ledger.get(m.id);
123
+ if (record && !terminalStatuses.has(record.status)) continue;
124
+ if (!record && !deps.endMissing) continue;
125
+ const result = recordCampaignMissionEnded(root, { status: 'running' }, { ...(record || { id: m.id, owner: m.member, status: 'stopped' }), campaign_id: campaignId }, terminalStatuses, deps);
126
+ if (result) ended.push(m.id);
127
+ }
128
+ return ended;
129
+ }
130
+
131
+ function memberOf(mission) {
132
+ return mission.owner || mission.executed_by || 'mission-lead';
133
+ }
134
+
135
+ // After the mission is saved: it starts in the campaign and holds its reserve.
136
+ function recordCampaignMissionStarted(root, mission, reserve, deps = {}) {
137
+ if (!mission || !mission.campaign_id) return null;
138
+ try {
139
+ const store = storeFor(root, deps);
140
+ const actor = memberOf(mission);
141
+ store.append({
142
+ campaign_id: mission.campaign_id, type: 'mission_started', actor,
143
+ data: {
144
+ mission_id: mission.id, member: actor, objective: String(mission.objective || '').slice(0, 200),
145
+ ...(mission.runner && mission.runner !== 'auto' ? { engine: mission.runner } : {}),
146
+ ...(mission.model ? { model: mission.model } : {}),
147
+ },
148
+ });
149
+ if (reserve > 0) {
150
+ store.append({ campaign_id: mission.campaign_id, type: 'budget_reserved', actor, data: { mission_id: mission.id, amount_usd: reserve } });
151
+ }
152
+ return getCampaign(store, mission.campaign_id);
153
+ } catch (error) {
154
+ warn(`mission ${mission.id} started, but campaign ${mission.campaign_id} did not record it: ${error.message}`);
155
+ return null;
156
+ }
157
+ }
158
+
159
+ // Each tick writes a step into the campaign with the dollars its engine
160
+ // reported, so the campaign's own log holds the spend: no lookback window, and
161
+ // every runner counts, including ones that run without a roster pick.
162
+ function recordCampaignTick(root, mission, usage = {}, deps = {}) {
163
+ if (!mission || !mission.campaign_id) return;
164
+ const data = { mission_id: mission.id };
165
+ if (typeof usage.cost_usd === 'number' && Number.isFinite(usage.cost_usd)) data.cost_usd = usage.cost_usd;
166
+ if (typeof usage.tokens === 'number' && Number.isFinite(usage.tokens)) data.tokens = usage.tokens;
167
+ // Which engine did the work: traces and per-engine cost need it.
168
+ if (typeof usage.engine === 'string' && usage.engine) data.engine = usage.engine;
169
+ try {
170
+ storeFor(root, deps).append({ campaign_id: mission.campaign_id, type: 'step', actor: memberOf(mission), data });
171
+ } catch (error) {
172
+ warn(`a tick of mission ${mission.id} was not recorded in campaign ${mission.campaign_id}: ${error.message}`);
173
+ }
174
+ }
175
+
176
+ // Dollars the mission's ticks reported, from the campaign's own steps. Ticks
177
+ // without a dollar figure (engines that print tokens only) are counted so the
178
+ // campaign can say spend is partial.
179
+ function missionCost(root, missionId, deps = {}) {
180
+ const store = storeFor(root, deps);
181
+ const campaignId = store.campaignForMission(missionId);
182
+ const rows = campaignId
183
+ ? store.read(campaignId).filter((e) => e.type === 'step' && e.data.mission_id === missionId).map((e) => e.data)
184
+ : [];
185
+ let cents = 0;
186
+ let uncosted = 0;
187
+ // A cost that is not a real, non-negative number never blocks the mission's
188
+ // end; that tick counts as one without a dollar figure.
189
+ for (const row of rows) {
190
+ if (typeof row.cost_usd === 'number' && Number.isFinite(row.cost_usd) && row.cost_usd >= 0) cents += Math.floor(row.cost_usd * 100 + 0.5);
191
+ else uncosted += 1;
192
+ }
193
+ return { cost_usd: cents / 100, runs: rows.length, uncosted_runs: uncosted };
194
+ }
195
+
196
+ function outcomeFor(mission) {
197
+ return mission.status === 'complete' ? 'kept' : 'failed';
198
+ }
199
+
200
+ // Called from saveMission with the record before and after. Only the step
201
+ // into a final status ends the mission in its campaign.
202
+ function recordCampaignMissionEnded(root, previous, next, terminalStatuses, deps = {}) {
203
+ if (!next || !next.campaign_id || !terminalStatuses.has(next.status)) return null;
204
+ if (previous && terminalStatuses.has(previous.status)) return null;
205
+ try {
206
+ const store = storeFor(root, deps);
207
+ const view = getCampaign(store, next.campaign_id);
208
+ const known = view && !view.error && view.missions.find((m) => m.id === next.id);
209
+ if (known && known.status === 'ended') return null;
210
+ const cost = missionCost(root, next.id, deps);
211
+ const actor = memberOf(next);
212
+ store.append({
213
+ campaign_id: next.campaign_id, type: 'mission_ended', actor,
214
+ data: {
215
+ mission_id: next.id, outcome: outcomeFor(next), cost_usd: cost.cost_usd,
216
+ runs: cost.runs, uncosted_runs: cost.uncosted_runs, receipt_path: next.receipt_path || null,
217
+ },
218
+ });
219
+ const after = getCampaign(store, next.campaign_id);
220
+ return after ? { campaign: after, next: decide(after), cost } : null;
221
+ } catch (error) {
222
+ warn(`mission ${next.id} ended, but campaign ${next.campaign_id} did not record it: ${error.message}`);
223
+ return null;
224
+ }
225
+ }
226
+
227
+ module.exports = {
228
+ campaignGate,
229
+ reconcileCampaign,
230
+ reserveCampaignMission,
231
+ checkCampaignStart,
232
+ defaultReserve,
233
+ missionCost,
234
+ recordCampaignMissionEnded,
235
+ recordCampaignMissionStarted,
236
+ recordCampaignTick,
237
+ };