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
@@ -56,4 +56,12 @@ function acquireRunLock(home, campaignId, { pid = process.pid, host = os.hostnam
56
56
  return { ok: false, holder: readHolder(file) };
57
57
  }
58
58
 
59
- module.exports = { acquireRunLock, lockPath };
59
+ // Who drives this campaign right now, or null. Same rules as acquireRunLock, nothing written.
60
+ function liveHolder(home, campaignId, { host = os.hostname(), isAlive = alive } = {}) {
61
+ const holder = readHolder(lockPath(home, campaignId));
62
+ if (!holder) return null;
63
+ if (holder.host !== host) return holder;
64
+ return isAlive(holder.pid) ? holder : null;
65
+ }
66
+
67
+ module.exports = { acquireRunLock, liveHolder, lockPath };
@@ -0,0 +1,124 @@
1
+ // atris campaign schedule: a person says yes once, and the campaign starts on
2
+ // its own at set times until its budget is spent. The schedule lives beside the
3
+ // campaign's other files, not in its events, so other surfaces fold unchanged.
4
+ // `atris campaign wake` (the keep timer) and `atris campaign tick` fire it.
5
+ // Spec: atris/features/campaign/schedule.md.
6
+
7
+ 'use strict';
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ const MIN = 60 * 1000;
13
+ const DAY_NAMES = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
14
+ const DAY_SETS = { daily: [0, 1, 2, 3, 4, 5, 6], weekdays: [1, 2, 3, 4, 5], weekends: [0, 6] };
15
+ const DEFAULT_STEPS = 4;
16
+
17
+ const HOW = 'say when like "weekdays 09:00", "daily 18:30", "mon,thu 07:00", or "30m" / "2h"';
18
+
19
+ // "30m", "2h", "daily 09:00", "weekdays 09:00", "mon,wed 14:30", or "09:00"
20
+ // (every day). Clock times are local to the computer that runs the timer.
21
+ function parseEvery(text) {
22
+ const value = String(text || '').trim().toLowerCase();
23
+ if (!value) return { error: `a schedule needs a time: ${HOW}` };
24
+ const interval = /^(\d+)\s*(m|min|mins|minutes?|h|hr|hrs|hours?)$/.exec(value);
25
+ if (interval) {
26
+ const ms = Number(interval[1]) * (interval[2].startsWith('h') ? 60 : 1) * MIN;
27
+ if (ms < MIN) return { error: 'a schedule runs at most once a minute' };
28
+ return { rule: { kind: 'interval', ms }, every: value };
29
+ }
30
+ const parts = value.split(/\s+/);
31
+ const clock = /^(\d{1,2}):(\d{2})$/.exec(parts[parts.length - 1]);
32
+ if (!clock || parts.length > 2) return { error: `cannot read "${text}": ${HOW}` };
33
+ const hour = Number(clock[1]);
34
+ const minute = Number(clock[2]);
35
+ if (hour > 23 || minute > 59) return { error: `"${parts[parts.length - 1]}" is not a time of day` };
36
+ let days = DAY_SETS.daily;
37
+ if (parts.length === 2) {
38
+ days = DAY_SETS[parts[0]];
39
+ if (!days) {
40
+ days = [];
41
+ for (const name of parts[0].split(',')) {
42
+ const day = DAY_NAMES.indexOf(name.slice(0, 3));
43
+ if (day < 0 || (name.length > 3 && !name.startsWith(DAY_NAMES[day]))) return { error: `"${name}" is not a day: ${HOW}` };
44
+ if (!days.includes(day)) days.push(day);
45
+ }
46
+ days.sort();
47
+ }
48
+ }
49
+ return { rule: { kind: 'clock', days, hour, minute }, every: value };
50
+ }
51
+
52
+ // The next slot after `after` (epoch ms), on a whole minute so it is due at the
53
+ // minute it is shown, and the one-minute timer never runs it a minute late.
54
+ function nextAt(rule, after) {
55
+ if (rule.kind === 'interval') return Math.floor((after + rule.ms) / MIN) * MIN;
56
+ const at = new Date(after);
57
+ at.setSeconds(0, 0);
58
+ at.setHours(rule.hour, rule.minute);
59
+ for (let i = 0; i < 8; i++) {
60
+ if (at.getTime() > after && rule.days.includes(at.getDay())) return at.getTime();
61
+ at.setDate(at.getDate() + 1);
62
+ at.setHours(rule.hour, rule.minute, 0, 0);
63
+ }
64
+ return null;
65
+ }
66
+
67
+ function scheduleFile(home, campaignId) {
68
+ return path.join(home, 'campaigns', campaignId, 'schedule.json');
69
+ }
70
+
71
+ function readSchedule(home, campaignId) {
72
+ if (!home) return null;
73
+ try {
74
+ const saved = JSON.parse(fs.readFileSync(scheduleFile(home, campaignId), 'utf8'));
75
+ return saved && saved.rule && Number.isFinite(saved.next_at) ? saved : null;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ function writeSchedule(home, campaignId, schedule) {
82
+ const file = scheduleFile(home, campaignId);
83
+ fs.mkdirSync(path.dirname(file), { recursive: true });
84
+ const tmp = `${file}.${process.pid}.tmp`;
85
+ fs.writeFileSync(tmp, `${JSON.stringify(schedule, null, 2)}\n`);
86
+ fs.renameSync(tmp, file);
87
+ return schedule;
88
+ }
89
+
90
+ function saveSchedule(home, campaignId, { every, rule, steps = DEFAULT_STEPS, by, now }) {
91
+ return writeSchedule(home, campaignId, {
92
+ campaign_id: campaignId, every, rule, steps, approved_by: by,
93
+ approved_at: new Date(now).toISOString(), last_fired_at: null, next_at: nextAt(rule, now),
94
+ });
95
+ }
96
+
97
+ function removeSchedule(home, campaignId) {
98
+ const file = scheduleFile(home, campaignId);
99
+ if (!fs.existsSync(file)) return false;
100
+ fs.unlinkSync(file);
101
+ return true;
102
+ }
103
+
104
+ function isDue(schedule, now) {
105
+ return Boolean(schedule) && now >= schedule.next_at;
106
+ }
107
+
108
+ // Use up the slot before the run starts, so a run that crashes does not fire
109
+ // again every minute. A slot missed while the computer slept fires once.
110
+ function markFired(home, campaignId, schedule, now) {
111
+ return writeSchedule(home, campaignId, { ...schedule, last_fired_at: new Date(now).toISOString(), next_at: nextAt(schedule.rule, now) });
112
+ }
113
+
114
+ function whenText(ms) {
115
+ const at = new Date(ms);
116
+ const day = at.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
117
+ return `${day} ${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`;
118
+ }
119
+
120
+ function describe(schedule) {
121
+ return `${schedule.every}, up to ${schedule.steps} step${schedule.steps === 1 ? '' : 's'} each time; next ${whenText(schedule.next_at)}`;
122
+ }
123
+
124
+ module.exports = { DEFAULT_STEPS, describe, isDue, markFired, nextAt, parseEvery, readSchedule, removeSchedule, saveSchedule, scheduleFile, whenText };
@@ -32,7 +32,9 @@ const STATES = Object.freeze([
32
32
 
33
33
  const STOP_REASONS = Object.freeze(['judge_passed', 'stopped', 'done_by_human', 'budget_spent']);
34
34
 
35
- const MISSION_OUTCOMES = Object.freeze(['kept', 'reverted', 'failed']);
35
+ // A fault is a job that never ran (engine missing, crash before the first
36
+ // step): the setup broke, not the work.
37
+ const MISSION_OUTCOMES = Object.freeze(['kept', 'reverted', 'failed', 'fault']);
36
38
 
37
39
  // What a person or agent writes. Everything else is folded from events.
38
40
  const CAMPAIGN_FIELDS = Object.freeze({
@@ -267,6 +267,20 @@ function createMemoryStore(options = {}) {
267
267
  const missions = new Map();
268
268
  const seqByOrigin = new Map();
269
269
  const meta = new Map();
270
+ const eventIds = new Set();
271
+ const firstByOriginSeq = new Map();
272
+ let lookupIndexed = true;
273
+ const { isProxy } = require('node:util').types;
274
+ const indexable = (event) => {
275
+ // Reflection must not add caller Proxy traps or evaluate any getters.
276
+ if (isProxy(event) || event === null || typeof event !== 'object' || Object.getPrototypeOf(event) !== Object.prototype) return false;
277
+ const id = Object.getOwnPropertyDescriptor(event, 'id');
278
+ const origin = Object.getOwnPropertyDescriptor(event, 'origin');
279
+ const seq = Object.getOwnPropertyDescriptor(event, 'seq');
280
+ return id && Object.hasOwn(id, 'value') && typeof id.value === 'string'
281
+ && origin && Object.hasOwn(origin, 'value') && typeof origin.value === 'string'
282
+ && seq && Object.hasOwn(seq, 'value') && Number.isInteger(seq.value) && seq.value >= 1;
283
+ };
270
284
 
271
285
  const clone = (value) => JSON.parse(JSON.stringify(value));
272
286
  const count = (campaignId) => events.filter((e) => e.campaign_id === campaignId).length;
@@ -283,6 +297,18 @@ function createMemoryStore(options = {}) {
283
297
  if (missionId && !missions.has(missionId)) missions.set(missionId, event.campaign_id);
284
298
  seqByOrigin.set(event.origin, Math.max(seqByOrigin.get(event.origin) || 0, event.seq));
285
299
  events.push(clone(event));
300
+ if (lookupIndexed) {
301
+ // Index the row actually stored, after any reentrant toJSON commits.
302
+ const stored = events[events.length - 1];
303
+ if (!indexable(stored)) {
304
+ lookupIndexed = false;
305
+ } else {
306
+ eventIds.add(stored.id);
307
+ let sequences = firstByOriginSeq.get(stored.origin);
308
+ if (!sequences) firstByOriginSeq.set(stored.origin, sequences = new Map());
309
+ if (!sequences.has(stored.seq)) sequences.set(stored.seq, stored);
310
+ }
311
+ }
286
312
  };
287
313
 
288
314
  return {
@@ -306,8 +332,14 @@ function createMemoryStore(options = {}) {
306
332
  ingest(event) {
307
333
  const check = validateEvent(event);
308
334
  if (!check.ok) throw storeError('invalid_event', `invalid ${event && event.type}: ${check.errors.join(', ')}`);
309
- if (events.some((e) => e.id === event.id)) return false;
310
- const clash = events.find((e) => e.origin === event.origin && e.seq === event.seq);
335
+ let clash;
336
+ if (lookupIndexed && indexable(event)) {
337
+ if (eventIds.has(event.id)) return false;
338
+ clash = firstByOriginSeq.get(event.origin)?.get(event.seq);
339
+ } else {
340
+ if (events.some((e) => e.id === event.id)) return false;
341
+ clash = events.find((e) => e.origin === event.origin && e.seq === event.seq);
342
+ }
311
343
  if (clash) throw storeError('seq_conflict', `origin ${event.origin} seq ${event.seq} is already ${clash.id}`);
312
344
  checkMission(event);
313
345
  if (event.type === 'campaign_started' && !campaigns.has(event.campaign_id)) campaigns.set(event.campaign_id, event.at);
@@ -7,7 +7,7 @@
7
7
  'use strict';
8
8
 
9
9
  const crypto = require('crypto');
10
- const { compareEvents, fold } = require('./campaign-core');
10
+ const { compareEvents, fold, foldWithSummaryPrefixes } = require('./campaign-core');
11
11
  const { judgeReading } = require('./campaign-reading');
12
12
 
13
13
  const SCHEMA = 'atris.campaign.trace';
@@ -18,7 +18,7 @@ const PRIVACY = {
18
18
  full: 'Full: includes titles, objectives, verify commands, the judge command, judge summaries and output tails, and ask text. Export this only for your own data, or with the written consent of everyone in the campaign.',
19
19
  };
20
20
 
21
- const REWARD = 'reward = reading_after - reading_before, where a reading is the judge score in [0, 1] from the judge runs on either side of the mission. Missions that ended between the same two judge runs form a batch and split the change equally.';
21
+ const REWARD = 'reward = reading_after - reading_before, where a reading is the judge score in [0, 1] from the judge runs on either side of the mission. An accepted judge reset between those runs makes the comparison unknown. Missions that ended between the same two judge runs form a batch; eligible missions split the change equally. Setup faults never ran, so they have no work reward and take no share of that change.';
22
22
 
23
23
  function round(value, places = 6) {
24
24
  const scale = 10 ** places;
@@ -55,16 +55,14 @@ function foldOrder(events) {
55
55
  }
56
56
 
57
57
  function compactState(view, run) {
58
- const missions = { proposed: 0, claimed: 0, running: 0, ended: 0, vetoed: 0, dropped: 0 };
59
- for (const m of view.missions) missions[m.status] = (missions[m.status] || 0) + 1;
60
58
  return {
61
59
  state: view.state,
62
60
  reading: readingOf(run).value,
63
- confidence: view.answer.confidence,
61
+ confidence: view.confidence,
64
62
  judge_runs: view.judge_runs,
65
63
  budget: view.budget,
66
- missions,
67
- open_asks: view.asks.length,
64
+ missions: view.mission_counts,
65
+ open_asks: view.open_asks,
68
66
  };
69
67
  }
70
68
 
@@ -84,14 +82,20 @@ function buildTrace(events, options = {}) {
84
82
 
85
83
  const missions = new Map();
86
84
  const judgeRuns = [];
85
+ const judgeChanges = [];
86
+ // Use the fold's authorization decision rather than a second owner rule.
87
+ const ignoredJudgeChanges = new Set(final.warnings
88
+ .filter(warning => warning.code === 'judge_set_not_owner')
89
+ .map(warning => warning.event_id));
87
90
  const asks = new Map();
88
91
  const info = (id) => {
89
- if (!missions.has(id)) missions.set(id, { id, firstPos: null, endPos: null, objective: null, verify: null, member: null, engine: null, kind: 'mission', estimate: null, ticks: 0, uncostedTicks: 0, endData: null, asks: [] });
92
+ if (!missions.has(id)) missions.set(id, { id, firstPos: null, endPos: null, objective: null, verify: null, member: null, engine: null, executionEngines: [], kind: 'mission', estimate: null, ticks: 0, uncostedTicks: 0, endData: null, asks: [] });
90
93
  return missions.get(id);
91
94
  };
92
95
 
93
96
  sequence.forEach((event, pos) => {
94
97
  const d = event.data;
98
+ if (event.type === 'judge_set' && !ignoredJudgeChanges.has(event.id)) judgeChanges.push(pos);
95
99
  if (event.type === 'judge_ran') {
96
100
  judgeRuns.push({ pos, at: event.at, passed: d.passed, reading: d.reading, summary: d.summary || null, outputTail: d.output_tail || null });
97
101
  return;
@@ -117,8 +121,10 @@ function buildTrace(events, options = {}) {
117
121
  if (typeof d.engine === 'string' && event.type === 'mission_started' && d.kind === 'plan') m.engine = d.engine;
118
122
  if (typeof d.estimate_usd === 'number' && m.estimate === null) m.estimate = d.estimate_usd;
119
123
  if (event.type === 'mission_started' && d.kind === 'plan') m.kind = 'plan';
124
+ if (event.type === 'mission_started' && !m.startedAt) m.startedAt = event.at;
120
125
  if (event.type === 'step') {
121
126
  m.ticks += 1;
127
+ if (d.kind !== 'plan') m.executionEngines.push(typeof d.engine === 'string' && d.engine ? d.engine : null);
122
128
  if (!(typeof d.cost_usd === 'number' && Number.isFinite(d.cost_usd) && d.cost_usd >= 0)) m.uncostedTicks += 1;
123
129
  }
124
130
  if (event.type === 'mission_ended' && m.endPos === null) {
@@ -129,24 +135,53 @@ function buildTrace(events, options = {}) {
129
135
  });
130
136
  for (const ask of asks.values()) if (ask.mission_id && missions.has(ask.mission_id)) missions.get(ask.mission_id).asks.push(ask);
131
137
 
132
- // The view as it stood just before a position, folded once per position.
133
- const prefixViews = new Map();
134
- const viewBefore = (pos) => {
135
- if (!prefixViews.has(pos)) prefixViews.set(pos, fold(sequence.slice(0, Math.max(1, pos))));
136
- return prefixViews.get(pos);
137
- };
138
+ const ended = [...missions.values()].filter((m) => m.endPos !== null).sort((a, b) => a.endPos - b.endPos);
139
+ // Reuse one event traversal and retain only the compact state needed for
140
+ // ended missions. Reading lookup below still uses the original position.
141
+ const prefixStates = ended.length
142
+ ? foldWithSummaryPrefixes(sequence, ended.map((m) => Math.max(1, m.firstPos === null ? m.endPos : m.firstPos))).prefixes
143
+ : new Map();
138
144
  const lastRunBefore = (pos) => {
139
- let found = null;
140
- for (const run of judgeRuns) if (run.pos < pos) found = run;
141
- return found;
145
+ let lo = 0;
146
+ let hi = judgeRuns.length;
147
+ while (lo < hi) {
148
+ const mid = Math.floor((lo + hi) / 2);
149
+ if (judgeRuns[mid].pos < pos) lo = mid + 1;
150
+ else hi = mid;
151
+ }
152
+ return judgeRuns[lo - 1] || null;
153
+ };
154
+ const firstRunAfter = (pos) => {
155
+ let lo = 0;
156
+ let hi = judgeRuns.length;
157
+ while (lo < hi) {
158
+ const mid = Math.floor((lo + hi) / 2);
159
+ if (judgeRuns[mid].pos <= pos) lo = mid + 1;
160
+ else hi = mid;
161
+ }
162
+ return judgeRuns[lo] || null;
163
+ };
164
+ const judgeChangedBetween = (beforePos, afterPos) => {
165
+ let lo = 0;
166
+ let hi = judgeChanges.length;
167
+ while (lo < hi) {
168
+ const mid = Math.floor((lo + hi) / 2);
169
+ if (judgeChanges[mid] <= beforePos) lo = mid + 1;
170
+ else hi = mid;
171
+ }
172
+ return lo < judgeChanges.length && judgeChanges[lo] < afterPos;
142
173
  };
143
- const firstRunAfter = (pos) => judgeRuns.find((run) => run.pos > pos) || null;
144
174
 
145
175
  // Where the campaign finished, if it did: the first position whose prefix is done.
146
176
  let finishPos = null;
147
177
  if (final.state === 'done') {
148
178
  let lo = 0;
149
179
  let hi = sequence.length - 1;
180
+ for (const [count, snapshot] of prefixStates) {
181
+ if (snapshot.state === 'done') { hi = count - 1; break; }
182
+ lo = count;
183
+ }
184
+
150
185
  while (lo < hi) {
151
186
  const mid = Math.floor((lo + hi) / 2);
152
187
  if (fold(sequence.slice(0, mid + 1)).state === 'done') hi = mid;
@@ -156,7 +191,7 @@ function buildTrace(events, options = {}) {
156
191
  }
157
192
 
158
193
  const finalMissions = new Map(final.missions.map((m) => [m.id, m]));
159
- const ended = [...missions.values()].filter((m) => m.endPos !== null).sort((a, b) => a.endPos - b.endPos);
194
+ const outcomeOf = (m) => (finalMissions.get(m.id) || {}).outcome || m.endData.outcome;
160
195
 
161
196
  // A batch: missions that ended between the same two judge runs.
162
197
  const batches = new Map();
@@ -164,8 +199,10 @@ function buildTrace(events, options = {}) {
164
199
  const before = lastRunBefore(m.endPos);
165
200
  const after = firstRunAfter(m.endPos);
166
201
  const key = `${before ? before.pos : -1}:${after ? after.pos : 'open'}`;
167
- if (!batches.has(key)) batches.set(key, { index: batches.size + 1, before, after, missions: [] });
168
- batches.get(key).missions.push(m);
202
+ if (!batches.has(key)) batches.set(key, { index: batches.size + 1, before, after, missions: [], eligible: 0 });
203
+ const batch = batches.get(key);
204
+ batch.missions.push(m);
205
+ if (outcomeOf(m) !== 'fault') batch.eligible += 1;
169
206
  }
170
207
 
171
208
  const memberFor = (member) => (redact && typeof member === 'string' && member.startsWith('human:') ? 'human' : member);
@@ -178,23 +215,31 @@ function buildTrace(events, options = {}) {
178
215
  const after = firstRunAfter(m.endPos);
179
216
  const batch = batches.get(`${before ? before.pos : -1}:${after ? after.pos : 'open'}`);
180
217
  const size = batch.missions.length;
218
+ const outcome = outcomeOf(m);
219
+ const judgeChanged = before && after && judgeChangedBetween(before.pos, after.pos);
181
220
  const readBefore = readingOf(before);
182
- const readAfter = readingOf(after);
221
+ // A replacement baseline is real campaign state, but cannot score earlier
222
+ // work. Null also prevents a replacement pass becoming fallback RSI proof.
223
+ const readAfter = readingOf(judgeChanged ? null : after);
183
224
  const delta = readBefore.value !== null && readAfter.value !== null ? readAfter.value - readBefore.value : null;
184
225
  const startPos = m.firstPos === null ? m.endPos : m.firstPos;
185
- const view = viewBefore(startPos);
186
226
  const folded = finalMissions.get(m.id) || {};
187
227
  const uncosted = typeof m.endData.uncosted_runs === 'number' ? m.endData.uncosted_runs : m.uncostedTicks;
188
228
  let creditNote = null;
189
- if (!after) creditNote = 'no judge run after this mission yet, so it has no reward';
229
+ if (outcome === 'fault') creditNote = 'this mission never ran (setup fault), so it has no work reward and takes no share of the reading change';
230
+ else if (!after) creditNote = 'no judge run after this mission yet, so it has no reward';
190
231
  else if (!before) creditNote = 'no judge run before this mission, so it has no reward';
232
+ else if (judgeChanged) creditNote = 'the judge changed between readings, so this mission has no comparable reward';
233
+ else if (batch.eligible < size) creditNote = `one of ${batch.eligible} eligible missions in this batch; setup faults are excluded and the reading change is split equally`;
191
234
  else if (size > 1) creditNote = `one of ${size} missions that ended between the same two judge runs; the reading change is split equally`;
192
235
 
193
236
  const action = {
194
237
  mission_id: idFor(m.id),
195
238
  kind: m.kind,
196
239
  member: memberFor(folded.member || m.member),
197
- engine: m.engine || (typeof m.endData.engine === 'string' ? m.endData.engine : null),
240
+ engine: m.executionEngines.length
241
+ ? new Set(m.executionEngines).size === 1 ? m.executionEngines[0] : null
242
+ : m.engine || (typeof m.endData.engine === 'string' ? m.endData.engine : null),
198
243
  estimate_usd: m.estimate,
199
244
  };
200
245
  if (!redact) {
@@ -207,9 +252,9 @@ function buildTrace(events, options = {}) {
207
252
  step: i + 1,
208
253
  at: m.endAt,
209
254
  batch: { index: batch.index, size },
210
- state_before: compactState(view, lastRunBefore(startPos)),
255
+ state_before: compactState(prefixStates.get(Math.max(1, startPos)), lastRunBefore(startPos)),
211
256
  action,
212
- outcome: folded.outcome || m.endData.outcome,
257
+ outcome,
213
258
  cost_usd: folded.cost_usd === undefined || folded.cost_usd === null ? m.endData.cost_usd : folded.cost_usd,
214
259
  ticks: m.ticks,
215
260
  uncosted_ticks: uncosted,
@@ -217,12 +262,15 @@ function buildTrace(events, options = {}) {
217
262
  reading_before: readBefore.value,
218
263
  reading_after: readAfter.value,
219
264
  reading_source: readAfter.source,
220
- reward: delta === null ? null : round(delta / size),
265
+ reward: outcome === 'fault' || delta === null ? null : round(delta / batch.eligible),
221
266
  credit_note: creditNote,
222
267
  done: finishPos !== null && finishPos <= (after ? after.pos : lastPos),
223
- judge_passed: after ? after.passed : null,
268
+ judge_passed: after && !judgeChanged ? after.passed : null,
224
269
  };
225
270
  if (!redact) {
271
+ row.started_at = m.startedAt || null;
272
+ row.receipt_path = typeof m.endData.receipt_path === 'string' ? m.endData.receipt_path : null;
273
+ row.execution_engines = m.executionEngines;
226
274
  row.judge_summary = after ? after.summary : null;
227
275
  row.judge_output_tail = after ? after.outputTail : null;
228
276
  row.asks = m.asks.map((ask) => ({ question: ask.question, answer: ask.answer }));
@@ -93,7 +93,11 @@ const SYSTEM_TOOLS = {
93
93
 
94
94
  function defaultRun(cmd, args) {
95
95
  const full = SYSTEM_TOOLS[cmd];
96
- return spawnSync(full && fs.existsSync(full) ? full : cmd, args, { encoding: 'utf8', timeout: 3000 });
96
+ const options = { encoding: 'utf8', timeout: 3000 };
97
+ // Environment listings grow with local jobs; keep a finite allowance above
98
+ // Node's default 1 MiB without changing the deadline or failed-read policy.
99
+ if (cmd === 'ps') options.maxBuffer = 8 * 1024 * 1024;
100
+ return spawnSync(full && fs.existsSync(full) ? full : cmd, args, options);
97
101
  }
98
102
 
99
103
  function parseFreePct(text) {