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
@@ -8,16 +8,25 @@
8
8
  const path = require('path');
9
9
  const crypto = require('crypto');
10
10
  const { spawn } = require('child_process');
11
- const { decide } = require('./campaign-core');
11
+ const { compareEvents, decide, fold } = require('./campaign-core');
12
+ const { validateEvent } = require('./campaign-schema');
12
13
  const { getCampaign, openCampaignStore } = require('./campaign-store');
13
- const { defaultReserve, reconcileCampaign } = require('./campaign-missions');
14
- const { judgeRanData, latestJudgeOutput } = require('./campaign-reading');
15
- const { LANE_ASK_KIND, pausedLane, laneAskOpen, laneQuestion } = require('./campaign-lanes');
14
+ const { defaultReserve, missionCost, reconcileCampaign } = require('./campaign-missions');
15
+ const { confirmedReading, judgeRanData, latestJudgeOutput } = require('./campaign-reading');
16
+ const { LANE_ASK_KIND, pausedLane, laneAskOpen, laneQuestion, openLaneAsks } = require('./campaign-lanes');
16
17
 
17
18
  const TERMINAL = new Set(['stopped', 'complete']);
18
19
 
19
20
  const CLI = path.join(__dirname, '..', 'bin', 'atris.js');
20
21
  const PROPOSE_TIMEOUT_MS = 5 * 60 * 1000;
22
+ // A campaign only the outside world can move checks its judge again after 10
23
+ // minutes, doubling while the reading stays the same, up to an hour.
24
+ const WAIT_FIRST_MS = 10 * 60 * 1000;
25
+ const WAIT_MAX_MS = 60 * 60 * 1000;
26
+
27
+ // Two readings are the same when they pass or fail alike and say the same thing.
28
+ const readingKey = (passed, reading, summary) => JSON.stringify([Boolean(passed), typeof reading === 'number' ? reading : null, summary || null]);
29
+ const clockTime = (ms) => new Date(ms).toISOString().slice(11, 16) + ' UTC';
21
30
 
