atris 3.43.0 → 3.45.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/atris/skills/design/SKILL.md +7 -1
  2. package/atris/skills/engines/SKILL.md +44 -13
  3. package/atris/team/customer-lead/MEMBER.md +45 -0
  4. package/atris/team/customer-lead/SOUL.md +33 -0
  5. package/atris/team/customer-lead/START_HERE.md +7 -0
  6. package/atris/team/customer-lead/skills/customer-commitments/SKILL.md +45 -0
  7. package/atris/team/improver/MEMBER.md +33 -0
  8. package/bin/atris.js +36 -3
  9. package/commands/aeo.js +5 -2
  10. package/commands/align.js +5 -2
  11. package/commands/autoland.js +15 -1
  12. package/commands/caretaker.js +303 -0
  13. package/commands/clean.js +76 -0
  14. package/commands/computer.js +5 -2
  15. package/commands/engine-watch.js +212 -0
  16. package/commands/engine.js +99 -11
  17. package/commands/founder.js +304 -0
  18. package/commands/human-missions.js +844 -0
  19. package/commands/improve.js +29 -6
  20. package/commands/init.js +16 -7
  21. package/commands/mission.js +124 -69
  22. package/commands/pull.js +5 -2
  23. package/commands/push.js +5 -2
  24. package/commands/slop.js +34 -3
  25. package/commands/task.js +51 -4
  26. package/commands/team.js +329 -13
  27. package/commands/terminal.js +5 -2
  28. package/commands/verify.js +99 -6
  29. package/commands/workflow.js +10 -3
  30. package/commands/worktree.js +119 -4
  31. package/lib/auto-accept-certified.js +302 -0
  32. package/lib/cloud-mission.js +59 -2
  33. package/lib/conductor-artifacts.js +1 -1
  34. package/lib/dispatch-scout.js +386 -0
  35. package/lib/engine-ask.js +645 -0
  36. package/lib/engine-job-lifecycle.js +65 -0
  37. package/lib/engine-receipt-sweep.js +98 -0
  38. package/lib/engine-registry.js +2 -2
  39. package/lib/engine-validate.js +382 -0
  40. package/lib/fleet.js +459 -106
  41. package/lib/known-commands.js +2 -2
  42. package/lib/member-alive.js +2 -2
  43. package/lib/policy-lessons.js +70 -0
  44. package/lib/receipt-evidence.js +56 -1
  45. package/lib/runner-command.js +1 -1
  46. package/lib/secret-gateway.js +588 -0
  47. package/lib/team-presence.js +13 -1
  48. package/lib/voice-gate.js +6 -0
  49. package/lib/wish-audit.js +5 -205
  50. package/lib/wish-delegate.js +5 -2
  51. package/package.json +6 -1
  52. package/utils/auth.js +56 -9
