atris 3.70.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 (76) hide show
  1. package/README.md +18 -0
  2. package/atris/skills/README.md +1 -0
  3. package/atris/skills/campaigns/SKILL.md +81 -0
  4. package/atris/skills/meta/SKILL.md +52 -8
  5. package/ax +226 -42
  6. package/bin/atris.js +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 +359 -94
  20. package/commands/mission.js +249 -36
  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 +71 -17
  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 +108 -15
  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 +33 -3
  50. package/lib/engine-registry.js +2 -0
  51. package/lib/engine-resource.js +518 -0
  52. package/lib/fleet.js +125 -16
  53. package/lib/init-commit.js +80 -0
  54. package/lib/intents.js +8 -0
  55. package/lib/known-commands.js +1 -1
  56. package/lib/land-green.js +58 -7
  57. package/lib/landing-proof.js +352 -0
  58. package/lib/member-autoland.js +395 -18
  59. package/lib/member-board.js +7 -1
  60. package/lib/member-map-fold.js +126 -0
  61. package/lib/member-map-inbox.js +80 -0
  62. package/lib/member-progress.js +48 -1
  63. package/lib/member-report.js +127 -8
  64. package/lib/member-self-next-job.js +97 -0
  65. package/lib/member-task-pick.js +41 -14
  66. package/lib/member-up.js +249 -16
  67. package/lib/roster-prep.js +25 -2
  68. package/lib/runner-command.js +5 -0
  69. package/lib/task-db.js +47 -11
  70. package/lib/task-explanation.js +7 -0
  71. package/lib/task-list-keeper.js +8 -1
  72. package/lib/task-receipt.js +6 -1
  73. package/mcp/atris-mcp/index.mjs +212 -10
  74. package/package.json +2 -1
  75. package/scripts/det/engine-reading.js +37 -0
  76. package/scripts/det/ytnotes +29 -5