22
31
  function claimKey(objective) {
23
32
  return String(objective).toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim().slice(0, 160);
@@ -28,12 +37,78 @@ function proposalId(objective) {
28
37
  return `m-${slug || 'mission'}-${crypto.randomBytes(3).toString('hex')}`;
29
38
  }
30
39
 
40
+ function rankCampaignMembers(view, rsiSummary = null, events = null) {
41
+ const seats = Array.isArray(view && view.seats) ? view.seats.map((s) => s.member).filter(Boolean) : [];
42
+ const candidates = [...new Set([view && view.owner, ...seats].filter(Boolean))];
43
+ const rsiMap = new Map();
44
+ if (rsiSummary && Array.isArray(rsiSummary.members)) {
45
+ for (const row of rsiSummary.members) {
46
+ if (row && row.member) rsiMap.set(row.member, row);
47
+ }
48
+ }
49
+ const missions = completionOrderedMissions(view, events);
50
+ const raw = candidates.map((member) => {
51
+ const info = rsiMap.get(member) || null;
52
+ const overall = info && typeof info.overall === 'number' ? info.overall : 75;
53
+ const overallLabel = (info && (info.overallLabel || info.note)) || 'unproven';
54
+ const repeatFailures = info && typeof info.repeatFailures === 'number'
55
+ ? info.repeatFailures
56
+ : (info && typeof info.repeat_failures === 'number' ? info.repeat_failures : 0);
57
+ const mine = missions.filter((m) => m && m.member === member && m.status === 'ended');
58
+ let streak = 0;
59
+ for (let i = mine.length - 1; i >= 0; i -= 1) {
60
+ const outcome = String(mine[i].outcome || '');
61
+ if (outcome && outcome !== 'kept' && !mine[i].credited) streak += 1;
62
+ else break;
63
+ }
64
+ const cold = streak >= 2 || (overall < 60 && repeatFailures >= 3);
65
+ return { member, overall, overallLabel, consecutiveFailures: streak, cold, benched: false };
66
+ });
67
+ const hasWarmAlternative = raw.some((r) => !r.cold);
68
+ for (const row of raw) {
69
+ if (row.cold && hasWarmAlternative && raw.length > 1) row.benched = true;
70
+ }
71
+ return raw.sort((a, b) => {
72
+ if (a.benched !== b.benched) return a.benched ? 1 : -1;
73
+ if (b.overall !== a.overall) return b.overall - a.overall;
74
+ return a.consecutiveFailures - b.consecutiveFailures;
75
+ });
76
+ }
77
+
78
+ // Callers supply the complete history behind this view. Coverage checks can
79
+ // reject a mismatch, but cannot prove that a partial history is complete.
80
+ function completionOrderedMissions(view, events) {
81
+ const missions = Array.isArray(view && view.missions) ? view.missions : [];
82
+ if (!Array.isArray(events)) return missions;
83
+ const seen = new Set();
84
+ const ends = new Map();
85
+ let started = false;
86
+ for (const event of events) {
87
+ if (!validateEvent(event).ok || event.campaign_id !== (view && view.id)) return missions;
88
+ if (seen.has(event.id)) continue;
89
+ seen.add(event.id);
90
+ if (event.type === 'campaign_started') started = true;
91
+ if (event.type !== 'mission_ended') continue;
92
+ const previous = ends.get(event.data.mission_id);
93
+ if (!previous || compareEvents(previous, event) < 0) ends.set(event.data.mission_id, event);
94
+ }
95
+ const ended = missions.filter((m) => m && m.status === 'ended');
96
+ if (!started || ends.size !== ended.length || ended.some((m) => !ends.has(m.id) || ends.get(m.id).data.outcome !== m.outcome)) return missions;
97
+ return ended.sort((a, b) => compareEvents(ends.get(a.id), ends.get(b.id)));
98
+ }
99
+
31
100
  // What the owner's engine reads before proposing: the goal, how it is judged,
32
101
  // what has been tried, and how it went.
33
- function proposalPrompt(view, count, judgeOutput = null, lessons = '') {
34
- 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';
35
- const seats = view.seats.map((s) => s.member).filter(Boolean);
36
- const members = [...new Set([view.owner, ...seats])];
102
+ function proposalPrompt(view, count, judgeOutput = null, lessons = '', rsiSummary = null, events = null) {
103
+ const tried = view.missions.slice(-12).map((m) => `- ${m.id}: ${m.status}${m.outcome ? ` (${m.outcome})` : ''}${m.cost_usd !== null ? `, $${m.cost_usd}` : ''}${typeof m.reward === 'number' ? `, reward ${m.reward >= 0 ? '+' : ''}${m.reward}` : ''}`).join('\n') || '- none yet';
104
+ const ranked = rankCampaignMembers(view, rsiSummary, events);
105
+ const memberLine = ranked.map((r) => {
106
+ if (r.benched) return `${r.member} (${r.overall} ovr, benched after ${r.consecutiveFailures || 'repeat'} misses)`;
107
+ return rsiSummary ? `${r.member} (${r.overall} ovr, ${r.overallLabel})` : r.member;
108
+ }).join(', ');
109
+ const watchouts = ranked
110
+ .filter((r) => r.benched || r.consecutiveFailures > 0 || /repeat miss/i.test(r.overallLabel || ''))
111
+ .map((r) => `${r.member}: ${r.overallLabel}${r.benched ? ' (benched)' : ''}`);
37
112
  return [
38
113
  `You plan the next missions for an Atris campaign. Answer with JSON only.`,
39
114
  ``,
@@ -43,7 +118,8 @@ function proposalPrompt(view, count, judgeOutput = null, lessons = '') {
43
118
  judgeOutput ? `Last judge output:\n\`\`\`\n${judgeOutput.replace(/```/g, "'''")}\n\`\`\`` : '',
44
119
  view.bet ? `Current bet: ${view.bet.text}${view.bet.wrong_if ? ` (wrong if ${view.bet.wrong_if})` : ''}` : '',
45
120
  `Money left: $${view.budget.available.toFixed(2)} of $${view.budget.total.toFixed(2)}`,
46
- `Members who can take a mission: ${members.join(', ')}`,
121
+ `Members who can take a mission: ${memberLine}`,
122
+ watchouts.length ? `Seat watchouts: ${watchouts.join('; ')}` : '',
47
123
  `Missions so far:`,
48
124
  tried,
49
125
  lessons ? `Lessons from earlier rounds (follow them):\n${lessons}` : '',
@@ -55,7 +131,7 @@ function proposalPrompt(view, count, judgeOutput = null, lessons = '') {
55
131
  ].filter((line) => line !== '').join('\n');
56
132
  }
57
133
 
58
- function parseProposals(text, view, count) {
134
+ function parseProposals(text, view, count, rsiSummary = null, events = null) {
59
135
  const match = String(text || '').match(/\[[\s\S]*\]/);
60
136
  if (!match) return [];
61
137
  let rows;
@@ -65,12 +141,14 @@ function parseProposals(text, view, count) {
65
141
  return [];
66
142
  }
67
143
  if (!Array.isArray(rows)) return [];
68
- const members = new Set([view.owner, ...view.seats.map((s) => s.member)]);
144
+ const ranked = rankCampaignMembers(view, rsiSummary, events);
145
+ const activeMembers = new Set(ranked.filter((r) => !r.benched).map((r) => r.member));
146
+ const fallbackMember = (ranked.find((r) => !r.benched) || ranked[0] || { member: view.owner }).member;
69
147
  return rows.slice(0, count)
70
148
  .filter((row) => row && typeof row.objective === 'string' && row.objective.trim())
71
149
  .map((row) => ({
72
150
  objective: row.objective.trim().slice(0, 300),
73
- member: members.has(row.member) ? row.member : view.owner,
151
+ member: activeMembers.has(row.member) ? row.member : fallbackMember,
74
152
  verify: typeof row.verify === 'string' && row.verify.trim() ? row.verify.trim() : view.judge,
75
153
  estimate_usd: typeof row.estimate_usd === 'number' && row.estimate_usd > 0 ? Math.min(row.estimate_usd, view.budget.available) : null,
76
154
  }));
@@ -134,18 +212,32 @@ function makeLoop(root, options = {}, deps = {}) {
134
212
  const ask = deps.ask || askOwner;
135
213
  const cli = deps.cli || ((args, extra = {}) => runCli(args, { cwd: root, ...extra }));
136
214
  const judge = deps.judge;
215
+ const now = deps.now || Date.now;
137
216
 
138
217
  const view = (id) => getCampaign(store, id);
139
218
  const append = (id, type, data, as = actor) => store.append({ campaign_id: id, type, actor: as, data });
140
219
 
141
220
  async function propose(id, count = parallel) {
142
- const current = view(id);
143
- const prompt = proposalPrompt(current, count, latestJudgeOutput(store.read(id)), deps.lessons ? deps.lessons(id) : '');
221
+ const events = store.read(id);
222
+ const current = events.length ? fold(events) : null;
223
+ let rsiSummary = null;
224
+ try {
225
+ const { missionRewardMap, summarizeStoreRsi } = require('./campaign-rsi');
226
+ const rewards = missionRewardMap(events);
227
+ current.missions = current.missions.map((m) => {
228
+ const raw = rewards && typeof rewards.get === 'function' ? rewards.get(m.id) : (rewards && rewards[m.id]);
229
+ const reward = typeof raw === 'number' ? raw : (raw && typeof raw.reward === 'number' ? raw.reward : undefined);
230
+ return typeof reward === 'number' ? { ...m, reward } : m;
231
+ });
232
+ rsiSummary = summarizeStoreRsi(store);
233
+ } catch {}
234
+ const prompt = proposalPrompt(current, count, latestJudgeOutput(events), deps.lessons ? deps.lessons(id) : '', rsiSummary, events);
144
235
  const answer = await ask(root, current, prompt);
145
- const proposals = parseProposals(answer.text, current, count);
236
+ const proposals = parseProposals(answer.text, current, count, rsiSummary, events);
146
237
  // Planning costs money too. It rides on the first mission it produced as a
147
238
  // step, so it is counted when that mission ends without making the judge
148
- // due. A plan that produced nothing becomes its own ended mission.
239
+ // due. A plan that produced nothing is not failed work: the step saves a
240
+ // wait instead (the owner's engine reports no dollar cost to carry).
149
241
  const plan = { kind: 'plan', engine: answer.engine };
150
242
  if (typeof answer.cost_usd === 'number') plan.cost_usd = answer.cost_usd;
151
243
  proposals.forEach((proposal, index) => {
@@ -154,11 +246,6 @@ function makeLoop(root, options = {}, deps = {}) {
154
246
  append(id, 'mission_claimed', { mission_id: missionId, claim_key: claimKey(proposal.objective) }, proposal.member);
155
247
  if (index === 0) append(id, 'step', { mission_id: missionId, ...plan }, current.owner);
156
248
  });
157
- if (!proposals.length) {
158
- const planId = `plan-${crypto.randomBytes(3).toString('hex')}`;
159
- append(id, 'mission_started', { mission_id: planId, member: current.owner, kind: 'plan' }, current.owner);
160
- 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);
161
- }
162
249
  return proposals;
163
250
  }
164
251
 
@@ -183,7 +270,12 @@ function makeLoop(root, options = {}, deps = {}) {
183
270
  '--max-wall', String(missionMinutes * 60), '--json',
184
271
  ]);
185
272
  if (started.code !== 0) {
186
- const why = (started.out + started.err).trim().split('\n').pop();
273
+ let why = (started.out + started.err).trim().split('\n').pop();
274
+ try {
275
+ const error = JSON.parse(started.out);
276
+ const detail = [error?.detail, error?.error].find(value => typeof value === 'string' && value.trim());
277
+ if (detail) why = detail.trim();
278
+ } catch {}
187
279
  log(` ${work.mission_id} did not start: ${why}`);
188
280
  append(id, 'mission_vetoed', { mission_id: work.mission_id, reason: why.slice(0, 200) }, current.owner);
189
281
  return { mission_id: work.mission_id, started: false };
@@ -191,6 +283,7 @@ function makeLoop(root, options = {}, deps = {}) {
191
283
  log(` ${work.mission_id} started for ${work.member}: ${work.objective}`);
192
284
  // The engine's live steps ("researcher 06:47 editing stable.mjs") pass through to
193
285
  // the campaign's own output, so a screen watching the run can show the work.
286
+ const stepsBefore = (view(id).missions.find((m) => m.id === work.mission_id) || {}).steps || 0;
194
287
  const ran = await cli(['mission', 'run', work.mission_id, '--max-ticks', String(maxTicks), '--complete-on-pass'], { onLine: (line) => { if (ACTIVITY_LINE.test(line)) log(` ${line.trim()}`); } });
195
288
  const after = view(id).missions.find((m) => m.id === work.mission_id);
196
289
  // A mission in a protected lane (billing, security, deploy, customer) pauses
@@ -205,6 +298,13 @@ function makeLoop(root, options = {}, deps = {}) {
205
298
  log(` ${work.mission_id} needs you: it touches ${lane}`);
206
299
  return { mission_id: work.mission_id, started: true, outcome: null, needs: lane };
207
300
  }
301
+ if (after && after.status !== 'ended' && after.steps === stepsBefore) {
302
+ // The run left no step behind: the job never ran. Score it as a fault
303
+ // with the run's own words, not as failed work by the member.
304
+ const why = `${ran.err || ''}\n${ran.out || ''}`.trim().split('\n').filter(Boolean).pop() || `exit ${ran.code}`;
305
+ append(id, 'mission_ended', { mission_id: work.mission_id, outcome: 'fault', reason: why.slice(0, 200), cost_usd: missionCost(root, work.mission_id, { store }).cost_usd }, work.member);
306
+ log(` ${work.mission_id} never ran: ${why}`);
307
+ }
208
308
  if (after && after.status !== 'ended') {
209
309
  // The run stopped without finishing (tick cap, error): close it so its
210
310
  // reserve comes back and its cost is counted.
@@ -221,6 +321,23 @@ function makeLoop(root, options = {}, deps = {}) {
221
321
  // how many events it pulled.
222
322
  const sync = deps.sync || (async () => 0);
223
323
 
324
+ // Run the judge and record its reading. waitFor(data) returns how long to
325
+ // wait before the next check (0 for no wait); a passing reading never waits.
326
+ // A pass that would finish the campaign must pass a second, fresh run first.
327
+ async function readJudge(id, current, waitFor = () => 0) {
328
+ const run = () => judge(current.judge);
329
+ const reading = current.standing ? await run() : await confirmedReading(run);
330
+ const data = judgeRanData(reading);
331
+ const waitMs = data.passed ? 0 : waitFor(data);
332
+ if (waitMs > 0) data.wait = { wake_at: now() + waitMs, wait_ms: waitMs };
333
+ append(id, 'judge_ran', data, 'judge');
334
+ if (!deps.skipReconcile) {
335
+ try { require('./campaign-rsi').writeRsiSnapshot(root, store, id); } catch {}
336
+ }
337
+ log(`judge ${reading.passed ? 'passed' : 'failed'}: ${reading.summary || `exit ${reading.exit}`}`);
338
+ return data;
339
+ }
340
+
224
341
  // One step, with a sync on each side.
225
342
  async function step(id) {
226
343
  await sync(id);
@@ -240,29 +357,53 @@ function makeLoop(root, options = {}, deps = {}) {
240
357
  if (current.error) return { stop: true, reason: `campaign cannot be read: ${current.error}` };
241
358
  const move = decide(current);
242
359
  if (move.action === 'none') return { stop: true, reason: move.reason };
243
- if (move.action === 'wait' && move.reason === 'open asks') return { stop: true, reason: `needs you: ${move.asks.length} open ask${move.asks.length === 1 ? '' : 's'}` };
360
+ const needsYou = (n) => ({ stop: true, reason: `needs you: ${n} open ask${n === 1 ? '' : 's'}` });
361
+ if (move.action === 'wait' && move.reason === 'open asks') return needsYou(move.asks.length);
362
+ // A protected lane waiting on a person holds the whole campaign: the loop
363
+ // cannot tell which next plan would touch that lane again.
364
+ if (openLaneAsks(store.read(id)).size) return needsYou(current.asks.length);
365
+ // A saved wait is a promise: nothing runs before it is due, even in a new
366
+ // process. When it is due, only the judge runs. The same reading waits
367
+ // twice as long; a changed one lets the next step plan again.
368
+ const saved = current.next_move;
369
+ if (saved && saved.action === 'wait') {
370
+ if (now() < saved.wake_at) return { stop: true, reason: `waiting on the world: next check at ${clockTime(saved.wake_at)}` };
371
+ const before = current.last_judge ? readingKey(current.last_judge.passed, current.last_judge.reading, current.answer.text) : null;
372
+ const data = await readJudge(id, current, (d) => readingKey(d.passed, d.reading, d.summary) === before ? Math.min(saved.wait_ms * 2, WAIT_MAX_MS) : 0);
373
+ if (data.wait) return { stop: true, reason: `nothing changed: next check at ${clockTime(data.wait.wake_at)}` };
374
+ return { stop: false, did: 'judge' };
375
+ }
244
376
  if (move.action === 'run_judge') {
245
- const reading = await judge(current.judge);
246
- append(id, 'judge_ran', judgeRanData(reading), 'judge');
247
- log(`judge ${reading.passed ? 'passed' : 'failed'}: ${reading.summary || `exit ${reading.exit}`}`);
377
+ await readJudge(id, current);
248
378
  return { stop: false, did: 'judge' };
249
379
  }
250
380
  if (move.action === 'start_mission' || (move.action === 'wait' && move.reason === 'budget')) {
251
381
  const work = claimedWork(id).slice(0, parallel);
252
- if (!work.length) return { stop: true, reason: 'claimed missions have no objective to run; start them by hand or veto them' };
382
+ if (!work.length) return { ok: false, stop: true, reason: 'claimed missions have no objective to run; start them by hand or veto them' };
253
383
  log(`starting ${work.length} mission${work.length === 1 ? '' : 's'} at once`);
254
384
  const results = await Promise.all(work.map((w) => startAndRun(id, w)));
255
- if (!results.some((r) => r.started)) return { stop: true, reason: 'no claimed mission could start' };
385
+ if (!results.some((r) => r.started)) return { ok: false, stop: true, reason: 'no claimed mission could start' };
256
386
  return { stop: false, did: 'missions' };
257
387
  }
258
388
  if (move.action === 'wait') return { stop: true, reason: `waiting: ${move.reason} (started elsewhere)` };
259
389
  log(`asking ${current.owner} to propose up to ${parallel} missions`);
260
390
  const proposals = await propose(id, parallel);
261
- if (!proposals.length) return { stop: true, reason: `${current.owner} proposed nothing usable` };
391
+ if (!proposals.length) {
392
+ const data = await readJudge(id, current, () => WAIT_FIRST_MS);
393
+ if (data.passed) return { stop: false, did: 'judge' };
394
+ const next = data.wait ? `; next check at ${clockTime(data.wait.wake_at)}` : '';
395
+ return { ok: false, stop: true, reason: `${current.owner} proposed nothing usable${next}` };
396
+ }
262
397
  for (const p of proposals) log(` proposed for ${p.member}: ${p.objective}`);
263
398
  return { stop: false, did: 'propose' };
264
399
  }
265
400
 
401
+ // One tick: the one step that is due now, with a sync on each side. A timer
402
+ // or a woken process calls this; run() is for a person watching.
403
+ async function tick(id) {
404
+ return step(id);
405
+ }
406
+
266
407
  async function run(id, maxSteps = 20) {
267
408
  await sync(id);
268
409
  for (let i = 0; i < maxSteps; i++) {
@@ -270,12 +411,12 @@ function makeLoop(root, options = {}, deps = {}) {
270
411
  // An answer pulled just now can unblock a step that stopped for a person.
271
412
  const pulled = await sync(id);
272
413
  if (result.stop && pulled > 0) continue;
273
- if (result.stop) return { steps: i, reason: result.reason, campaign: view(id) };
414
+ if (result.stop) return { ...(result.ok === false ? { ok: false } : {}), steps: i, reason: result.reason, campaign: view(id) };
274
415
  }
275
416
  return { steps: maxSteps, reason: `stopped after ${maxSteps} steps`, campaign: view(id) };
276
417
  }
277
418
 
278
- return { step, run, propose };
419
+ return { step, tick, run, propose };
279
420
  }
280
421
 
281
- module.exports = { ACTIVITY_LINE, askOwner, makeLoop, parseProposals, proposalPrompt, runCli };
422
+ module.exports = { ACTIVITY_LINE, askOwner, makeLoop, parseProposals, proposalPrompt, rankCampaignMembers, readingKey, runCli };
@@ -136,6 +136,19 @@ function judgeRanData(result) {
136
136
  return data;
137
137
  }
138
138
 
139
+ // One pass can be luck: a flaky test, a race, a grader that rolls dice. A pass
140
+ // only counts when a second, fresh run of the same judge passes too. A failed
141
+ // second run is recorded as the failure it is, naming the pass it overturned.
142
+ async function confirmedReading(run) {
143
+ const first = await run();
144
+ if (!first.passed) return first;
145
+ const second = await run();
146
+ const seconds = (Number(first.seconds) || 0) + (Number(second.seconds) || 0);
147
+ if (second.passed) return { ...second, seconds };
148
+ const said = second.summary || lastSaidLine(second.output_tail) || `exit ${second.exit}`;
149
+ return { ...second, seconds, summary: `passed once, then failed a fresh run: ${said}` };
150
+ }
151
+
139
152
  // The output_tail of the latest judge run in fold order, or null.
140
153
  function latestJudgeOutput(events) {
141
154
  const { compareEvents } = require('./campaign-core');
@@ -144,4 +157,4 @@ function latestJudgeOutput(events) {
144
157
  return last && typeof last.data.output_tail === 'string' && last.data.output_tail ? last.data.output_tail : null;
145
158
  }
146
159
 
147
- module.exports = { judgeReading, judgeRanData, latestJudgeOutput, outputTail };
160
+ module.exports = { confirmedReading, judgeReading, judgeRanData, latestJudgeOutput, outputTail };