atris 3.69.0 → 3.71.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 (79) hide show
  1. package/README.md +40 -1
  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 +26 -6
  7. package/commands/autoland.js +176 -7
  8. package/commands/autopilot.js +71 -11
  9. package/commands/campaign.js +759 -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/interview.js +15 -3
  17. package/commands/land.js +22 -6
  18. package/commands/link.js +1 -0
  19. package/commands/member.js +433 -99
  20. package/commands/mission.js +332 -41
  21. package/commands/report.js +42 -8
  22. package/commands/run.js +9 -2
  23. package/commands/scout.js +9 -1
  24. package/commands/task.js +132 -16
  25. package/commands/team.js +212 -4
  26. package/commands/unknowns.js +15 -1
  27. package/commands/verify.js +46 -0
  28. package/commands/worktree.js +148 -18
  29. package/commands/write.js +12 -1
  30. package/commands/youtube.js +17 -2
  31. package/lib/accept-verify-gate.js +1 -0
  32. package/lib/auto-accept-certified.js +1 -0
  33. package/lib/autoland.js +13 -4
  34. package/lib/campaign-cadence.js +131 -0
  35. package/lib/campaign-core.js +330 -0
  36. package/lib/campaign-holdout.js +65 -0
  37. package/lib/campaign-loop.js +242 -0
  38. package/lib/campaign-missions.js +237 -0
  39. package/lib/campaign-new.js +251 -0
  40. package/lib/campaign-protect.js +75 -0
  41. package/lib/campaign-reading.js +147 -0
  42. package/lib/campaign-schema.js +196 -0
  43. package/lib/campaign-store.js +380 -0
  44. package/lib/campaign-sync.js +302 -0
  45. package/lib/campaign-trace.js +300 -0
  46. package/lib/conductor-artifacts.js +15 -1
  47. package/lib/daily-log.js +11 -1
  48. package/lib/dispatch-scout.js +18 -0
  49. package/lib/engine-ask.js +35 -4
  50. package/lib/engine-registry.js +2 -0
  51. package/lib/engine-resource.js +518 -0
  52. package/lib/engine-validate.js +189 -9
  53. package/lib/fleet.js +125 -16
  54. package/lib/init-commit.js +80 -0
  55. package/lib/intents.js +8 -0
  56. package/lib/known-commands.js +1 -1
  57. package/lib/land-green.js +58 -7
  58. package/lib/landing-proof.js +352 -0
  59. package/lib/member-autoland.js +479 -10
  60. package/lib/member-board.js +86 -10
  61. package/lib/member-map-fold.js +126 -0
  62. package/lib/member-map-inbox.js +80 -0
  63. package/lib/member-progress.js +48 -1
  64. package/lib/member-report.js +433 -0
  65. package/lib/member-scaffold.js +1 -2
  66. package/lib/member-self-next-job.js +97 -0
  67. package/lib/member-switches.js +7 -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 +63 -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,759 @@