@@ -0,0 +1,300 @@
1
+ // Campaign traces: a campaign's event log as reinforcement-learning rows.
2
+ // State is the folded view, an action is one mission, the reward is the
3
+ // change in the judge's reading, and cost is the dollars the mission spent.
4
+ // buildTrace is pure and deterministic: the same events give the same rows.
5
+ // Doc: atris/features/campaign/traces.md.
6
+
7
+ 'use strict';
8
+
9
+ const crypto = require('crypto');
10
+ const { compareEvents, fold } = require('./campaign-core');
11
+ const { judgeReading } = require('./campaign-reading');
12
+
13
+ const SCHEMA = 'atris.campaign.trace';
14
+ const VERSION = 1;
15
+
16
+ const PRIVACY = {
17
+ redacted: 'Redacted: titles, objectives, verify commands, the judge command, judge summaries and output, and ask text are dropped. Campaign and mission ids are replaced by stable pseudonyms (Atris ids carry words from the title), and people show as "human". Numbers, members, engines, and outcomes stay.',
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
+ };
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.';
22
+
23
+ function round(value, places = 6) {
24
+ const scale = 10 ** places;
25
+ return Math.round(value * scale) / scale;
26
+ }
27
+
28
+ function pseudonym(prefix, ...parts) {
29
+ return `${prefix}-${crypto.createHash('sha256').update(parts.join('\n')).digest('hex').slice(0, 12)}`;
30
+ }
31
+
32
+ // A judge run's reading: what the judge reported, else counts in its summary,
33
+ // else 1 for a pass and 0 for a fail.
34
+ function readingOf(run) {
35
+ if (!run) return { value: null, source: null };
36
+ if (typeof run.reading === 'number' && Number.isFinite(run.reading)) return { value: round(Math.min(1, Math.max(0, run.reading)), 4), source: 'graded' };
37
+ const parsed = run.summary ? judgeReading(String(run.summary)) : null;
38
+ if (parsed !== null) return { value: parsed, source: 'summary' };
39
+ return { value: run.passed ? 1 : 0, source: 'pass_fail' };
40
+ }
41
+
42
+ // The order the fold applies events in: duplicates dropped, sorted, the first
43
+ // start moved to the front.
44
+ function foldOrder(events) {
45
+ const seen = new Set();
46
+ const unique = [];
47
+ for (const event of events) {
48
+ if (seen.has(event.id)) continue;
49
+ seen.add(event.id);
50
+ unique.push(event);
51
+ }
52
+ const ordered = unique.sort(compareEvents);
53
+ const start = ordered.find((e) => e.type === 'campaign_started');
54
+ return [start, ...ordered.filter((e) => e !== start)];
55
+ }
56
+
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
+ return {
61
+ state: view.state,
62
+ reading: readingOf(run).value,
63
+ confidence: view.answer.confidence,
64
+ judge_runs: view.judge_runs,
65
+ budget: view.budget,
66
+ missions,
67
+ open_asks: view.asks.length,
68
+ };
69
+ }
70
+
71
+ function judgeRunner(command) {
72
+ const first = String(command || '').trim().split(/\s+/)[0] || '';
73
+ return first.split('/').pop() || null;
74
+ }
75
+
76
+ // events: one campaign's events in any order. options.redact defaults to true.
77
+ // Returns { header, rows } or { error }.
78
+ function buildTrace(events, options = {}) {
79
+ const redact = options.redact !== false;
80
+ const final = fold(events);
81
+ if (final.error) return { error: final.error };
82
+ const sequence = foldOrder(events);
83
+ const campaignId = final.id;
84
+
85
+ const missions = new Map();
86
+ const judgeRuns = [];
87
+ const asks = new Map();
88
+ 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: [] });
90
+ return missions.get(id);
91
+ };
92
+
93
+ sequence.forEach((event, pos) => {
94
+ const d = event.data;
95
+ if (event.type === 'judge_ran') {
96
+ judgeRuns.push({ pos, at: event.at, passed: d.passed, reading: d.reading, summary: d.summary || null, outputTail: d.output_tail || null });
97
+ return;
98
+ }
99
+ if (event.type === 'ask_opened') {
100
+ if (!asks.has(d.ask_id)) asks.set(d.ask_id, { mission_id: d.mission_id || null, question: d.question, answer: null });
101
+ return;
102
+ }
103
+ if (event.type === 'ask_answered') {
104
+ const ask = asks.get(d.ask_id);
105
+ if (ask && ask.answer === null && String(event.actor).startsWith('human:')) ask.answer = d.answer;
106
+ return;
107
+ }
108
+ const missionId = typeof d.mission_id === 'string' && d.mission_id ? d.mission_id : null;
109
+ if (!missionId || event.type === 'budget_reserved' || event.type === 'budget_released') return;
110
+ const m = info(missionId);
111
+ if (m.firstPos === null) m.firstPos = pos;
112
+ if (typeof d.objective === 'string' && (m.objective === null || event.type === 'mission_proposed')) m.objective = d.objective;
113
+ if (typeof d.verify === 'string' && m.verify === null) m.verify = d.verify;
114
+ if (typeof d.member === 'string' && m.member === null) m.member = d.member;
115
+ // A planning step names the planner's engine, not the one that did the work.
116
+ if (typeof d.engine === 'string' && m.engine === null && d.kind !== 'plan') m.engine = d.engine;
117
+ if (typeof d.engine === 'string' && event.type === 'mission_started' && d.kind === 'plan') m.engine = d.engine;
118
+ if (typeof d.estimate_usd === 'number' && m.estimate === null) m.estimate = d.estimate_usd;
119
+ if (event.type === 'mission_started' && d.kind === 'plan') m.kind = 'plan';
120
+ if (event.type === 'step') {
121
+ m.ticks += 1;
122
+ if (!(typeof d.cost_usd === 'number' && Number.isFinite(d.cost_usd) && d.cost_usd >= 0)) m.uncostedTicks += 1;
123
+ }
124
+ if (event.type === 'mission_ended' && m.endPos === null) {
125
+ m.endPos = pos;
126
+ m.endData = d;
127
+ m.endAt = event.at;
128
+ }
129
+ });
130
+ for (const ask of asks.values()) if (ask.mission_id && missions.has(ask.mission_id)) missions.get(ask.mission_id).asks.push(ask);
131
+
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 lastRunBefore = (pos) => {
139
+ let found = null;
140
+ for (const run of judgeRuns) if (run.pos < pos) found = run;
141
+ return found;
142
+ };
143
+ const firstRunAfter = (pos) => judgeRuns.find((run) => run.pos > pos) || null;
144
+
145
+ // Where the campaign finished, if it did: the first position whose prefix is done.
146
+ let finishPos = null;
147
+ if (final.state === 'done') {
148
+ let lo = 0;
149
+ let hi = sequence.length - 1;
150
+ while (lo < hi) {
151
+ const mid = Math.floor((lo + hi) / 2);
152
+ if (fold(sequence.slice(0, mid + 1)).state === 'done') hi = mid;
153
+ else lo = mid + 1;
154
+ }
155
+ finishPos = lo;
156
+ }
157
+
158
+ 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);
160
+
161
+ // A batch: missions that ended between the same two judge runs.
162
+ const batches = new Map();
163
+ for (const m of ended) {
164
+ const before = lastRunBefore(m.endPos);
165
+ const after = firstRunAfter(m.endPos);
166
+ 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);
169
+ }
170
+
171
+ const memberFor = (member) => (redact && typeof member === 'string' && member.startsWith('human:') ? 'human' : member);
172
+ const idFor = (missionId) => (redact ? pseudonym('m', campaignId, missionId) : missionId);
173
+ const outCampaign = redact ? pseudonym('cmp', campaignId) : campaignId;
174
+ const lastPos = sequence.length - 1;
175
+
176
+ const rows = ended.map((m, i) => {
177
+ const before = lastRunBefore(m.endPos);
178
+ const after = firstRunAfter(m.endPos);
179
+ const batch = batches.get(`${before ? before.pos : -1}:${after ? after.pos : 'open'}`);
180
+ const size = batch.missions.length;
181
+ const readBefore = readingOf(before);
182
+ const readAfter = readingOf(after);
183
+ const delta = readBefore.value !== null && readAfter.value !== null ? readAfter.value - readBefore.value : null;
184
+ const startPos = m.firstPos === null ? m.endPos : m.firstPos;
185
+ const view = viewBefore(startPos);
186
+ const folded = finalMissions.get(m.id) || {};
187
+ const uncosted = typeof m.endData.uncosted_runs === 'number' ? m.endData.uncosted_runs : m.uncostedTicks;
188
+ let creditNote = null;
189
+ if (!after) creditNote = 'no judge run after this mission yet, so it has no reward';
190
+ else if (!before) creditNote = 'no judge run before this mission, so it has no reward';
191
+ else if (size > 1) creditNote = `one of ${size} missions that ended between the same two judge runs; the reading change is split equally`;
192
+
193
+ const action = {
194
+ mission_id: idFor(m.id),
195
+ kind: m.kind,
196
+ member: memberFor(folded.member || m.member),
197
+ engine: m.engine || (typeof m.endData.engine === 'string' ? m.endData.engine : null),
198
+ estimate_usd: m.estimate,
199
+ };
200
+ if (!redact) {
201
+ action.objective = m.objective;
202
+ action.verify = m.verify;
203
+ }
204
+ const row = {
205
+ kind: 'step',
206
+ campaign_id: outCampaign,
207
+ step: i + 1,
208
+ at: m.endAt,
209
+ batch: { index: batch.index, size },
210
+ state_before: compactState(view, lastRunBefore(startPos)),
211
+ action,
212
+ outcome: folded.outcome || m.endData.outcome,
213
+ cost_usd: folded.cost_usd === undefined || folded.cost_usd === null ? m.endData.cost_usd : folded.cost_usd,
214
+ ticks: m.ticks,
215
+ uncosted_ticks: uncosted,
216
+ human_asks: m.asks.length,
217
+ reading_before: readBefore.value,
218
+ reading_after: readAfter.value,
219
+ reading_source: readAfter.source,
220
+ reward: delta === null ? null : round(delta / size),
221
+ credit_note: creditNote,
222
+ done: finishPos !== null && finishPos <= (after ? after.pos : lastPos),
223
+ judge_passed: after ? after.passed : null,
224
+ };
225
+ if (!redact) {
226
+ row.judge_summary = after ? after.summary : null;
227
+ row.judge_output_tail = after ? after.outputTail : null;
228
+ row.asks = m.asks.map((ask) => ({ question: ask.question, answer: ask.answer }));
229
+ }
230
+ return row;
231
+ });
232
+
233
+ const lastRun = judgeRuns[judgeRuns.length - 1] || null;
234
+ const header = {
235
+ kind: 'header',
236
+ schema: SCHEMA,
237
+ version: VERSION,
238
+ campaign_id: outCampaign,
239
+ redacted: redact,
240
+ privacy: redact ? PRIVACY.redacted : PRIVACY.full,
241
+ judge: { runner: judgeRunner(final.judge), outside: true, runs: judgeRuns.length },
242
+ reward: REWARD,
243
+ rows: rows.length,
244
+ batches: batches.size,
245
+ pending_missions: final.missions.filter((m) => ['proposed', 'claimed', 'running'].includes(m.status)).length,
246
+ missions_not_run: final.missions.filter((m) => m.status === 'vetoed' || m.status === 'dropped').length,
247
+ campaign_asks: [...asks.values()].filter((ask) => !ask.mission_id).length,
248
+ events: sequence.length,
249
+ first_at: sequence[0].at,
250
+ last_at: sequence[lastPos].at,
251
+ final: {
252
+ state: final.state,
253
+ stop_reason: final.stop_reason,
254
+ confidence: final.answer.confidence,
255
+ reading: readingOf(lastRun).value,
256
+ standing: final.standing,
257
+ budget: final.budget,
258
+ },
259
+ };
260
+ if (!redact) {
261
+ header.title = final.title;
262
+ header.judge.command = final.judge;
263
+ }
264
+ return { header, rows };
265
+ }
266
+
267
+ function traceLines(trace) {
268
+ return [trace.header, ...trace.rows].map((row) => JSON.stringify(row)).join('\n') + '\n';
269
+ }
270
+
271
+ // atris campaign export <id> --trace [--out <file>] [--full]
272
+ function exportTrace(args, ctx) {
273
+ const words = [];
274
+ for (let i = 1; i < args.length; i++) {
275
+ const arg = String(args[i]);
276
+ if (arg === '--out' || arg === '--as') { i++; continue; }
277
+ if (!arg.startsWith('--')) words.push(arg);
278
+ }
279
+ const { readFlag } = require('./arg-parser');
280
+ const id = words[0];
281
+ if (!id) return ctx.fail('name the campaign: atris campaign export <id> --trace [--out <file.jsonl>] [--full]');
282
+ const events = ctx.store.read(id);
283
+ if (!events.length) return ctx.fail(`no campaign ${id}. See them all with: atris campaign`, 1);
284
+ const full = args.includes('--full');
285
+ if (full && args.includes('--redact')) return ctx.fail('pick one: --redact (the default) or --full');
286
+ const trace = buildTrace(events, { redact: !full });
287
+ if (trace.error) return ctx.fail(`campaign ${id} cannot be read: ${trace.error}`, 1);
288
+ const text = traceLines(trace);
289
+ const out = readFlag(args, '--out', '');
290
+ if (!out) {
291
+ process.stdout.write(text);
292
+ return 0;
293
+ }
294
+ require('fs').writeFileSync(out, text);
295
+ const note = `wrote ${trace.rows.length} row${trace.rows.length === 1 ? '' : 's'} and a header to ${out}${full ? ' (full: includes objectives and judge output)' : ' (redacted)'}`;
296
+ ctx.out(note, { ok: true, action: 'export', path: out, rows: trace.rows.length, redacted: !full });
297
+ return 0;
298
+ }
299
+
300
+ module.exports = { SCHEMA, VERSION, buildTrace, exportTrace, readingOf, traceLines };
@@ -10,20 +10,34 @@ const CONDUCTOR_UNTRACKED_PATTERN =
10
10
  const CONDUCTOR_ROOT_OUTPUT_PATTERN = /^\.codex-last-message\.txt$/;