@@ -0,0 +1,645 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const { spawn, spawnSync } = require('child_process');
8
+ const {
9
+ DEFAULT_CLAUDE_RUNNER_MODEL,
10
+ RUNNER_PROFILE_DEFS,
11
+ } = require('./runner-command');
12
+ const { canonicalEngineName } = require('./engine-registry');
13
+ const {
14
+ appendEngineLiveLogChunk,
15
+ createEngineLiveLog,
16
+ engineTerminalStatus,
17
+ } = require('./engine-job-lifecycle');
18
+
19
+ const DEFAULT_ASK_CONCURRENCY = 3;
20
+ const DEFAULT_ASK_TIMEOUT_MS = 120000;
21
+ const MAX_ASK_CONCURRENCY = 4;
22
+ const MAX_ASK_JOBS = 8;
23
+ const MAX_ASK_TIMEOUT_MS = 10 * 60 * 1000;
24
+ const MAX_ASK_PROMPT_BYTES = 16 * 1024;
25
+ const MAX_ASK_TOTAL_PROMPT_BYTES = 64 * 1024;
26
+ const MAX_ASK_OUTPUT_BYTES = 1024 * 1024;
27
+ const ASK_STOP_GRACE_MS = 250;
28
+ const ASK_MODEL_ENGINES = new Set(['claude', 'fable', 'haiku', 'codex', 'cursor', 'devin', 'grok']);
29
+ const READ_ONLY_PREAMBLE = [
30
+ 'This is a read-only request.',
31
+ 'Do not modify files, create worktrees, start background agents, or run commands with side effects.',
32
+ 'Return the answer in this process.',
33
+ ].join(' ');
34
+
35
+ function askUsage() {
36
+ return [
37
+ 'usage:',
38
+ ' atris engine ask "<question>" --engine <name> [--engine <name> ...] [--model <name>]',
39
+ ' atris engine ask --jobs <jobs.json>',
40
+ '',
41
+ 'options:',
42
+ ' --model <name> exact model for every selected engine',
43
+ ` --concurrency <n> parallel runs, 1-${MAX_ASK_CONCURRENCY} (default ${DEFAULT_ASK_CONCURRENCY})`,
44
+ ` --timeout <sec> per-engine timeout, 1-${MAX_ASK_TIMEOUT_MS / 1000} (default ${DEFAULT_ASK_TIMEOUT_MS / 1000})`,
45
+ ' --json print the receipt as json',
46
+ '',
47
+ `jobs files contain up to ${MAX_ASK_JOBS} objects: [{"engine":"codex","model":"optional","prompt":"question","label":"optional"}]`,
48
+ ].join('\n');
49
+ }
50
+
51
+ function parseBoundedInteger(raw, flag, min, max) {
52
+ const value = Number(raw);
53
+ if (!Number.isInteger(value) || value < min || value > max) {
54
+ throw new Error(`${flag} must be an integer from ${min} to ${max}`);
55
+ }
56
+ return value;
57
+ }
58
+
59
+ function normalizeAskJob(job, index) {
60
+ if (!job || typeof job !== 'object' || Array.isArray(job)) {
61
+ throw new Error(`job ${index + 1} must be an object`);
62
+ }
63
+ const requestedEngine = String(job.engine || '').trim();
64
+ const engine = canonicalEngineName(requestedEngine);
65
+ if (!engine) {
66
+ throw new Error(`job ${index + 1} names an unknown engine "${requestedEngine}"`);
67
+ }
68
+ const prompt = String(job.prompt || '').trim();
69
+ if (!prompt) throw new Error(`job ${index + 1} needs a prompt`);
70
+ if (Buffer.byteLength(prompt) > MAX_ASK_PROMPT_BYTES) {
71
+ throw new Error(`job ${index + 1} prompt must be ${MAX_ASK_PROMPT_BYTES} bytes or fewer`);
72
+ }
73
+ const model = String(job.model || '').trim();
74
+ const label = String(job.label || '').trim();
75
+ if (label.length > 80) throw new Error(`job ${index + 1} label must be 80 characters or fewer`);
76
+ return { engine, model, prompt, label };
77
+ }
78
+
79
+ function labelAskJobs(jobs) {
80
+ const totals = new Map();
81
+ for (const job of jobs) totals.set(job.engine, (totals.get(job.engine) || 0) + 1);
82
+ const seen = new Map();
83
+ return jobs.map((job) => {
84
+ if (job.label) return job;
85
+ const number = (seen.get(job.engine) || 0) + 1;
86
+ seen.set(job.engine, number);
87
+ return { ...job, label: totals.get(job.engine) > 1 ? `${job.engine}-${number}` : job.engine };
88
+ });
89
+ }
90
+
91
+ function parseEngineAskArgs(args, { root = process.cwd(), readFile = fs.readFileSync } = {}) {
92
+ const promptParts = [];
93
+ const requestedEngines = [];
94
+ let jobsFile = '';
95
+ let requestedModel = '';
96
+ let modelFlagPresent = false;
97
+ let concurrency = DEFAULT_ASK_CONCURRENCY;
98
+ let timeoutMs = DEFAULT_ASK_TIMEOUT_MS;
99
+ let json = false;
100
+ let help = false;
101
+
102
+ for (let index = 0; index < args.length; index += 1) {
103
+ const arg = String(args[index]);
104
+ if (arg === '--help' || arg === '-h') { help = true; continue; }
105
+ if (arg === '--json') { json = true; continue; }
106
+ if (arg === '--engine') { requestedEngines.push(String(args[index + 1] || '')); index += 1; continue; }
107
+ if (arg.startsWith('--engine=')) { requestedEngines.push(arg.slice('--engine='.length)); continue; }
108
+ if (arg === '--engines') { requestedEngines.push(...String(args[index + 1] || '').split(',')); index += 1; continue; }
109
+ if (arg.startsWith('--engines=')) { requestedEngines.push(...arg.slice('--engines='.length).split(',')); continue; }
110
+ if (arg === '--model') {
111
+ modelFlagPresent = true;
112
+ requestedModel = String(args[index + 1] || '').trim();
113
+ index += 1;
114
+ continue;
115
+ }
116
+ if (arg.startsWith('--model=')) {
117
+ modelFlagPresent = true;
118
+ requestedModel = arg.slice('--model='.length).trim();
119
+ continue;
120
+ }
121
+ if (arg === '--jobs') { jobsFile = String(args[index + 1] || ''); index += 1; continue; }
122
+ if (arg.startsWith('--jobs=')) { jobsFile = arg.slice('--jobs='.length); continue; }
123
+ if (arg === '--concurrency') {
124
+ concurrency = parseBoundedInteger(args[index + 1], '--concurrency', 1, MAX_ASK_CONCURRENCY);
125
+ index += 1;
126
+ continue;
127
+ }
128
+ if (arg.startsWith('--concurrency=')) {
129
+ concurrency = parseBoundedInteger(arg.slice('--concurrency='.length), '--concurrency', 1, MAX_ASK_CONCURRENCY);
130
+ continue;
131
+ }
132
+ if (arg === '--timeout') {
133
+ timeoutMs = parseBoundedInteger(args[index + 1], '--timeout', 1, MAX_ASK_TIMEOUT_MS / 1000) * 1000;
134
+ index += 1;
135
+ continue;
136
+ }
137
+ if (arg.startsWith('--timeout=')) {
138
+ timeoutMs = parseBoundedInteger(arg.slice('--timeout='.length), '--timeout', 1, MAX_ASK_TIMEOUT_MS / 1000) * 1000;
139
+ continue;
140
+ }
141
+ if (arg.startsWith('--')) throw new Error(`unknown option ${arg}`);
142
+ promptParts.push(arg);
143
+ }
144
+
145
+ if (help) return { help: true, json, jobs: [], concurrency, timeoutMs };
146
+ if (modelFlagPresent && !requestedModel) throw new Error('--model needs a name');
147
+ const commonPrompt = promptParts.join(' ').trim();
148
+ let jobs;
149
+ if (jobsFile) {
150
+ if (commonPrompt || requestedEngines.length || modelFlagPresent) {
151
+ throw new Error('--jobs cannot be combined with a shared prompt, --engine, or --model');
152
+ }
153
+ const absoluteJobsFile = path.resolve(root, jobsFile);
154
+ let parsed;
155
+ try {
156
+ parsed = JSON.parse(readFile(absoluteJobsFile, 'utf8'));
157
+ } catch (error) {
158
+ throw new Error(`could not read --jobs ${jobsFile}: ${error.message}`);
159
+ }
160
+ if (!Array.isArray(parsed)) throw new Error('--jobs must contain a json array');
161
+ jobs = parsed.map(normalizeAskJob);
162
+ } else {
163
+ if (!commonPrompt || !requestedEngines.length) throw new Error(askUsage());
164
+ jobs = requestedEngines.map((requestedEngine, index) => normalizeAskJob({
165
+ engine: requestedEngine,
166
+ model: requestedModel,
167
+ prompt: commonPrompt,
168
+ }, index));
169
+ }
170
+
171
+ if (!jobs.length) throw new Error('at least one engine ask job is required');
172
+ if (jobs.length > MAX_ASK_JOBS) throw new Error(`engine ask accepts at most ${MAX_ASK_JOBS} jobs per run`);
173
+ const totalPromptBytes = jobs.reduce((sum, job) => sum + Buffer.byteLength(job.prompt), 0);
174
+ if (totalPromptBytes > MAX_ASK_TOTAL_PROMPT_BYTES) {
175
+ throw new Error(`engine ask accepts at most ${MAX_ASK_TOTAL_PROMPT_BYTES} prompt bytes per run`);
176
+ }
177
+ return { help: false, json, jobs: labelAskJobs(jobs), concurrency, timeoutMs };
178
+ }
179
+
180
+ function guardedPrompt(prompt) {
181
+ return `${READ_ONLY_PREAMBLE}\n\n${prompt}`;
182
+ }
183
+
184
+ function assertAskModelSupported(engine, model) {
185
+ if (!model || ASK_MODEL_ENGINES.has(engine)) return;
186
+ const error = new Error(`${engine} does not support model selection for engine ask`);
187
+ error.reason = 'model_not_supported';
188
+ throw error;
189
+ }
190
+
191
+ function buildReadOnlyEngineInvocation(engineName, prompt, modelName = '') {
192
+ const engine = canonicalEngineName(engineName);
193
+ const profile = RUNNER_PROFILE_DEFS[engine];
194
+ if (!profile) throw new Error(`unknown engine "${engineName}"`);
195
+ const model = String(modelName || '').trim();
196
+ assertAskModelSupported(engine, model);
197
+ const request = guardedPrompt(prompt);
198
+
199
+ if (engine === 'atris-fast' || engine === 'composer') {
200
+ return { engine, bin: profile.bin, args: ['--fast', '--cloud', '--print', request] };
201
+ }
202
+ if (engine === 'claude' || engine === 'fable' || engine === 'haiku') {
203
+ return {
204
+ engine,
205
+ bin: profile.bin,
206
+ args: [
207
+ '-p', request,
208
+ '--model', model || profile.model || DEFAULT_CLAUDE_RUNNER_MODEL,
209
+ '--tools', 'Read,Glob,Grep,WebSearch,WebFetch',
210
+ '--permission-mode', 'plan',
211
+ '--safe-mode',
212
+ '--no-session-persistence',
213
+ ],
214
+ };
215
+ }
216
+ if (engine === 'codex') {
217
+ return {
218
+ engine,
219
+ bin: profile.bin,
220
+ args: ['exec', ...(model ? ['-m', model] : []), '--sandbox', 'read-only', '--ephemeral', '--ignore-user-config', '--ignore-rules', '--color', 'never', request],
221
+ };
222
+ }
223
+ if (engine === 'cursor') {
224
+ // Cursor pays roughly a minute of silent workspace setup in every
225
+ // directory except the user's home, where the same ask answers in
226
+ // seconds (measured 15+ runs, 2026-08-12). Asks are read-only, so
227
+ // launch from home and skip the tax.
228
+ return { engine, bin: profile.bin, cwd: os.homedir(), args: ['--trust', ...(model ? ['--model', model] : []), '-p', '--mode', 'ask', '--sandbox', 'enabled', request] };
229
+ }
230
+ if (engine === 'devin') {
231
+ return { engine, bin: profile.bin, args: ['-p', request, ...(model ? ['--model', model] : []), '--permission-mode', 'auto', '--sandbox', '--respect-workspace-trust', 'false'] };
232
+ }
233
+ if (engine === 'grok') {
234
+ return {
235
+ engine,
236
+ bin: profile.bin,
237
+ args: ['--no-memory', '--no-subagents', '--permission-mode', 'plan', '--sandbox', 'read-only', ...(model ? ['--model', model] : []), '-p', request],
238
+ };
239
+ }
240
+ if (engine === 'droid') {
241
+ return { engine, bin: profile.bin, args: ['exec', '--disable-builtin-skills', request] };
242
+ }
243
+ throw new Error(`engine ask has no read-only command for ${engine}`);
244
+ }
245
+
246
+ function appendCapped(chunks, chunk, state) {
247
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
248
+ if (state.bytes >= MAX_ASK_OUTPUT_BYTES) {
249
+ if (buffer.length) state.truncated = true;
250
+ return;
251
+ }
252
+ const available = MAX_ASK_OUTPUT_BYTES - state.bytes;
253
+ chunks.push(buffer.subarray(0, available));
254
+ state.bytes += Math.min(buffer.length, available);
255
+ if (buffer.length > available) state.truncated = true;
256
+ }
257
+
258
+ function cancelledAskResult(job, extra = {}) {
259
+ return {
260
+ ...job,
261
+ ok: false,
262
+ reason: 'cancelled',
263
+ exit_code: extra.exit_code == null ? null : extra.exit_code,
264
+ signal: extra.signal || 'SIGTERM',
265
+ timed_out: false,
266
+ cancelled: true,
267
+ stdout: extra.stdout || '',
268
+ stderr: extra.stderr || '',
269
+ output_truncated: Boolean(extra.output_truncated),
270
+ duration_ms: extra.duration_ms || 0,
271
+ };
272
+ }
273
+
274
+ function runAskProcess(invocation, {
275
+ cwd = process.cwd(),
276
+ timeoutMs = DEFAULT_ASK_TIMEOUT_MS,
277
+ spawnProcess = spawn,
278
+ killProcess = process.kill.bind(process),
279
+ signal = null,
280
+ onOutputChunk = null,
281
+ } = {}) {
282
+ return new Promise((resolve) => {
283
+ const startedAt = Date.now();
284
+ const stdoutChunks = [];
285
+ const stderrChunks = [];
286
+ const stdoutState = { bytes: 0, truncated: false };
287
+ const stderrState = { bytes: 0, truncated: false };
288
+ let child;
289
+ let finished = false;
290
+ let timedOut = false;
291
+ let cancelled = false;
292
+ let timeoutTimer;
293
+ let hardStopTimer = null;
294
+ let closeCode = null;
295
+ let closeSignal = null;
296
+
297
+ const finish = (code, closeSignalValue, spawnError) => {
298
+ if (finished) return;
299
+ finished = true;
300
+ clearTimeout(timeoutTimer);
301
+ if (hardStopTimer) clearTimeout(hardStopTimer);
302
+ if (signal && typeof signal.removeEventListener === 'function') signal.removeEventListener('abort', onAbort);
303
+ const stdout = Buffer.concat(stdoutChunks).toString('utf8');
304
+ const stderr = Buffer.concat(stderrChunks).toString('utf8');
305
+ const hasOutput = Boolean(stdout.trim() || stderr.trim());
306
+ const ok = !cancelled && !timedOut && !spawnError && code === 0 && hasOutput;
307
+ let reason = 'ok';
308
+ if (cancelled) reason = 'cancelled';
309
+ else if (timedOut) reason = 'timeout';
310
+ else if (spawnError) reason = 'spawn_error';
311
+ else if (code !== 0) reason = `exit_${code == null ? 'unknown' : code}`;
312
+ else if (!hasOutput) reason = 'no_output';
313
+ resolve({
314
+ ok,
315
+ reason,
316
+ exit_code: Number.isInteger(code) ? code : null,
317
+ signal: closeSignalValue || null,
318
+ timed_out: timedOut && !cancelled,
319
+ cancelled,
320
+ stdout,
321
+ stderr: spawnError ? `${stderr}${stderr ? '\n' : ''}${spawnError.message || spawnError}` : stderr,
322
+ output_truncated: stdoutState.truncated || stderrState.truncated,
323
+ duration_ms: Date.now() - startedAt,
324
+ });
325
+ };
326
+
327
+ const stopOwnProcessGroup = (stopSignal) => {
328
+ if (!child || !Number.isInteger(child.pid) || child.pid <= 0) return;
329
+ try {
330
+ if (process.platform === 'win32') child.kill(stopSignal);
331
+ else killProcess(-child.pid, stopSignal);
332
+ } catch {
333
+ try { child.kill(stopSignal); } catch {}
334
+ }
335
+ };
336
+
337
+ const requestStop = () => {
338
+ stopOwnProcessGroup('SIGTERM');
339
+ if (hardStopTimer) return;
340
+ hardStopTimer = setTimeout(() => {
341
+ hardStopTimer = null;
342
+ stopOwnProcessGroup('SIGKILL');
343
+ finish(closeCode, closeSignal || 'SIGTERM', null);
344
+ }, ASK_STOP_GRACE_MS);
345
+ };
346
+
347
+ const onAbort = () => {
348
+ cancelled = true;
349
+ if (!child) {
350
+ finish(null, 'SIGTERM', null);
351
+ return;
352
+ }
353
+ requestStop();
354
+ };
355
+
356
+ if (signal) {
357
+ if (signal.aborted) {
358
+ cancelled = true;
359
+ finish(null, 'SIGTERM', null);
360
+ return;
361
+ }
362
+ if (typeof signal.addEventListener === 'function') signal.addEventListener('abort', onAbort, { once: true });
363
+ }
364
+
365
+ try {
366
+ child = spawnProcess(invocation.bin, invocation.args, {
367
+ cwd,
368
+ env: process.env,
369
+ detached: process.platform !== 'win32',
370
+ stdio: ['ignore', 'pipe', 'pipe'],
371
+ });
372
+ } catch (error) {
373
+ finish(null, null, error);
374
+ return;
375
+ }
376
+
377
+ if (child.stdout) child.stdout.on('data', (chunk) => {
378
+ appendCapped(stdoutChunks, chunk, stdoutState);
379
+ if (onOutputChunk) onOutputChunk(chunk, 'stdout');
380
+ });
381
+ if (child.stderr) child.stderr.on('data', (chunk) => {
382
+ appendCapped(stderrChunks, chunk, stderrState);
383
+ if (onOutputChunk) onOutputChunk(chunk, 'stderr');
384
+ });
385
+ child.once('error', (error) => finish(null, null, error));
386
+ child.once('close', (code, closeSignalValue) => {
387
+ closeCode = code;
388
+ closeSignal = closeSignalValue;
389
+ if (timedOut || cancelled) stopOwnProcessGroup('SIGKILL');
390
+ finish(code, closeSignalValue, null);
391
+ });
392
+ timeoutTimer = setTimeout(() => {
393
+ if (cancelled || finished) return;
394
+ timedOut = true;
395
+ requestStop();
396
+ }, timeoutMs);
397
+ });
398
+ }
399
+
400
+ async function runEngineAskJobs(jobs, {
401
+ root = process.cwd(),
402
+ concurrency = DEFAULT_ASK_CONCURRENCY,
403
+ timeoutMs = DEFAULT_ASK_TIMEOUT_MS,
404
+ executeAskJob = null,
405
+ signal = null,
406
+ onOutputChunk = null,
407
+ } = {}) {
408
+ const answers = new Array(jobs.length);
409
+ let nextIndex = 0;
410
+ const execute = executeAskJob || (async (job) => {
411
+ const invocation = buildReadOnlyEngineInvocation(job.engine, job.prompt, job.model);
412
+ return runAskProcess(invocation, { cwd: invocation.cwd || root, timeoutMs, signal, onOutputChunk });
413
+ });
414
+ const workerCount = Math.min(concurrency, jobs.length);
415
+
416
+ async function work() {
417
+ while (true) {
418
+ const index = nextIndex;
419
+ nextIndex += 1;
420
+ if (index >= jobs.length) return;
421
+ const job = jobs[index];
422
+ if (signal && signal.aborted) {
423
+ answers[index] = cancelledAskResult(job);
424
+ continue;
425
+ }
426
+ try {
427
+ assertAskModelSupported(job.engine, job.model);
428
+ answers[index] = { ...job, ...(await execute(job, { root, timeoutMs, index, signal, onOutputChunk })) };
429
+ } catch (error) {
430
+ answers[index] = {
431
+ ...job,
432
+ ok: false,
433
+ reason: error && error.reason ? error.reason : 'runner_error',
434
+ exit_code: null,
435
+ signal: null,
436
+ timed_out: false,
437
+ cancelled: false,
438
+ stdout: '',
439
+ stderr: String(error && error.message ? error.message : error),
440
+ output_truncated: false,
441
+ duration_ms: 0,
442
+ };
443
+ }
444
+ }
445
+ }
446
+
447
+ await Promise.all(Array.from({ length: workerCount }, () => work()));
448
+ return answers;
449
+ }
450
+
451
+ function answerStatus(answer) {
452
+ return engineTerminalStatus(answer);
453
+ }
454
+
455
+ function engineAskReceipt(answers, { concurrency, timeoutMs, at = new Date().toISOString() }) {
456
+ const receiptAnswers = answers.map((answer) => ({ ...answer, status: answerStatus(answer) }));
457
+ const answered = receiptAnswers.filter((answer) => answer.status === 'answered').length;
458
+ const failed = receiptAnswers.filter((answer) => answer.status === 'failed').length;
459
+ const timedOut = receiptAnswers.filter((answer) => answer.status === 'timed out').length;
460
+ const cancelled = receiptAnswers.filter((answer) => answer.status === 'cancelled').length;
461
+ return {
462
+ schema: 'atris.engine_ask_receipt.v1',
463
+ at,
464
+ read_only: true,
465
+ limits: {
466
+ jobs: answers.length,
467
+ max_jobs: MAX_ASK_JOBS,
468
+ max_prompt_bytes: MAX_ASK_PROMPT_BYTES,
469
+ max_total_prompt_bytes: MAX_ASK_TOTAL_PROMPT_BYTES,
470
+ concurrency,
471
+ max_concurrency: MAX_ASK_CONCURRENCY,
472
+ timeout_ms: timeoutMs,
473
+ },
474
+ summary: { answered, failed, timed_out: timedOut, cancelled },
475
+ answers: receiptAnswers,
476
+ };
477
+ }
478
+
479
+ function processGroupId(pid = process.pid) {
480
+ if (process.platform === 'win32') return null;
481
+ const result = spawnSync('ps', ['-o', 'pgid=', '-p', String(pid)], { encoding: 'utf8' });
482
+ if (result.status !== 0) return null;
483
+ const pgid = Number(String(result.stdout || '').trim());
484
+ return Number.isInteger(pgid) && pgid > 0 ? pgid : null;
485
+ }
486
+
487
+ function atomicWriteEngineAskReceipt(receiptPath, receipt) {
488
+ const tmpPath = path.join(path.dirname(receiptPath), `.${path.basename(receiptPath)}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`);
489
+ try {
490
+ fs.writeFileSync(tmpPath, `${JSON.stringify(receipt, null, 2)}\n`);
491
+ fs.renameSync(tmpPath, receiptPath);
492
+ } finally {
493
+ try { fs.unlinkSync(tmpPath); } catch {}
494
+ }
495
+ return receiptPath;
496
+ }
497
+
498
+ function createEngineAskReceipt(root, receipt) {
499
+ const runsDir = path.join(root, 'atris', 'runs');
500
+ fs.mkdirSync(runsDir, { recursive: true });
501
+ const stamp = receipt.started_at.replace(/[-:.TZ]/g, '');
502
+ const suffix = crypto.randomBytes(4).toString('hex');
503
+ const receiptPath = path.join(runsDir, `engine-ask-${stamp}-${process.pid}-${suffix}.json`);
504
+ return atomicWriteEngineAskReceipt(receiptPath, receipt);
505
+ }
506
+
507
+ function compactFailure(answer) {
508
+ if (answer.reason === 'cancelled' || answer.cancelled) return 'cancelled';
509
+ if (answer.reason === 'timeout') return `timed out after ${Math.round(answer.duration_ms / 1000)}s`;
510
+ const detail = String(answer.stderr || '').trim().split(/\r?\n/).find(Boolean);
511
+ return detail || String(answer.reason || 'failed').replace(/_/g, ' ');
512
+ }
513
+
514
+ async function runEngineAskCommand(args, root = process.cwd(), deps = {}) {
515
+ let parsed;
516
+ try {
517
+ parsed = parseEngineAskArgs(args, { root, readFile: deps.readFile || fs.readFileSync });
518
+ } catch (error) {
519
+ console.error(`engine ask: ${error.message}`);
520
+ return 2;
521
+ }
522
+ if (parsed.help) {
523
+ console.log(askUsage());
524
+ return 0;
525
+ }
526
+
527
+ const abort = deps.abortController || new AbortController();
528
+ const startedAt = deps.now ? deps.now().toISOString() : new Date().toISOString();
529
+ const engines = [...new Set(parsed.jobs.map((job) => job.engine))];
530
+ const runningReceipt = {
531
+ schema: 'atris.engine_ask_receipt.v1',
532
+ status: 'running',
533
+ pid: process.pid,
534
+ pgid: processGroupId(),
535
+ engine: engines.length === 1 ? engines[0] : 'multiple',
536
+ engines,
537
+ started_at: startedAt,
538
+ at: startedAt,
539
+ read_only: true,
540
+ };
541
+ const receiptPath = (deps.createReceipt || createEngineAskReceipt)(root, runningReceipt);
542
+ const relativeReceiptPath = path.relative(root, receiptPath) || receiptPath;
543
+ const liveLogPath = (deps.createLiveLog || createEngineLiveLog)(receiptPath);
544
+ runningReceipt.live_log = path.relative(root, liveLogPath) || liveLogPath;
545
+ (deps.updateReceipt || atomicWriteEngineAskReceipt)(receiptPath, runningReceipt);
546
+ const appendLiveLog = deps.appendLiveLog
547
+ || ((filePath, chunk) => appendEngineLiveLogChunk(filePath, chunk));
548
+ let interruptedSignal = '';
549
+ let finalized = false;
550
+ const writeFinal = (receipt) => {
551
+ (deps.updateReceipt || atomicWriteEngineAskReceipt)(receiptPath, receipt);
552
+ finalized = true;
553
+ };
554
+ const signalHandlers = deps.abortController ? [] : ['SIGINT', 'SIGTERM', 'SIGHUP'].map((signalName) => {
555
+ const handler = () => {
556
+ interruptedSignal = interruptedSignal || signalName;
557
+ abort.abort();
558
+ };
559
+ process.once(signalName, handler);
560
+ return [signalName, handler];
561
+ });
562
+ const onExit = () => {
563
+ if (finalized) return;
564
+ try {
565
+ writeFinal({
566
+ ...runningReceipt,
567
+ status: interruptedSignal ? 'cancelled' : 'failed',
568
+ signal: interruptedSignal || null,
569
+ finished_at: new Date().toISOString(),
570
+ error: interruptedSignal ? 'engine ask interrupted' : 'engine ask exited before completion',
571
+ });
572
+ } catch {}
573
+ };
574
+ process.once('exit', onExit);
575
+
576
+ try {
577
+ const answers = await runEngineAskJobs(parsed.jobs, {
578
+ root,
579
+ concurrency: parsed.concurrency,
580
+ timeoutMs: parsed.timeoutMs,
581
+ executeAskJob: deps.executeAskJob,
582
+ signal: abort.signal,
583
+ onOutputChunk: (chunk, stream) => appendLiveLog(liveLogPath, chunk, stream),
584
+ });
585
+ const receipt = engineAskReceipt(answers, {
586
+ concurrency: parsed.concurrency,
587
+ timeoutMs: parsed.timeoutMs,
588
+ at: startedAt,
589
+ });
590
+ let status = 'completed';
591
+ if (interruptedSignal || receipt.summary.cancelled) status = 'cancelled';
592
+ else if (receipt.summary.timed_out) status = 'timed_out';
593
+ else if (receipt.summary.failed) status = 'failed';
594
+ writeFinal({
595
+ ...receipt,
596
+ status,
597
+ pid: runningReceipt.pid,
598
+ pgid: runningReceipt.pgid,
599
+ engine: runningReceipt.engine,
600
+ engines,
601
+ started_at: startedAt,
602
+ finished_at: new Date().toISOString(),
603
+ signal: interruptedSignal || null,
604
+ live_log: runningReceipt.live_log,
605
+ });
606
+
607
+ if (parsed.json) {
608
+ console.log(JSON.stringify({ ...receipt, status, receipt_path: relativeReceiptPath, live_log: runningReceipt.live_log }, null, 2));
609
+ } else {
610
+ for (const answer of answers) {
611
+ console.log(`\n${answer.label} (${answer.engine})`);
612
+ const output = String(answer.stdout || '').trim() || String(answer.stderr || '').trim();
613
+ if (answer.ok) console.log(output);
614
+ else console.log(`failed: ${compactFailure(answer)}`);
615
+ }
616
+ console.log('\nsummary:');
617
+ for (const answer of answers) console.log(` ${answer.label} (${answer.engine}): ${answerStatus(answer)}`);
618
+ console.log(`\nreceipt: ${relativeReceiptPath}\n`);
619
+ }
620
+ return receipt.summary.failed || receipt.summary.timed_out || receipt.summary.cancelled ? 1 : 0;
621
+ } catch (error) {
622
+ writeFinal({
623
+ ...runningReceipt,
624
+ status: interruptedSignal ? 'cancelled' : 'failed',
625
+ signal: interruptedSignal || null,
626
+ finished_at: new Date().toISOString(),
627
+ error: String(error && error.message ? error.message : error),
628
+ });
629
+ throw error;
630
+ } finally {
631
+ for (const [signalName, handler] of signalHandlers) process.removeListener(signalName, handler);
632
+ process.removeListener('exit', onExit);
633
+ }
634
+ }
635
+
636
+ module.exports = {
637
+ MAX_ASK_CONCURRENCY,
638
+ MAX_ASK_JOBS,
639
+ MAX_ASK_PROMPT_BYTES,
640
+ parseEngineAskArgs,
641
+ buildReadOnlyEngineInvocation,
642
+ runAskProcess,
643
+ runEngineAskJobs,
644
+ runEngineAskCommand,
645
+ };