1
+ // atris campaign: long-running goals with a judge, a budget, and members in
2
+ // seats. Every verb appends events to the workspace campaign store; every
3
+ // screen is a fold of those events. Spec: atris/features/campaign/idea.md,
4
+ // contract: test/fixtures/campaign/README.md.
5
+
6
+ 'use strict';
7
+
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+ const { spawn } = require('child_process');
12
+ const { readFlag } = require('../lib/arg-parser');
13
+ const { CAMPAIGN_ID, isHumanActor, validateCampaign } = require('../lib/campaign-schema');
14
+ const { decide } = require('../lib/campaign-core');
15
+ const { campaignDbPath, getCampaign, listCampaigns, newCampaignId, openCampaignStore } = require('../lib/campaign-store');
16
+ const { findHoldout, moveHoldoutBack, moveHoldoutIn, scrub } = require('../lib/campaign-holdout');
17
+ const { judgeRanData, judgeReading, outputTail } = require('../lib/campaign-reading');
18
+ const { CLOUD_FEATURES, cloudLink, cloudRunStatus, runInCloud, sendPersonAction, syncCampaign, takeCampaignToken } = require('../lib/campaign-sync');
19
+ const { campaignHome, changedProtected, checkoutTop, planProtect, readProtect, saveProtect } = require('../lib/campaign-protect');
20
+
21
+ const JUDGE_TIMEOUT_SECONDS = 15 * 60;
22
+ // Who pays for a campaign's engines (atris/features/campaign/api.md, Credits).
23
+ const BASES = ['own_engines', 'atris_cloud'];
24
+ const STATE_ORDER = ['needs_you', 'running', 'queued', 'paused', 'resting', 'out_of_budget', 'done'];
25
+
26
+ function printHelp() {
27
+ console.log('Usage:');
28
+ console.log(' atris campaign "<goal as a sentence>" --judge "<cmd>" begin a campaign from one sentence; every flag below works here too');
29
+ console.log(' atris campaign every campaign: state, money, and the next move');
30
+ console.log(' atris campaign "<goal>" draft a judge that fails today, start on a yes; same as campaign new');
31
+ console.log(' atris campaign new "<goal>" [--budget 20] [--owner <member>] [--yes] [--run] --run keeps going after the start');
32
+ console.log(' atris campaign start "<title>" --judge "<cmd>" [--budget <dollars>] no --budget means no limit; --cloud needs one');
33
+ console.log(' [--holdout <file>] move held-out data out of the repo; the judge reads it from $ATRIS_CAMPAIGN_HOLDOUT');
34
+ console.log(' [--owner <member>] [--seat <member>[:<role>]]... [--bet "<bet>" [--wrong-if "<what proves it wrong>"]]');
35
+ console.log(' [--standing] [--parent <id>] [--id cmp-<name>] [--cloud [--basis atris_cloud]] [--protect <path>]... [--run]');
36
+ console.log(' --protect saves paths agents must not touch, and the commit they start from');
37
+ console.log(' --basis atris_cloud: Atris runs it on your Atris computer and bills its engines; the default is your own engines');
38
+ console.log(' atris campaign show <id> bet, judge, answer, budget, seats, missions, asks');
39
+ console.log(' atris campaign judge <id> [--timeout <seconds>] run the judge now and record the reading');
40
+ console.log(' atris campaign guard <id> exits 1 naming any protected path changed since start');
41
+ console.log(' atris campaign set-judge <id> "<cmd>" change the judge (owner or a person; must fail first)');
42
+ console.log(' atris campaign seat <id> <member> [--role <role>]');
43
+ console.log(' atris campaign bet <id> "<bet>" [--wrong-if "<what proves it wrong>"]');
44
+ console.log(' atris campaign budget <id> --add <dollars>');
45
+ console.log(' atris campaign ask <id> "<question>" [--mission <id>] stop before anything that cannot be undone');
46
+ console.log(' atris campaign answer <id> <ask-id> "<answer>" a person answers; agents cannot');
47
+ console.log(' atris campaign pause|resume|stop <id>');
48
+ console.log(' atris campaign event <id> <type> [--data \'<json>\'] append any event by hand');
49
+ console.log(' atris campaign run <id> [--parallel 3] [--max-steps 20] [--max-ticks 3] [--runner auto]');
50
+ console.log(' the campaign runs itself: proposes missions, runs them at once, judges, repeats until done, broke, or needing you');
51
+ console.log(' add --work 50m --break 10m [--rounds N] to work in rounds; each break judges and writes lessons the next round plans from');
52
+ console.log(' atris campaign step <id> one step of that loop');
53
+ console.log(' atris campaign propose <id> [--count 3] ask the owner\'s engine for the next missions');
54
+ console.log(' atris campaign export <id> --trace [--out <file.jsonl>] [--full] one row per mission for learning; redacted unless --full');
55
+ console.log(' atris campaign sync <id> trade events with the Atris cloud so web and iOS show it');
56
+ console.log(' start --cloud syncs at once; run syncs before and after every step of a synced campaign;');
57
+ console.log(' on a computer that has never seen <id>, sync pulls the whole campaign from the cloud');
58
+ console.log(' atris campaign cloud-run <id> [--work 50m --break 10m --rounds N] run an atris_cloud campaign on your');
59
+ console.log(' Atris computer, so it keeps going while this one sleeps; --status says how that run is going');
60
+ console.log(' atris campaign features [--json] what this CLI can do for an Atris cloud computer (the backend checks it)');
61
+ console.log('');
62
+ console.log('Every verb takes --json. Writes are recorded as you (human:<login>); agents pass --as <member>.');
63
+ console.log('A new campaign runs its judge first and is refused if it already passes: write a judge that fails first.');
64
+ }
65
+
66
+ function clip(text, max = 80) {
67
+ const value = String(text);
68
+ return value.length > max ? `${value.slice(0, max - 3)}...` : value;
69
+ }
70
+
71
+ function money(value) {
72
+ return `$${Number(value).toFixed(2)}`;
73
+ }
74
+
75
+ // A local start may leave out --budget. The schema still wants a positive
76
+ // number, so the campaign gets a ceiling nothing real reaches, and show and
77
+ // list leave its money lines out.
78
+ const NO_LIMIT_BUDGET = 1e9;
79
+ const noLimit = (view) => view.budget.total >= NO_LIMIT_BUDGET;
80
+
81
+ // The flags this command understands, so positional words are what is left.
82
+ const VALUE_FLAGS = ['--basis', '--work', '--break', '--rounds', '--mission', '--parallel', '--max-steps', '--max-ticks', '--runner', '--count', '--judge', '--budget', '--owner', '--seat', '--bet', '--wrong-if', '--parent', '--id', '--role', '--add', '--data', '--timeout', '--as', '--holdout', '--protect'];
83
+
84
+ function positionals(args) {
85
+ const out = [];
86
+ for (let i = 0; i < args.length; i++) {
87
+ const arg = String(args[i]);
88
+ if (VALUE_FLAGS.includes(arg)) { i++; continue; }
89
+ if (arg.startsWith('--')) continue;
90
+ out.push(arg);
91
+ }
92
+ return out;
93
+ }
94
+
95
+ function readAll(args, name) {
96
+ const values = [];
97
+ for (let i = 0; i < args.length; i++) {
98
+ if (args[i] === name && args[i + 1] !== undefined) values.push(String(args[++i]));
99
+ else if (String(args[i]).startsWith(`${name}=`)) values.push(String(args[i]).slice(name.length + 1));
100
+ }
101
+ return values;
102
+ }
103
+
104
+ // Agents run in shells that say so; their writes are never recorded as a
105
+ // person. ATRIS_HUMAN=1 is how a person typing inside an agent session says
106
+ // it is really them.
107
+ const AGENT_ENV = ['ATRIS_AGENT_ID', 'CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT', 'CODEX_SANDBOX', 'CURSOR_AGENT'];
108
+
109
+ function defaultActor(env = process.env) {
110
+ if (env.ATRIS_HUMAN !== '1' && AGENT_ENV.some((name) => env[name])) return env.ATRIS_AGENT_ID || 'agent';
111
+ try {
112
+ return `human:${os.userInfo().username}`;
113
+ } catch {
114
+ return 'human:unknown';
115
+ }
116
+ }
117
+
118
+ // The campaign store and the judge belong to the nearest checkout.
119
+ function moveToCheckoutRoot(asJson) {
120
+ let dir = process.cwd();
121
+ for (;;) {
122
+ if (fs.existsSync(path.join(dir, '.git'))) break;
123
+ const parent = path.dirname(dir);
124
+ if (parent === dir) return;
125
+ dir = parent;
126
+ }
127
+ if (dir !== process.cwd()) {
128
+ if (!asJson) console.error(`campaign: working in ${dir}`);
129
+ process.chdir(dir);
130
+ }
131
+ }
132
+
133
+ function nextMove(view) {
134
+ const move = decide(view);
135
+ switch (move.action) {
136
+ case 'run_judge': return { move, text: `run the judge: atris campaign judge ${view.id}` };
137
+ case 'start_mission': return { move, text: `start mission ${move.mission_id}: atris campaign run ${view.id}` };
138
+ case 'wait': return { move, text: move.reason === 'open asks' ? `answer ${move.asks.length} open ask${move.asks.length === 1 ? '' : 's'}: atris campaign answer ${view.id} ${move.asks[0]} "<answer>"` : `wait: ${move.reason} (atris campaign show ${view.id})` };
139
+ case 'propose': return { move, text: `plan and run the next missions: atris campaign run ${view.id}` };
140
+ default: return { move, text: `nothing to do: ${move.reason}` };
141
+ }
142
+ }
143
+
144
+ const JUDGE_OUTPUT_LIMIT = 256 * 1024;
145
+
146
+ // The summary is the line that says how it went: pass and fail counts when the
147
+ // output has them, else the last line with real words in it.
148
+ function judgeSummary(output) {
149
+ const lines = output.split('\n').map((line) => line.trim()).filter(Boolean);
150
+ const counts = lines.filter((line) => /^(ℹ|#)\s*(pass|fail)\s+\d+$/i.test(line)).map((line) => line.replace(/^(ℹ|#)\s*/, ''));
151
+ if (counts.length) return counts.slice(-2).join(', ');
152
+ const tally = lines.filter((line) => /\d+\s+(passed|failed|passing|failing)\b|\b(passed|failed|passing|failing)\b.*\d+/i.test(line));
153
+ if (tally.length) return tally[tally.length - 1].slice(0, 200);
154
+ const telling = lines.filter((line) => /[a-z0-9]{2,}/i.test(line));
155
+ return telling.length ? telling[telling.length - 1].slice(0, 200) : '';
156
+ }
157
+
158
+ // Runs in its own process group so a timeout stops everything it started;
159
+ // keeps only the last part of a huge output instead of failing on it.
160
+ function runJudgeCommand(command, cwd, timeoutSeconds, env = process.env) {
161
+ const started = Date.now();
162
+ return new Promise((resolve) => {
163
+ const child = spawn('/bin/sh', ['-c', command], { cwd, env, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
164
+ let output = '';
165
+ let timedOut = false;
166
+ const keep = (chunk) => {
167
+ output += chunk;
168
+ if (output.length > JUDGE_OUTPUT_LIMIT * 2) output = output.slice(-JUDGE_OUTPUT_LIMIT);
169
+ };
170
+ child.stdout.on('data', keep);
171
+ child.stderr.on('data', keep);
172
+ const timer = setTimeout(() => {
173
+ timedOut = true;
174
+ try { process.kill(-child.pid, 'SIGKILL'); } catch {}
175
+ }, timeoutSeconds * 1000);
176
+ child.on('error', (error) => {
177
+ clearTimeout(timer);
178
+ resolve({ passed: false, exit: null, summary: error.message.slice(0, 200), seconds: Math.round((Date.now() - started) / 1000) });
179
+ });
180
+ child.on('close', (code) => {
181
+ clearTimeout(timer);
182
+ const seconds = Math.round((Date.now() - started) / 1000);
183
+ if (timedOut) return resolve({ passed: false, exit: null, summary: `timed out after ${timeoutSeconds}s`, seconds });
184
+ resolve({ passed: code === 0, exit: code, summary: judgeSummary(output), seconds, reading: judgeReading(output), output_tail: outputTail(output) });
185
+ });
186
+ });
187
+ }
188
+
189
+ function shellQuote(text) {
190
+ return /^[\w@%+=:,./-]+$/.test(text) ? text : `'${text.replace(/'/g, `'\\''`)}'`;
191
+ }
192
+
193
+ // A judge that is just one script file outside this checkout is copied into
194
+ // the campaign's own folder, so deleting or editing the original never changes
195
+ // what the campaign checks. Returns the copy as a command, or null to keep it.
196
+ function keepJudgeScript(judge, folder) {
197
+ const named = judge.trim().replace(/^(['"])(.*)\1$/, '$2');
198
+ let file;
199
+ try {
200
+ file = fs.realpathSync(path.resolve(named));
201
+ if (!fs.statSync(file).isFile()) return null;
202
+ } catch {
203
+ return null;
204
+ }
205
+ const inside = path.relative(fs.realpathSync(process.cwd()), file);
206
+ if (inside && !inside.startsWith('..') && !path.isAbsolute(inside)) return null;
207
+ fs.mkdirSync(folder, { recursive: true });
208
+ const copy = path.join(folder, path.basename(file));
209
+ fs.copyFileSync(file, copy);
210
+ fs.chmodSync(copy, fs.statSync(file).mode & 0o777);
211
+ return { from: file, judge: shellQuote(copy) };
212
+ }
213
+
214
+ function makeContext(args, deps) {
215
+ const asJson = args.includes('--json');
216
+ const store = deps.store || openCampaignStore(process.cwd());
217
+ const actor = readFlag(args, '--as', '') || deps.actor || defaultActor();
218
+ const forgedHuman = readFlag(args, '--as', '').startsWith('human:');
219
+ const runJudge = deps.runJudge || ((command, timeout, env) => runJudgeCommand(command, process.cwd(), timeout, env));
220
+ // Held-out files live beside the campaign store, outside every checkout.
221
+ const holdoutRoot = deps.holdoutRoot || path.dirname(store.path || campaignDbPath());
222
+ // A campaign with a held-out file hands its path to the judge, and nothing
223
+ // the judge prints from it reaches the store.
224
+ const judge = async (command, timeout, campaignId) => {
225
+ const holdout = campaignId ? findHoldout(holdoutRoot, campaignId) : null;
226
+ const reading = await runJudge(command, timeout, holdout ? { ...process.env, ATRIS_CAMPAIGN_HOLDOUT: holdout } : process.env);
227
+ if (!holdout) return reading;
228
+ return { ...reading, summary: scrub(reading.summary, holdout), output_tail: scrub(reading.output_tail, holdout) };
229
+ };
230
+ const out = (human, json) => {
231
+ if (asJson) console.log(JSON.stringify(json));
232
+ else console.log(human);
233
+ };
234
+ const fail = (message, code = 2) => {
235
+ if (asJson) console.log(JSON.stringify({ ok: false, error: message }));
236
+ else console.error(message);
237
+ return code;
238
+ };
239
+ const append = (campaignId, type, data, as = actor) => store.append({ campaign_id: campaignId, type, actor: as, data });
240
+ // A campaign's own files (its judge copy, held-out data) live beside the store, outside the repo.
241
+ const folder = deps.campaignFolder || ((id) => path.join(holdoutRoot, 'campaigns', id));
242
+ return { asJson, store, actor, judge, holdoutRoot, out, fail, append, forgedHuman, folder, loopDeps: deps.loopDeps, cloud: deps.cloud || {} };
243
+ }
244
+
245
+ function requireCampaign(ctx, id) {
246
+ if (!id) return { error: ctx.fail('name the campaign: atris campaign <verb> <id>') };
247
+ const view = getCampaign(ctx.store, id);
248
+ if (!view) return { error: ctx.fail(`no campaign ${id}. See them all with: atris campaign`, 1) };
249
+ if (view.error) return { error: ctx.fail(`campaign ${id} cannot be read: ${view.error}`, 1) };
250
+ return { view };
251
+ }
252
+
253
+ function renderList(views) {
254
+ if (!views.length) {
255
+ return 'No campaigns yet. Start one:\n atris campaign "<goal as a sentence>" --judge "<command that fails today>"';
256
+ }
257
+ const sorted = views.slice().sort((a, b) => STATE_ORDER.indexOf(a.state) - STATE_ORDER.indexOf(b.state));
258
+ const lines = [];
259
+ for (const view of sorted) {
260
+ const running = view.missions.filter((m) => m.status === 'running').length;
261
+ lines.push(`${clip(view.title)} (${view.id})`);
262
+ const spend = noLimit(view) ? '' : ` · ${money(view.budget.spent)} of ${money(view.budget.total)}`;
263
+ lines.push(` ${view.state.replace(/_/g, ' ')}${spend} · ${running} mission${running === 1 ? '' : 's'} running · answer ${view.answer.confidence}`);
264
+ lines.push(` next: ${nextMove(view).text}`);
265
+ }
266
+ return lines.join('\n');
267
+ }
268
+
269
+ function renderShow(view) {
270
+ const next = nextMove(view).text;
271
+ const lines = [`${clip(view.title)} (${view.id})`, ''];
272
+ const row = (label, value) => lines.push(`${label.padEnd(9)} ${value}`);
273
+ row('state', `${view.state.replace(/_/g, ' ')}${view.stop_reason ? ` (${view.stop_reason.replace(/_/g, ' ')})` : ''} · next: ${next}`);
274
+ const last = view.last_judge;
275
+ row('judge', `${view.judge}${last ? ` · last run ${last.passed ? 'passed' : 'failed'} at ${last.at}` : ' · never run'}${view.judge_due && last ? ' · due again' : ''}`);
276
+ row('answer', `${view.answer.confidence}${view.answer.text ? `: ${view.answer.text}` : ''}`);
277
+ if (!noLimit(view)) row('budget', `${money(view.budget.spent)} spent of ${money(view.budget.total)} · ${money(view.budget.reserved)} reserved · ${money(view.budget.available)} left`);
278
+ row('owner', view.owner + (view.standing ? ' · standing (a passing judge means healthy, not done)' : ''));
279
+ if (view.bet) row('bet', `${view.bet.text}${view.bet.wrong_if ? ` · wrong if ${view.bet.wrong_if}` : ''}`);
280
+ if (view.parent) row('parent', view.parent);
281
+ row('seats', view.seats.length ? view.seats.map((s) => (s.role ? `${s.member} (${s.role})` : s.member)).join(', ') : 'none');
282
+ row('missions', view.missions.length ? '' : 'none');
283
+ for (const m of view.missions) {
284
+ const cost = m.cost_usd === null ? '' : ` · ${money(m.cost_usd)}`;
285
+ lines.push(` ${m.id.padEnd(22)} ${m.status.padEnd(8)} ${m.member || '-'}${m.outcome ? ` · ${m.outcome}` : ''}${cost}`);
286
+ }
287
+ row('asks', view.asks.length ? '' : 'none');
288
+ for (const ask of view.asks) lines.push(` ${ask.id}: ${ask.question}`);
289
+ if (view.warnings.length) {
290
+ row('warnings', '');
291
+ for (const w of view.warnings) lines.push(` ${w.code.replace(/_/g, ' ')} (${w.event_id})`);
292
+ }
293
+ return lines.join('\n');
294
+ }
295
+
296
+ async function cmdStart(args, ctx) {
297
+ const title = positionals(args)[1];
298
+ const judge = readFlag(args, '--judge', '');
299
+ const budgetText = readFlag(args, '--budget', '');
300
+ if (!budgetText && args.includes('--cloud')) return ctx.fail('a cloud campaign needs --budget <dollars>, so the cloud knows what it may spend');
301
+ const budget = budgetText ? Number(budgetText) : NO_LIMIT_BUDGET;
302
+ const bet = readFlag(args, '--bet', '');
303
+ const wrongIf = readFlag(args, '--wrong-if', '');
304
+ const parent = readFlag(args, '--parent', '');
305
+ const standing = args.includes('--standing');
306
+ const owner = readFlag(args, '--owner', '');
307
+ const seats = readAll(args, '--seat').map((seat) => {
308
+ const [member, role] = seat.split(':');
309
+ return role ? { member, role } : member;
310
+ });
311
+ const record = { title, judge, budget };
312
+ if (owner) record.owner = owner;
313
+ if (seats.length) record.seats = seats;
314
+ if (bet) record.bet = bet;
315
+ if (standing) record.standing = true;
316
+ if (parent) record.parent = parent;
317
+ for (const key of Object.keys(record)) if (record[key] === undefined) delete record[key];
318
+
319
+ const lookup = (id) => {
320
+ const view = getCampaign(ctx.store, id);
321
+ return view && !view.error ? { parent: view.parent } : null;
322
+ };
323
+ const check = validateCampaign(record, { lookup });
324
+ if (!check.ok) {
325
+ const words = {
326
+ missing_title: 'a title in quotes', missing_judge: '--judge "<command>"', bad_budget: '--budget <dollars above 0>',
327
+ nested_parent: 'a parent with no parent of its own (one level only)', unknown_parent: 'a --parent that exists', bad_seat: '--seat <member>[:<role>]',
328
+ };
329
+ return ctx.fail(`a campaign needs ${check.errors.map((e) => words[e] || e).join(', ')}`);
330
+ }
331
+ if (wrongIf && !bet) return ctx.fail('--wrong-if goes with --bet');
332
+ const basis = readFlag(args, '--basis', '');
333
+ if (basis && !BASES.includes(basis)) return ctx.fail(`--basis is ${BASES.join(' or ')}`);
334
+ if (basis === 'atris_cloud' && !args.includes('--cloud')) {
335
+ return ctx.fail('--basis atris_cloud runs on your Atris computer, so it needs --cloud too');
336
+ }
337
+
338
+ const id = readFlag(args, '--id', '') || newCampaignId(title);
339
+ if (!CAMPAIGN_ID.test(id)) return ctx.fail(`--id must look like cmp-<lowercase-words>, for example cmp-ship-billing`);
340
+ if (getCampaign(ctx.store, id)) return ctx.fail(`campaign ${id} already exists`, 1);
341
+ const protect = readAll(args, '--protect');
342
+ const protectPlan = protect.length ? planProtect(process.cwd(), protect) : null;
343
+ if (protectPlan && protectPlan.error) return ctx.fail(protectPlan.error);
344
+
345
+ const holdoutFlag = readFlag(args, '--holdout', '');
346
+ let held = null;
347
+ if (holdoutFlag) {
348
+ held = moveHoldoutIn(ctx.holdoutRoot, id, holdoutFlag);
349
+ if (held.error) return ctx.fail(held.error);
350
+ if (!ctx.asJson) console.error(`moved the held-out file out of the repo; the judge reads it from ATRIS_CAMPAIGN_HOLDOUT`);
351
+ }
352
+
353
+ const timeout = Number(readFlag(args, '--timeout', '')) || JUDGE_TIMEOUT_SECONDS;
354
+ if (!ctx.asJson) console.error(`running the judge first: ${judge}`);
355
+ const reading = await ctx.judge(judge, timeout, id);
356
+ if (reading.passed && !standing) {
357
+ moveHoldoutBack(held);
358
+ return ctx.fail(`already done: the judge passes before any work (${reading.summary || 'exit 0'}). Write a judge that fails first.`, 1);
359
+ }
360
+
361
+ let kept = null;
362
+ try {
363
+ kept = keepJudgeScript(judge, ctx.folder(id));
364
+ } catch (error) {
365
+ if (!ctx.asJson) console.error(`could not copy the judge script, keeping the original: ${error.message}`);
366
+ }
367
+ if (kept && !ctx.asJson) console.error(`copied the judge script from ${kept.from} into the campaign: ${kept.judge}`);
368
+
369
+ const startData = { title, judge: kept ? kept.judge : judge, budget, owner: owner || 'mission-lead', created_from: `atris campaign start by ${ctx.actor}` };
370
+ if (seats.length) startData.seats = seats;
371
+ if (bet && !wrongIf) startData.bet = bet;
372
+ if (standing) startData.standing = true;
373
+ if (basis) startData.basis = basis;
374
+ ctx.append(id, 'campaign_started', startData);
375
+ if (bet && wrongIf) ctx.append(id, 'bet_set', { bet, wrong_if: wrongIf });
376
+ if (parent) ctx.append(id, 'attached', { parent_id: parent });
377
+ ctx.append(id, 'judge_ran', judgeRanData(reading), 'judge');
378
+ if (protectPlan) saveProtect(ctx.campaignHome, id, protectPlan);
379
+
380
+ let cloud = null;
381
+ if (args.includes('--cloud')) {
382
+ cloud = await syncCampaign(ctx.store, id, ctx.cloud);
383
+ if (!cloud.ok && !ctx.asJson) console.error(`started here, not yet in the cloud: ${cloud.error}. Try again with: atris campaign sync ${id}`);
384
+ }
385
+ const view = getCampaign(ctx.store, id);
386
+ const synced = cloud && cloud.ok ? `\nin the cloud: ${cloud.pushed} event${cloud.pushed === 1 ? '' : 's'} sent` : '';
387
+ ctx.out(`started ${view.title} (${id})${synced}\n${renderShow(view)}`, { ok: true, action: 'campaign_started', campaign: view, next: decide(view), ...(cloud ? { cloud } : {}) });
388
+ // --run keeps going: plan and run missions until the judge passes or a stop.
389
+ if (args.includes('--run')) return cmdLoop(['run', id, ...args.slice(2)], ctx, 'run');
390
+ return 0;
391
+ }
392
+
393
+ function describeSync(result) {
394
+ const parts = [`sent ${result.pushed}`, `received ${result.pulled}`];
395
+ if (result.duplicates) parts.push(`${result.duplicates} already there`);
396
+ if (result.held) parts.push(`${result.held} kept here (only a person's screen sends answers, budget, and done)`);
397
+ const first = result.first_pull ? `pulled ${result.campaign_id} from the cloud, new on this computer. ` : '';
398
+ return `${first}synced with the cloud: ${parts.join(', ')}`;
399
+ }
400
+
401
+ async function cmdSync(args, ctx) {
402
+ const id = positionals(args)[1];
403
+ // A computer that has never seen the campaign pulls it whole from the cloud.
404
+ const here = id && getCampaign(ctx.store, id);
405
+ if (id && !here && !CAMPAIGN_ID.test(id)) return ctx.fail(`campaign ids look like cmp-<lowercase-words>, not ${id}`);
406
+ const { view, error } = here || !id ? requireCampaign(ctx, id) : { view: { id } };
407
+ if (error !== undefined) return error;
408
+ const result = await syncCampaign(ctx.store, view.id, ctx.cloud);
409
+ if (!result.ok) {
410
+ if (ctx.asJson) console.log(JSON.stringify(result));
411
+ else console.error(`sync failed: ${result.error}`);
412
+ return 1;
413
+ }
414
+ const after = getCampaign(ctx.store, view.id);
415
+ ctx.out(`${describeSync(result)}\n${renderShow(after)}`, { ...result, campaign: after, next: decide(after) });
416
+ return 0;
417
+ }
418
+
419
+ // Run an atris_cloud campaign on the person's Atris computer, or say how that
420
+ // run is going (--status). A campaign that lives here is synced first so the
421
+ // computer pulls everything this machine knows.
422
+ async function cmdCloudRun(args, ctx) {
423
+ const id = positionals(args)[1];
424
+ if (!id) return ctx.fail('name the campaign: atris campaign cloud-run <id> [--work 50m --break 10m --rounds N]');
425
+ if (!CAMPAIGN_ID.test(id)) return ctx.fail(`campaign ids look like cmp-<lowercase-words>, not ${id}`);
426
+ if (args.includes('--status')) {
427
+ const status = await cloudRunStatus(id, ctx.cloud);
428
+ if (!status.ok) return ctx.fail(`cloud run status failed: ${status.error}`, 1);
429
+ ctx.out(describeCloudRun(status.data), status.data);
430
+ return 0;
431
+ }
432
+ const body = {};
433
+ const work = readFlag(args, '--work', '');
434
+ const pause = readFlag(args, '--break', '');
435
+ const rounds = readFlag(args, '--rounds', '');
436
+ const { parseDuration } = require('../lib/campaign-cadence');
437
+ try {
438
+ if (work) { parseDuration(work, 0); body.work = work; }
439
+ if (pause) { parseDuration(pause, 0); body.break = pause; }
440
+ } catch (err) {
441
+ return ctx.fail(err.message);
442
+ }
443
+ if (rounds) {
444
+ if (!/^[1-9][0-9]*$/.test(rounds)) return ctx.fail('--rounds is a whole number of rounds, 1 or more');
445
+ body.max_rounds = Number(rounds);
446
+ }
447
+ let synced = null;
448
+ const local = getCampaign(ctx.store, id);
449
+ if (local && !local.error) {
450
+ synced = await syncCampaign(ctx.store, id, ctx.cloud);
451
+ if (!synced.ok) return ctx.fail(`not started: the cloud needs this campaign first, and sync failed: ${synced.error}`, 1);
452
+ }
453
+ const started = await runInCloud(id, body, ctx.cloud);
454
+ if (!started.ok) return ctx.fail(`not started in the cloud: ${started.error}`, 1);
455
+ const run = started.data;
456
+ const lines = [
457
+ `running ${id} on your Atris computer (${run.computer}), run ${run.run_id}`,
458
+ `work ${run.work} · break ${run.break} · ${run.max_rounds ? `${run.max_rounds} round${run.max_rounds === 1 ? '' : 's'}` : 'until done, out of money, or needing you'} · stops by ${run.deadline}`,
459
+ ];
460
+ if (run.woke) lines.push('your computer was asleep; it is awake now and starts in about two minutes');
461
+ if (run.recorded === false) lines.push('note: the run started, but the campaign did not record its new home; atris campaign sync will still show its progress');
462
+ lines.push(`check on it: atris campaign cloud-run ${id} --status`);
463
+ ctx.out(lines.join('\n'), { ...run, ...(synced ? { sync: synced } : {}) });
464
+ return 0;
465
+ }
466
+
467
+ function describeCloudRun(data) {
468
+ const run = data.run;
469
+ const lines = [`${data.campaign_id} ${String(data.state || '').replace(/_/g, ' ')} · home ${data.home || 'unknown'}`, data.summary || ''];
470
+ if (run) {
471
+ lines.push(`run ${run.run_id} on ${run.computer}: ${run.status}${run.exit_code === null || run.exit_code === undefined ? '' : ` (exit ${run.exit_code})`}${run.timed_out ? ', stopped at its deadline' : ''}`);
472
+ if (run.output_tail) lines.push('', 'last output:', run.output_tail.trimEnd());
473
+ }
474
+ return lines.filter((line, i) => line || i > 1).join('\n');
475
+ }
476
+
477
+ function cmdShow(args, ctx) {
478
+ const { view, error } = requireCampaign(ctx, positionals(args)[1]);
479
+ if (error !== undefined) return error;
480
+ ctx.out(renderShow(view), { ok: true, campaign: view, next: decide(view) });
481
+ return 0;
482
+ }
483
+
484
+ // Change the judge. Like a new campaign, the new judge must fail first
485
+ // (unless the campaign is standing), so nobody swaps in a judge that passes.
486
+ async function cmdSetJudge(args, ctx) {
487
+ const words = positionals(args);
488
+ const { view, error } = requireCampaign(ctx, words[1]);
489
+ if (error !== undefined) return error;
490
+ const judge = words[2];
491
+ if (!judge) return ctx.fail('name the new judge: atris campaign set-judge <id> "<command that fails today>"');
492
+ if (ctx.actor !== view.owner && !String(ctx.actor).startsWith('human:')) {
493
+ return ctx.fail(`only the owner (${view.owner}) or a person can change the judge`);
494
+ }
495
+ const timeout = Number(readFlag(args, '--timeout', '')) || JUDGE_TIMEOUT_SECONDS;
496
+ if (!ctx.asJson) console.error(`running the new judge first: ${judge}`);
497
+ const reading = await ctx.judge(judge, timeout, view.id);
498
+ if (reading.passed && !view.standing) {
499
+ return ctx.fail(`the new judge already passes (${reading.summary || 'exit 0'}). Write a judge that fails first.`, 1);
500
+ }
501
+ ctx.append(view.id, 'judge_set', { judge, from: view.judge });
502
+ ctx.append(view.id, 'judge_ran', { passed: reading.passed, summary: reading.summary || `exit ${reading.exit}`, seconds: reading.seconds }, 'judge');
503
+ const after = getCampaign(ctx.store, view.id);
504
+ ctx.out(`judge changed to: ${judge}\n${renderShow(after)}`, { ok: true, action: 'judge_set', campaign: after, next: decide(after) });
505
+ return 0;
506
+ }
507
+
508
+ async function cmdJudge(args, ctx) {
509
+ const { view, error } = requireCampaign(ctx, positionals(args)[1]);
510
+ if (error !== undefined) return error;
511
+ const timeout = Number(readFlag(args, '--timeout', '')) || JUDGE_TIMEOUT_SECONDS;
512
+ if (!ctx.asJson) console.error(`running the judge: ${view.judge}`);
513
+ const reading = await ctx.judge(view.judge, timeout, view.id);
514
+ ctx.append(view.id, 'judge_ran', judgeRanData(reading), 'judge');
515
+ const after = getCampaign(ctx.store, view.id);
516
+ const verdict = reading.passed ? 'passed' : 'failed';
517
+ ctx.out(`judge ${verdict} in ${reading.seconds}s: ${reading.summary || `exit ${reading.exit}`}\n${renderShow(after)}`,
518
+ { ok: true, action: 'judge_ran', passed: reading.passed, reading, campaign: after, next: decide(after) });
519
+ return reading.passed ? 0 : 1;
520
+ }
521
+
522
+ // Exit 1 when a protected path changed since start, naming each one.
523
+ function cmdGuard(args, ctx) {
524
+ const { view, error } = requireCampaign(ctx, positionals(args)[1]);
525
+ if (error !== undefined) return error;
526
+ const saved = readProtect(ctx.campaignHome, view.id);
527
+ if (!saved) {
528
+ ctx.out(`${view.id} protects nothing`, { ok: true, campaign_id: view.id, paths: [], changed: [] });
529
+ return 0;
530
+ }
531
+ const root = checkoutTop(process.cwd());
532
+ if (!root) return ctx.fail('guard needs the git checkout the campaign started in');
533
+ const result = changedProtected(root, saved);
534
+ if (result.error) return ctx.fail(result.error);
535
+ const body = { ok: result.changed.length === 0, campaign_id: view.id, base_commit: saved.base_commit, paths: saved.paths, changed: result.changed };
536
+ const since = String(saved.base_commit).slice(0, 12);
537
+ if (result.changed.length) {
538
+ if (ctx.asJson) console.log(JSON.stringify(body));
539
+ else console.error(`protected path${result.changed.length === 1 ? '' : 's'} changed since ${since}:\n${result.changed.map((p) => ` ${p}`).join('\n')}`);
540
+ return 1;
541
+ }
542
+ ctx.out(`no protected path changed since ${since} (${saved.paths.join(', ')})`, body);
543
+ return 0;
544
+ }
545
+
546
+ // toCloud(data) names the cloud person route for events only a person's
547
+ // screen may send (api.md): on a synced campaign a person's answer or budget
548
+ // goes there and comes back by pull. Offline, it is recorded here instead.
549
+ function simpleEvent(type, buildData, describe, toCloud = null) {
550
+ return async (args, ctx) => {
551
+ const words = positionals(args);
552
+ const { view, error } = requireCampaign(ctx, words[1]);
553
+ if (error !== undefined) return error;
554
+ const built = buildData(args, words, view);
555
+ if (built.error) return ctx.fail(built.error);
556
+ let via = 'local';
557
+ if (toCloud && isHumanActor(ctx.actor) && cloudLink(ctx.store, view.id)) {
558
+ const sent = await sendPersonAction(ctx.store, view.id, toCloud(built.data), ctx.cloud);
559
+ if (sent.ok) via = 'cloud';
560
+ else if (!ctx.asJson) console.error(`recorded here only; the cloud did not take it: ${sent.error}`);
561
+ }
562
+ if (via === 'local') ctx.append(view.id, type, built.data);
563
+ const after = getCampaign(ctx.store, view.id);
564
+ ctx.out(`${describe(built.data, after)}\n${renderShow(after)}`, { ok: true, action: type, via, campaign: after, next: decide(after) });
565
+ return 0;
566
+ };
567
+ }
568
+
569
+ function seatRole(member) {
570
+ const { autoJobForMember } = require('../lib/member-engine');
571
+ return autoJobForMember(member);
572
+ }
573
+
574
+ const cmdSeat = simpleEvent('seat_filled', (args, words) => {
575
+ const member = words[2];
576
+ if (!member) return { error: 'name the member: atris campaign seat <id> <member> [--role <role>]' };
577
+ return { data: { member, role: readFlag(args, '--role', '') || seatRole(member) } };
578
+ }, (d) => `seated ${d.member} as ${d.role}`);
579
+
580
+ const cmdBet = simpleEvent('bet_set', (args, words) => {
581
+ const bet = words[2];
582
+ if (!bet) return { error: 'write the bet: atris campaign bet <id> "<bet>" [--wrong-if "..."]' };
583
+ const wrongIf = readFlag(args, '--wrong-if', '');
584
+ return { data: wrongIf ? { bet, wrong_if: wrongIf } : { bet } };
585
+ }, (d) => `bet set: ${d.bet}`);
586
+
587
+ const cmdBudget = simpleEvent('budget_added', (args) => {
588
+ const amount = Number(readFlag(args, '--add', ''));
589
+ if (!(amount > 0)) return { error: 'say how much to add: atris campaign budget <id> --add <dollars>' };
590
+ return { data: { amount_usd: amount } };
591
+ }, (d, view) => `added ${money(d.amount_usd)}; budget is now ${money(view.budget.total)}`, (d) => ({ action: 'add_budget', amount_usd: d.amount_usd }));
592
+
593
+ // The no-reload line: anything that cannot be undone waits here for a person.
594
+ const cmdAsk = simpleEvent('ask_opened', (args, words) => {
595
+ const question = words[2];
596
+ if (!question) return { error: 'write the question: atris campaign ask <id> "<question>" [--mission <mission-id>]' };
597
+ const askId = `a-${require('crypto').randomBytes(3).toString('hex')}`;
598
+ const mission = readFlag(args, '--mission', '');
599
+ return { data: mission ? { ask_id: askId, question, mission_id: mission } : { ask_id: askId, question } };
600
+ }, (d) => `asked ${d.ask_id}: ${d.question}`);
601
+
602
+ const cmdAnswer = simpleEvent('ask_answered', (args, words) => {
603
+ const askId = words[2];
604
+ const answer = words[3];
605
+ if (!askId || !answer) return { error: 'answer an ask: atris campaign answer <id> <ask-id> "<answer>"' };
606
+ return { data: { ask_id: askId, answer } };
607
+ }, (d, view) => (view.asks.some((a) => a.id === d.ask_id) ? `not closed: only a person can answer ${d.ask_id}` : `answered ${d.ask_id}: ${d.answer}`), (d) => ({ ask_id: d.ask_id, answer: d.answer }));
608
+
609
+ const cmdPause = simpleEvent('paused', () => ({ data: {} }), () => 'paused');
610
+ const cmdResume = simpleEvent('resumed', () => ({ data: {} }), () => 'resumed');
611
+ const cmdStop = simpleEvent('stopped', () => ({ data: {} }), () => 'stopped');
612
+
613
+ const cmdEvent = (args, ctx) => {
614
+ const words = positionals(args);
615
+ const { view, error } = requireCampaign(ctx, words[1]);
616
+ if (error !== undefined) return error;
617
+ const type = words[2];
618
+ if (!type) return ctx.fail('name the event type: atris campaign event <id> <type> [--data \'<json>\']');
619
+ // Events that only the judge or a person may write have their own verbs.
620
+ const guarded = { judge_ran: 'atris campaign judge', ask_answered: 'atris campaign answer', done: 'atris campaign stop, or a passing judge', budget_added: 'atris campaign budget --add', campaign_started: 'atris campaign start' };
621
+ if (guarded[type]) return ctx.fail(`${type} cannot be written by hand; use ${guarded[type]}`);
622
+ let data;
623
+ try {
624
+ data = JSON.parse(readFlag(args, '--data', '') || '{}');
625
+ } catch {
626
+ return ctx.fail('--data must be JSON, for example --data \'{"mission_id":"m1"}\'');
627
+ }
628
+ try {
629
+ ctx.append(view.id, type, data);
630
+ } catch (err) {
631
+ if (err.code === 'invalid_event' || err.code === 'mission_in_other_campaign') return ctx.fail(err.message);
632
+ throw err;
633
+ }
634
+ const after = getCampaign(ctx.store, view.id);
635
+ ctx.out(`recorded ${type}\n${renderShow(after)}`, { ok: true, action: type, campaign: after, next: decide(after) });
636
+ return 0;
637
+ };
638
+
639
+ async function cmdLoop(args, ctx, verb) {
640
+ const { view, error } = requireCampaign(ctx, positionals(args)[1]);
641
+ if (error !== undefined) return error;
642
+ const { makeLoop } = require('../lib/campaign-loop');
643
+ // A synced campaign trades events around every step. Failures warn and the
644
+ // loop runs on offline; a refusal (403, 409) stops syncing for this run
645
+ // instead of asking again every step.
646
+ const syncWarnings = [];
647
+ let refused = false;
648
+ let failing = false;
649
+ const sync = cloudLink(ctx.store, view.id) ? async (id) => {
650
+ if (refused) return 0;
651
+ const result = await syncCampaign(ctx.store, id, ctx.cloud);
652
+ if (result.ok) {
653
+ failing = false;
654
+ return result.pulled;
655
+ }
656
+ if (result.status === 403 || result.status === 409) refused = true;
657
+ // One warning per stretch of failures, not one per step.
658
+ if (!failing || refused) {
659
+ const warning = `sync: ${result.error}${refused ? '; not syncing again this run' : '; running on offline'}`;
660
+ syncWarnings.push(warning);
661
+ if (!ctx.asJson) console.error(warning);
662
+ }
663
+ failing = true;
664
+ return 0;
665
+ } : undefined;
666
+ const loop = makeLoop(process.cwd(), {
667
+ parallel: readFlag(args, '--parallel', '') || readFlag(args, '--count', ''),
668
+ maxTicks: readFlag(args, '--max-ticks', ''),
669
+ runner: readFlag(args, '--runner', ''),
670
+ actor: ctx.actor,
671
+ }, {
672
+ store: ctx.store,
673
+ log: ctx.asJson ? () => {} : (line) => console.log(line),
674
+ judge: (command) => ctx.judge(command, JUDGE_TIMEOUT_SECONDS, view.id),
675
+ lessons: (id) => require('../lib/campaign-cadence').readLessons(process.cwd(), id),
676
+ ...(sync ? { sync } : {}),
677
+ ...(ctx.loopDeps || {}),
678
+ });
679
+ if (verb === 'propose') {
680
+ const proposals = await loop.propose(view.id);
681
+ const after = getCampaign(ctx.store, view.id);
682
+ ctx.out(proposals.length ? `${proposals.length} proposed and claimed\n${renderShow(after)}` : 'nothing usable was proposed', { ok: proposals.length > 0, proposals, campaign: after, next: decide(after) });
683
+ return proposals.length ? 0 : 1;
684
+ }
685
+ if (verb === 'step') {
686
+ const result = await loop.step(view.id);
687
+ const after = getCampaign(ctx.store, view.id);
688
+ ctx.out(`${result.stop ? `stopped: ${result.reason}` : `did: ${result.did}`}\n${renderShow(after)}`, { ok: true, ...result, campaign: after, next: decide(after), sync_warnings: syncWarnings });
689
+ return 0;
690
+ }
691
+ const work = readFlag(args, '--work', '');
692
+ const pause = readFlag(args, '--break', '');
693
+ const rounds = readFlag(args, '--rounds', '');
694
+ if (work || pause || rounds) {
695
+ const { makeCadence } = require('../lib/campaign-cadence');
696
+ const { askOwner } = require('../lib/campaign-loop');
697
+ let cadence;
698
+ try {
699
+ cadence = makeCadence(process.cwd(), loop, { work, break: pause, rounds }, { store: ctx.store, log: ctx.asJson ? () => {} : (line) => console.log(line), ask: (ctx.loopDeps && ctx.loopDeps.ask) || askOwner });
700
+ } catch (err) {
701
+ return ctx.fail(err.message);
702
+ }
703
+ const result = await cadence.run(view.id);
704
+ ctx.out(`stopped after ${result.rounds} round${result.rounds === 1 ? '' : 's'}: ${result.reason}\n${renderShow(result.campaign)}`, { ok: true, ...result, next: decide(result.campaign) });
705
+ return 0;
706
+ }
707
+ const maxSteps = Number(readFlag(args, '--max-steps', '')) || 20;
708
+ const result = await loop.run(view.id, maxSteps);
709
+ ctx.out(`stopped after ${result.steps} step${result.steps === 1 ? '' : 's'}: ${result.reason}\n${renderShow(result.campaign)}`, { ok: true, ...result, next: decide(result.campaign), sync_warnings: syncWarnings });
710
+ return 0;
711
+ }
712
+
713
+ const VERBS = {
714
+ start: cmdStart, show: cmdShow, judge: cmdJudge, guard: cmdGuard, seat: cmdSeat, bet: cmdBet, budget: cmdBudget,
715
+ pause: cmdPause, resume: cmdResume, stop: cmdStop, event: cmdEvent,
716
+ ask: cmdAsk, answer: cmdAnswer, 'set-judge': cmdSetJudge, sync: cmdSync, 'cloud-run': cmdCloudRun,
717
+ run: (args, ctx) => cmdLoop(args, ctx, 'run'),
718
+ step: (args, ctx) => cmdLoop(args, ctx, 'step'),
719
+ propose: (args, ctx) => cmdLoop(args, ctx, 'propose'),
720
+ // atris campaign new "<goal>": the owner's engine drafts a judge that fails today (lib/campaign-new.js).
721
+ new: (args, ctx) => require('../lib/campaign-new').draftAndStart(args, ctx, { start: cmdStart, goal: positionals(args)[1], ask: ctx.loopDeps && ctx.loopDeps.ask, confirm: ctx.loopDeps && ctx.loopDeps.confirm }),
722
+ export: (args, ctx) => require('../lib/campaign-trace').exportTrace(args, ctx),
723
+ features: (args, ctx) => {
724
+ ctx.out(`this atris CLI can: ${CLOUD_FEATURES.join(', ')}`, { ok: true, features: CLOUD_FEATURES });
725
+ return 0;
726
+ },
727
+ };
728
+
729
+ function campaignCommand(args = [], deps = {}) {
730
+ // Taken out of the environment before anything else runs (lib/campaign-sync.js).
731
+ const campaignToken = takeCampaignToken(deps.env || process.env);
732
+ if (campaignToken) deps = { ...deps, cloud: { ...(deps.cloud || {}), campaignToken } };
733
+ if (!deps.store) moveToCheckoutRoot(args.includes('--json'));
734
+ const verb = positionals(args)[0];
735
+ if (verb === 'help' || args.includes('--help') || args.includes('-h')) {
736
+ printHelp();
737
+ return 0;
738
+ }
739
+ const ctx = makeContext(args, deps);
740
+ ctx.campaignHome = deps.campaignHome || campaignHome(ctx.store);
741
+ if (ctx.forgedHuman) return ctx.fail('--as cannot name a person; a person runs atris in their own terminal (or sets ATRIS_HUMAN=1)');
742
+ if (!verb || verb === 'list') {
743
+ const all = listCampaigns(ctx.store);
744
+ const views = all.filter((view) => !view.error);
745
+ const unreadable = all.filter((view) => view.error);
746
+ const notes = unreadable.map((view) => `${view.id} cannot be read (${view.error})`);
747
+ ctx.out([renderList(views), ...notes].filter(Boolean).join('\n'), { ok: true, campaigns: views.map((view) => ({ ...view, next: decide(view) })), unreadable });
748
+ return 0;
749
+ }
750
+ const handler = VERBS[verb];
751
+ if (handler) return handler(args, ctx);
752
+ // A sentence where the verb goes is a title: it starts a campaign with the
753
+ // same flags as start. One word is more likely a mistyped verb, so it is not.
754
+ // With no --judge, the owner's engine drafts one first, as campaign new does.
755
+ if (/\s/.test(verb.trim())) return args.includes('--judge') ? cmdStart(['start', ...args], ctx) : VERBS.new(['new', ...args], ctx);
756
+ return ctx.fail(`unknown verb ${verb}. Verbs: ${Object.keys(VERBS).join(', ')}. To start a campaign, write the goal as a sentence: atris campaign "<goal>" [--judge "<cmd>"]`);
757
+ }
758
+
759
+ module.exports = { campaignCommand, renderShow, runJudgeCommand };