11
11
  const CONDUCTOR_RUN_OUTPUT_PATTERN = /^atris\/runs\/[^/]+\.(?:json|live\.log)$/;
12
12
 
13
+ // Every atris call inside a work folder can write more of its own state under
14
+ // .atris/ (engine health, usage, briefs). A list of names always lagged: on
15
+ // 2026-10-15 .atris/state/engines.json and usage.jsonl made every dispatch
16
+ // guard fail on a fresh repo. A NEW file under .atris/ is always the tool's
17
+ // own, so untracked checks skip the whole folder. Tracked .atris/ files keep
18
+ // the exact list, since a repo may commit some of them on purpose.
19
+ const ATRIS_OWN_DIR_PATTERN = /^\.atris\//;
20
+
13
21
  function isConductorArtifact(file) {
14
22
  const value = String(file || '');
15
23
  return CONDUCTOR_UNTRACKED_PATTERN.test(value) || CONDUCTOR_ROOT_OUTPUT_PATTERN.test(value)
16
24
  || CONDUCTOR_RUN_OUTPUT_PATTERN.test(value);
17
25
  }
18
26
 
27
+ // For files git does not track yet.
28
+ function isConductorUntracked(file) {
29
+ return isConductorArtifact(file) || ATRIS_OWN_DIR_PATTERN.test(String(file || ''));
30
+ }
31
+
19
32
  // Accepts a `git status --porcelain` line ("?? .atris/fleet-prompt-BCK-1.md").
20
33
  function isConductorStatusLine(line) {
21
34
  const text = String(line || '');
22
35
  if (!text.startsWith('?? ')) return false;
23
- return isConductorArtifact(text.slice(3));
36
+ return isConductorUntracked(text.slice(3));
24
37
  }
25
38
 
26
39
  module.exports = {
27
40
  isConductorArtifact,
28
41
  isConductorStatusLine,
42
+ isConductorUntracked,
29
43
  };
package/lib/daily-log.js CHANGED
@@ -46,7 +46,17 @@ function memberSlug(root, member) {
46
46
  return slug;
47
47
  }
48
48
 
49
- function appendMemberDailyEntry(root, member, title, fields) {
49
+ // Member logs are gitignored, so one written inside a work folder dies when
50
+ // that folder is reaped. A linked worktree writes to its main checkout instead
51
+ // when the main checkout knows the member.
52
+ function memberLogRoot(root, member) {
53
+ const { mainCheckoutRoot } = require('./engine-registry');
54
+ const main = mainCheckoutRoot(root);
55
+ return main && memberSlug(main, member) ? main : root;
56
+ }
57
+
58
+ function appendMemberDailyEntry(workspaceRoot, member, title, fields) {
59
+ const root = memberLogRoot(workspaceRoot, member);
50
60
  const slug = memberSlug(root, member);
51
61
  if (!slug || !fs.existsSync(path.join(root, 'atris'))) return null;
52
62
  const logPath = path.join(root, 'atris', 'team', slug, 'logs', todayLogName());
@@ -331,10 +331,28 @@ function verifyScoutPack(rawPack, { task, worktreePath, allowedFiles, expectedCo
331
331
  return Buffer.byteLength(JSON.stringify(pack)) <= SCOUT_MAX_PACK_BYTES ? pack : null;
332
332
  }
333
333
 
334
+ // A task whose title already names the file it changes, by a path that is in
335
+ // the repo or sits in one of its folders, gains nothing from a scout: the
336
+ // engine knows where to start. Measured 2026-10-15 on five one-file tasks: the
337
+ // scout ran 21 to 118 seconds before each engine, longer than the engine's own
338
+ // 37 to 53 second build, and pushed the flight from about 95 to 196 seconds.
339
+ const NAMED_PATH_PATTERN = /(?:^|[\s"'`(])((?:[\w.-]+\/)+[\w.-]+|[\w-]+\.[A-Za-z0-9]{1,8})(?=$|[\s"'`),:;]|\.(?:\s|$))/g;
340
+
341
+ function titleNamesItsFiles(task, worktreePath) {
342
+ const title = String(task && task.title || '');
343
+ const named = [...title.matchAll(NAMED_PATH_PATTERN)].map((match) => match[1]);
344
+ if (!named.length) return false;
345
+ const tracked = trackedFiles(worktreePath);
346
+ const files = new Set(tracked);
347
+ const folders = new Set(tracked.map((file) => path.posix.dirname(file)));
348
+ return named.every((ref) => files.has(ref) || (ref.includes('/') && folders.has(path.posix.dirname(ref))));
349
+ }
350
+
334
351
  async function buildVerifiedScoutPack({ task, worktreePath, ask = defaultScoutAsk }) {
335
352
  try {
336
353
  const commit = checkoutCommit(worktreePath);
337
354
  if (!commit) return null;
355
+ if (titleNamesItsFiles(task, worktreePath)) return null;
338
356
  const seed = seedScoutContext({ task, worktreePath });
339
357
  if (seed.allowedFiles.length < 2) return null;
340
358
  const job = {
package/lib/engine-ask.js CHANGED
@@ -21,6 +21,7 @@ const {
21
21
  createEngineLiveLog,
22
22
  engineTerminalStatus,
23
23
  } = require('./engine-job-lifecycle');
24
+ const { recordEngineResource } = require('./engine-resource');
24
25
 
25
26
  const DEFAULT_ASK_CONCURRENCY = 3;
26
27
  const DEFAULT_ASK_TIMEOUT_MS = 120000;
@@ -278,8 +279,9 @@ function buildReadOnlyEngineInvocation(engineName, prompt, modelName = '', {
278
279
  const request = guardedPrompt(prompt);
279
280
  const effortArgs = askEffortArgs(engine, effort);
280
281
 
281
- if (engine === 'atris-fast' || engine === 'composer') {
282
- return { engine, bin: profile.bin, args: ['--fast', '--cloud', '--print', request] };
282
+ if (engine === 'atris-fast' || engine === 'composer' || engine === 'mercury') {
283
+ const lane = engine === 'mercury' ? '--mercury' : '--fast';
284
+ return { engine, bin: profile.bin, args: [lane, '--cloud', '--print', request] };
283
285
  }
284
286
  if (engine === 'claude' || engine === 'fable' || engine === 'haiku') {
285
287
  return {
@@ -545,10 +547,37 @@ async function runEngineAskJobs(jobs, {
545
547
  } = {}) {
546
548
  const answers = new Array(jobs.length);
547
549
  let nextIndex = 0;
548
- const execute = executeAskJob || (async (job) => {
550
+ const launch = executeAskJob || (async (job) => {
549
551
  const invocation = buildReadOnlyEngineInvocation(job.engine, job.prompt, job.model, { timeoutMs, effort: job.effort || '' });
550
552
  return runAskProcess(invocation, { cwd: invocation.cwd || root, timeoutMs, signal, onOutputChunk });
551
553
  });
554
+ // Machine memory and load at both ends, like a dispatch, so a killed ask
555
+ // still says whether the machine was short on memory. This wraps a
556
+ // caller's own launcher too (link, the dispatch scout); one that throws
557
+ // still gets its end line before the error passes on.
558
+ const execute = async (job, context) => {
559
+ const resource = { engine: canonicalEngineName(job.engine) || job.engine, task: `ask ${job.label || job.engine}`, run_id: crypto.randomUUID() };
560
+ recordEngineResource(root, { phase: 'start', ...resource });
561
+ const startedAt = Date.now();
562
+ const writeEnd = (ran, error = '') => recordEngineResource(root, {
563
+ phase: 'end',
564
+ ...resource,
565
+ signal: ran.signal || null,
566
+ timed_out: ran.timed_out,
567
+ error,
568
+ duration_ms: Number.isFinite(ran.duration_ms) ? ran.duration_ms : Date.now() - startedAt,
569
+ });
570
+ let result;
571
+ try {
572
+ result = await launch(job, context);
573
+ } catch (error) {
574
+ writeEnd({}, String(error && error.message || error).slice(-2000));
575
+ throw error;
576
+ }
577
+ // A SIGKILL's cause goes on the answer so the failure line can name it.
578
+ const end = writeEnd(result || {});
579
+ return end && end.cause && !(result && result.timed_out) ? { ...result, kill_cause: end.cause } : result;
580
+ };
552
581
  const workerCount = Math.min(concurrency, jobs.length);
553
582
 
554
583
  async function work() {
@@ -700,6 +729,7 @@ function createEngineAskReceipt(root, receipt) {
700
729
  function compactFailure(answer) {
701
730
  if (answer.reason === 'cancelled' || answer.cancelled) return 'cancelled';
702
731
  if (answer.reason === 'timeout') return `timed out after ${Math.round(answer.duration_ms / 1000)}s`;
732
+ if (answer.kill_cause) return `stopped by ${answer.signal || 'SIGKILL'}; ${answer.kill_cause}`;
703
733
  const detail = String(answer.stderr || '').trim().split(/\r?\n/).find(Boolean);
704
734
  return detail || String(answer.reason || 'failed').replace(/_/g, ' ');
705
735
  }
@@ -160,6 +160,8 @@ function engineFailureHealthStatus(result) {
160
160
  // fallback_order. Haiku searches first wherever the claude CLI is installed.
161
161
  const ENGINE_SEED_META = Object.freeze({
162
162
  'atris-fast': Object.freeze({ tier: 'fast', roles: Object.freeze(['navigator']), models: Object.freeze(['atris fast']), duty: 'learning', fallback_order: 10, hosted: true }),
163
+ // Test model on a capped free tier: a roster pick may use it, the router never hands it work.
164
+ mercury: Object.freeze({ tier: 'fast', roles: Object.freeze([]), roster_only_roles: Object.freeze(['navigator']), models: Object.freeze(['mercury 3 (test model)']), fallback_order: 100, hosted: true }),
163
165
  // roster_only_roles: jobs a roster pick may give this engine, but the router
164
166
  // never hands it on its own. Claude can own search when picked; with no
165
167
  // pick, search goes to haiku, then other installed tools, then atris-fast.