atris 3.60.0 → 3.62.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 (41) hide show
  1. package/README.md +37 -5
  2. package/atris/skills/engines/SKILL.md +9 -9
  3. package/bin/atris.js +1 -0
  4. package/commands/autoland.js +36 -5
  5. package/commands/autopilot.js +105 -6
  6. package/commands/engine.js +96 -21
  7. package/commands/host.js +56 -4
  8. package/commands/land.js +129 -8
  9. package/commands/member.js +33 -8
  10. package/commands/mission.js +112 -3
  11. package/commands/one-lap.js +14 -10
  12. package/commands/stream.js +56 -22
  13. package/commands/team.js +82 -10
  14. package/commands/worktree.js +39 -0
  15. package/commands/youtube.js +36 -26
  16. package/lib/engine-ask.js +112 -9
  17. package/lib/engine-registry.js +283 -39
  18. package/lib/fleet.js +444 -26
  19. package/lib/functional-owner.js +15 -6
  20. package/lib/host.js +311 -175
  21. package/lib/member-engine.js +11 -1
  22. package/lib/member-park.js +138 -0
  23. package/lib/member-scaffold.js +37 -0
  24. package/lib/roster-markdown.js +1 -1
  25. package/lib/roster-prep.js +289 -0
  26. package/lib/roster-runs.js +389 -0
  27. package/lib/roster-suggest.js +145 -0
  28. package/lib/router-brain.js +111 -3
  29. package/lib/self-drive.js +26 -2
  30. package/lib/state-detection.js +3 -3
  31. package/lib/task-db.js +38 -3
  32. package/lib/team-lineup.js +7 -2
  33. package/lib/todo.js +1 -1
  34. package/lib/wish-audit.js +1 -1
  35. package/package.json +1 -1
  36. package/scripts/det/cpu-hotspots.js +216 -0
  37. package/scripts/det/ytnotes +105 -19
  38. package/scripts/det/ytquote-repair.js +18 -4
  39. package/templates/members/host/MEMBER.md +5 -3
  40. package/templates/members/host/MISSION.md +1 -1
  41. package/templates/members/host/skills/host/SKILL.md +61 -12
@@ -26,6 +26,7 @@ const {
26
26
  rosterModelLabel,
27
27
  rosterPickFromValue,
28
28
  } = require('./engine-registry');
29
+ const { isParkedText } = require('./member-park');
29
30
 
30
31
  const SEARCH_WORDS = Object.freeze(['navigator', 'researcher', 'research', 'search', 'scout']);
31
32
  const REVIEW_WORDS = Object.freeze(['validator', 'reviewer', 'review', 'judge', 'critic', 'verifier']);
@@ -211,8 +212,16 @@ function memberRosterEngine(memberName, root = process.cwd(), options = {}) {
211
212
  }
212
213
  }
213
214
 
215
+ // A parked card stays on disk and still runs by name; this view (the roster
216
+ // team block and the boot lineup) just leaves it out.
217
+ function memberCardParked(root, name) {
218
+ const card = findMemberCard(root, name);
219
+ if (!card) return false;
220
+ try { return isParkedText(fs.readFileSync(card, 'utf8')); } catch { return false; }
221
+ }
222
+
214
223
  // One row per member for `atris engine roster`, plus a warning for every
215
- // team line that could not be used.
224
+ // team line that could not be used. Parked members are skipped.
216
225
  function teamRosterView(root = process.cwd(), options = {}) {
217
226
  const state = options.rosterState || readRosterState(root, options);
218
227
  const names = new Set(listTeamMembers(root));
@@ -223,6 +232,7 @@ function teamRosterView(root = process.cwd(), options = {}) {
223
232
  else if (layer.scope === 'project') warnings.push({ file: line.file, line: line.line, text: line.text, message: `there is no team member named ${line.member}, so the line does nothing` });
224
233
  }
225
234
  }
235
+ for (const name of [...names]) if (memberCardParked(root, name)) names.delete(name);
226
236
  const rows = [...names].sort().map((name) => {
227
237
  const picked = resolveEngineForMember(name, root, { ...options, rosterState: state });
228
238
  const line = teamLineFor(state, name);
@@ -0,0 +1,138 @@
1
+ 'use strict';
2
+
3
+ // A parked member keeps its folder and saved state; only the views skip it.
4
+ // Parking is two frontmatter lines in MEMBER.md:
5
+ // status: parked
6
+ // parked_note: <date> <reason>
7
+ // Views (`atris team`, the roster team block, the boot lineup, owner
8
+ // inference) read isParked*. Nothing that runs a member reads it, so a
9
+ // parked member still runs when called by name.
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+
14
+ const STATUS_LINE_RE = /^status:[ \t]*(['"]?)([^'"#\r\n]*?)\1[ \t]*(?:#.*)?$/m;
15
+ const MEMBER_NAME_RE = /^[a-zA-Z0-9._-]+$/;
16
+
17
+ function memberCardPath(root, name) {
18
+ return path.join(root, 'atris', 'team', name, 'MEMBER.md');
19
+ }
20
+
21
+ // Splits text into lines that keep their own endings, so a rewrite can
22
+ // touch one line and leave every other byte, including mixed LF and CRLF,
23
+ // exactly as written.
24
+ function splitKeepEndings(text) {
25
+ return String(text || '').match(/[^\n]*\n|[^\n]+$/g) || [];
26
+ }
27
+
28
+ const lineBody = (line) => line.replace(/\r?\n$/, '');
29
+
30
+ // The frontmatter is the lines between an opening "---" on the first line
31
+ // and the next line that is exactly "---". An empty block is fine. Returns
32
+ // { lines, open, close } as line indexes, or null when there is none.
33
+ function frontmatterBlock(lines) {
34
+ if (!lines.length || lineBody(lines[0]).replace(/^\uFEFF/, '') !== '---') return null;
35
+ for (let at = 1; at < lines.length; at += 1) {
36
+ if (lineBody(lines[at]).trimEnd() === '---') return { open: 0, close: at };
37
+ }
38
+ return null;
39
+ }
40
+
41
+ function frontmatterStatus(text) {
42
+ const lines = splitKeepEndings(text);
43
+ const block = frontmatterBlock(lines);
44
+ if (!block) return '';
45
+ const inside = lines.slice(block.open + 1, block.close).map(lineBody).join('\n');
46
+ const match = STATUS_LINE_RE.exec(inside);
47
+ return match ? match[2].trim().toLowerCase() : '';
48
+ }
49
+
50
+ function isParkedText(text) {
51
+ return frontmatterStatus(text) === 'parked';
52
+ }
53
+
54
+ // For a frontmatter object already parsed by commands/member.js.
55
+ function isParkedFrontmatter(frontmatter) {
56
+ return String((frontmatter && frontmatter.status) || '').trim().toLowerCase() === 'parked';
57
+ }
58
+
59
+ function isMemberParked(root, name) {
60
+ const key = String(name || '').trim();
61
+ if (!key || !MEMBER_NAME_RE.test(key)) return false;
62
+ try {
63
+ return isParkedText(fs.readFileSync(memberCardPath(root, key), 'utf8'));
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ function todayStamp(now = new Date()) {
70
+ const date = now instanceof Date ? now : new Date(now);
71
+ const pad = (value) => String(value).padStart(2, '0');
72
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
73
+ }
74
+
75
+ function oneLine(text) {
76
+ return String(text || '').replace(/[\r\n]+/g, ' ').replace(/[–—]/g, '-').replace(/\s+/g, ' ').trim();
77
+ }
78
+
79
+ // Rewrites only the status and parked_note lines; every other byte of the
80
+ // file stays as it was. Returns { ok, changed, message } or { ok: false, error }.
81
+ function setMemberParked(root, name, { parked, note = '', now = new Date() } = {}) {
82
+ const key = String(name || '').trim();
83
+ if (!key || !MEMBER_NAME_RE.test(key)) {
84
+ return { ok: false, error: `no team member named ${key || '(blank)'}. see the team with: atris team --all` };
85
+ }
86
+ const file = memberCardPath(root, key);
87
+ let text;
88
+ try {
89
+ text = fs.readFileSync(file, 'utf8');
90
+ } catch {
91
+ return { ok: false, error: `no team member named ${key}. see the team with: atris team --all` };
92
+ }
93
+ const already = isParkedText(text);
94
+ if (parked && already) return { ok: true, changed: false, message: `${key} is already parked` };
95
+ if (!parked && !already) return { ok: true, changed: false, message: `${key} is not parked` };
96
+
97
+ const lines = splitKeepEndings(text);
98
+ const block = frontmatterBlock(lines);
99
+ const endingOf = (line) => (/\r\n$/.test(line) ? '\r\n' : '\n');
100
+ let next;
101
+ if (parked) {
102
+ const reason = oneLine(note) || 'parked by hand';
103
+ const statusLine = 'status: parked';
104
+ const noteLine = `parked_note: ${todayStamp(now)} ${reason}; unpark with atris team unpark ${key}`;
105
+ if (!block) {
106
+ const eol = /\r\n/.test(text) ? '\r\n' : '\n';
107
+ next = `---${eol}name: ${key}${eol}${statusLine}${eol}${noteLine}${eol}---${eol}${text}`;
108
+ } else {
109
+ const inside = (at) => at > block.open && at < block.close;
110
+ const statusAt = lines.findIndex((line, at) => inside(at) && /^status:/.test(line));
111
+ const noteAt = lines.findIndex((line, at) => inside(at) && /^parked_note:/.test(line));
112
+ // A replaced line keeps its own ending; nothing else moves.
113
+ if (statusAt >= 0) lines[statusAt] = statusLine + endingOf(lines[statusAt]);
114
+ if (noteAt >= 0) lines[noteAt] = noteLine + endingOf(lines[noteAt]);
115
+ if (statusAt < 0 || noteAt < 0) {
116
+ const nameAt = lines.findIndex((line, at) => inside(at) && /^name:/.test(line));
117
+ const anchor = statusAt >= 0 ? statusAt : nameAt >= 0 ? nameAt : block.open;
118
+ const eol = endingOf(lines[anchor]);
119
+ const insert = [...(statusAt < 0 ? [statusLine + eol] : []), ...(noteAt < 0 ? [noteLine + eol] : [])];
120
+ lines.splice(anchor + 1, 0, ...insert);
121
+ }
122
+ next = lines.join('');
123
+ }
124
+ } else {
125
+ next = lines
126
+ .filter((line, at) => !(block && at > block.open && at < block.close && /^(status|parked_note):/.test(line)))
127
+ .join('');
128
+ }
129
+ fs.writeFileSync(file, next, 'utf8');
130
+ return { ok: true, changed: true, message: parked ? `parked ${key}` : `unparked ${key}` };
131
+ }
132
+
133
+ module.exports = {
134
+ isMemberParked,
135
+ isParkedFrontmatter,
136
+ isParkedText,
137
+ setMemberParked,
138
+ };
@@ -1,5 +1,6 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
+ const crypto = require('crypto');
3
4
 
4
5
  const MEMBER_DIRS = Object.freeze(['skills', 'tools', 'context', 'logs']);
5
6
 
@@ -190,8 +191,44 @@ function memberBundlePresent(memberDir) {
190
191
  return true;
191
192
  }
192
193
 
194
+ function updateMemberSkills(templateDir, memberDir) {
195
+ const sourceRoot = path.join(templateDir, 'skills');
196
+ if (!fs.existsSync(sourceRoot)) return [];
197
+ const targetRoot = path.join(memberDir, 'skills');
198
+ const updated = [];
199
+ const copySkills = (source, target, relative = '') => {
200
+ if (fs.existsSync(target) && !fs.lstatSync(target).isDirectory()) {
201
+ throw new Error(`Member skills path is not a directory: ${target}`);
202
+ }
203
+ fs.mkdirSync(target, { recursive: true });
204
+ for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
205
+ const rel = path.join(relative, entry.name);
206
+ const sourceFile = path.join(source, entry.name);
207
+ const targetFile = path.join(target, entry.name);
208
+ if (entry.isDirectory()) {
209
+ copySkills(sourceFile, targetFile, rel);
210
+ } else if (entry.isFile()) {
211
+ const content = fs.readFileSync(sourceFile);
212
+ if (fs.existsSync(targetFile) && fs.lstatSync(targetFile).isFile()
213
+ && fs.readFileSync(targetFile).equals(content)) continue;
214
+ const temporary = path.join(target, `.${entry.name}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`);
215
+ try {
216
+ fs.writeFileSync(temporary, content, { flag: 'wx' });
217
+ fs.renameSync(temporary, targetFile);
218
+ } finally {
219
+ fs.rmSync(temporary, { force: true });
220
+ }
221
+ updated.push(path.join('skills', rel));
222
+ }
223
+ }
224
+ };
225
+ copySkills(sourceRoot, targetRoot);
226
+ return updated;
227
+ }
228
+
193
229
  module.exports = {
194
230
  ensureMemberBundle,
195
231
  memberBundlePresent,
196
232
  memberMarkdown,
233
+ updateMemberSkills,
197
234
  };
@@ -14,7 +14,7 @@
14
14
  // build: claude opus 5.5, backup codex
15
15
 
16
16
  const OLD_ROSTER_NOTE = '> edit any time. each line is job: engine and model, an optional effort (low, medium, high, xhigh, max), then optional "backup <engine and model>", "max 20 min", and "until 2026-10-24" (the year is required).';
17
- const ROSTER_NOTE = '> edit any time. each "## job" lists its workers in order, one per line: "- tool, model: name", then optional "effort: medium", "max: 20 min", and "until 2026-10-24" (the year is required). the first ready worker leads and the rest back it up.';
17
+ const ROSTER_NOTE = '> edit any time. each "## job" lists its workers in order, one per line: "- tool, model: name", then optional "effort: medium", "max: 20 min", "prep: search" (that job reads the task first and hands this worker a short brief), and "until 2026-10-24" (the year is required). the first ready worker leads and the rest back it up.';
18
18
 
19
19
  const ROSTER_MARKDOWN_TEMPLATE = [
20
20
  '# roster',
@@ -0,0 +1,289 @@
1
+ 'use strict';
2
+
3
+ // The prep pass. A heavy roster worker whose line says "prep: search" does
4
+ // not explore on its own: before it runs, the search job's lead reads the
5
+ // task read-only and writes a trimmed brief (file:line pointers, short
6
+ // excerpts, the facts that matter). The heavy worker gets its normal prompt
7
+ // plus that brief and one instruction: work from the brief. Heavy models
8
+ // think; cheap ones gather.
9
+ //
10
+ // Prep is best effort. When it fails, stalls, or says nothing, the heavy
11
+ // worker runs with its normal prompt and the record says why prep was
12
+ // skipped. The prep pass records its own line in roster_runs.jsonl.
13
+
14
+ const {
15
+ rosterJobKey,
16
+ rosterJobLabel,
17
+ resolveJobTeam,
18
+ } = require('./engine-registry');
19
+ const { spawnSync } = require('child_process');
20
+ const { buildReadOnlyEngineInvocation, buildAskSpawnEnv, runAskProcess } = require('./engine-ask');
21
+ const { appendRosterRun, parseRunUsage } = require('./roster-runs');
22
+
23
+ // A brief is at most 300 lines and 20 KB: enough for a dozen file:line
24
+ // pointers with short excerpts, small enough that a heavy model reads it in
25
+ // one pass instead of exploring.
26
+ const PREP_MAX_LINES = 300;
27
+ const PREP_MAX_BYTES = 20 * 1024;
28
+ // The prep worker's own time cap when its roster line sets none.
29
+ const PREP_DEFAULT_SECONDS = 300;
30
+ // How much of the heavy worker's own instructions the prep worker sees.
31
+ const PREP_CONTEXT_LINES = 80;
32
+ const PREP_CONTEXT_BYTES = 6 * 1024;
33
+ const PREP_BLOCK_HEADING = '## brief from the prep pass';
34
+ const PREP_CUT_NOTE = '(brief cut to fit its size cap)';
35
+
36
+ function byteLength(text) {
37
+ return Buffer.byteLength(String(text || ''), 'utf8');
38
+ }
39
+
40
+ // Whole lines only, never more than maxLines lines or maxBytes bytes.
41
+ function fitLines(lines, maxLines, maxBytes) {
42
+ const out = [];
43
+ let bytes = 0;
44
+ for (const line of lines.slice(0, maxLines)) {
45
+ const size = byteLength(line) + (out.length ? 1 : 0);
46
+ if (bytes + size > maxBytes) break;
47
+ out.push(line);
48
+ bytes += size;
49
+ }
50
+ return out;
51
+ }
52
+
53
+ // The prep worker's answer cut to the cap, note included. Empty when the
54
+ // answer had nothing in it.
55
+ function capBrief(raw) {
56
+ const text = String(raw || '').replace(/\r\n/g, '\n').trim();
57
+ if (!text) return { text: '', lines: 0, bytes: 0, cut: false };
58
+ const all = text.split('\n');
59
+ if (all.length <= PREP_MAX_LINES && byteLength(text) <= PREP_MAX_BYTES) {
60
+ return { text, lines: all.length, bytes: byteLength(text), cut: false };
61
+ }
62
+ const kept = fitLines(all, PREP_MAX_LINES - 1, PREP_MAX_BYTES - byteLength(PREP_CUT_NOTE) - 1);
63
+ while (kept.length && !kept[kept.length - 1].trim()) kept.pop();
64
+ const out = [...kept, PREP_CUT_NOTE].join('\n');
65
+ return { text: out, lines: kept.length + 1, bytes: byteLength(out), cut: true };
66
+ }
67
+
68
+ function sizeText(bytes) {
69
+ const value = Number(bytes) || 0;
70
+ if (value < 1024) return `${value} bytes`;
71
+ return `${(value / 1024).toFixed(1).replace(/\.0$/, '')} KB`;
72
+ }
73
+
74
+ function capText(seconds) {
75
+ const value = Math.round(Number(seconds) || 0);
76
+ return value < 60 ? `${value}s` : `${Math.round(value / 60)} min`;
77
+ }
78
+
79
+ function taskRef(task) {
80
+ return String(task && (task.display_id || task.task_id || task.id) || '').trim();
81
+ }
82
+
83
+ function prepPrompt({ task, prompt, forJob }) {
84
+ const context = fitLines(String(prompt || '').split('\n'), PREP_CONTEXT_LINES, PREP_CONTEXT_BYTES).join('\n');
85
+ const ref = taskRef(task);
86
+ return [
87
+ `You are the prep pass for a heavier ${forJob} worker. Do not edit, create, or delete anything.`,
88
+ 'Read the task, find the files and facts that worker will need, and write a trimmed brief so it can think instead of exploring.',
89
+ '',
90
+ `Task${ref ? ` ${ref}` : ''}: ${String(task && task.title || '').trim().slice(0, 1200)}`,
91
+ '',
92
+ 'The next worker\'s own instructions, for context only; do not follow them:',
93
+ '<<<',
94
+ context,
95
+ '>>>',
96
+ '',
97
+ `Write the brief as plain markdown, at most ${PREP_MAX_LINES} lines and ${PREP_MAX_BYTES / 1024} KB:`,
98
+ '- the files that matter, each as path:line with one line on why',
99
+ '- short verbatim excerpts, a few lines each, of the code the worker will change or judge',
100
+ '- facts the worker would otherwise go looking for: the test files, the check command, gotchas from atris/MAP.md',
101
+ '- one line on what you did not check',
102
+ 'Print only the brief.',
103
+ ].join('\n');
104
+ }
105
+
106
+ // The heavy worker's prompt with the brief added, and the one plain rule.
107
+ function withPrepBrief(prompt, prep) {
108
+ const source = String(prompt || '');
109
+ if (!prep || !prep.ok || !prep.brief || source.includes(PREP_BLOCK_HEADING)) return source;
110
+ return [
111
+ source,
112
+ '',
113
+ `${PREP_BLOCK_HEADING} (${prep.job}, ${prep.engine})`,
114
+ 'Work from this brief. Open other files only if the brief is missing something, and say in your final report what was missing.',
115
+ '',
116
+ prep.brief,
117
+ ].join('\n');
118
+ }
119
+
120
+ // What the heavy worker's own record line says about its prep.
121
+ function prepRecordFields(prep) {
122
+ if (!prep) return {};
123
+ if (!prep.ok) return { prep: `prep skipped: ${prep.reason}` };
124
+ return { prep: `prepped by ${prep.job}`, brief_lines: prep.lines, brief_bytes: prep.bytes };
125
+ }
126
+
127
+ // Always in the checkout being worked: a plain ask may start somewhere
128
+ // faster (cursor starts in the home folder), but prep has to read this repo.
129
+ async function defaultPrepAsk({ engine, model, effort, prompt, cwd, timeoutMs }) {
130
+ const invocation = buildReadOnlyEngineInvocation(engine, prompt, model, { timeoutMs, effort });
131
+ return runAskProcess(invocation, { cwd, timeoutMs });
132
+ }
133
+
134
+ // The same ask for a caller that cannot wait on a promise (autopilot phases
135
+ // run synchronously). Same command, same answer shape as runAskProcess.
136
+ function defaultPrepAskSync({ engine, model, effort, prompt, cwd, timeoutMs }) {
137
+ const invocation = buildReadOnlyEngineInvocation(engine, prompt, model, { timeoutMs, effort });
138
+ const run = spawnSync(invocation.bin, invocation.args, {
139
+ cwd,
140
+ env: buildAskSpawnEnv(),
141
+ encoding: 'utf8',
142
+ timeout: timeoutMs,
143
+ killSignal: 'SIGKILL',
144
+ maxBuffer: 4 * 1024 * 1024,
145
+ stdio: ['ignore', 'pipe', 'pipe'],
146
+ });
147
+ const stdout = String(run.stdout || '');
148
+ const stderr = String(run.stderr || '');
149
+ const timedOut = Boolean(run.error && run.error.code === 'ETIMEDOUT');
150
+ const spawnError = run.error && !timedOut ? run.error : null;
151
+ const hasOutput = Boolean(stdout.trim() || stderr.trim());
152
+ let reason = 'ok';
153
+ if (timedOut) reason = 'timeout';
154
+ else if (spawnError) reason = 'spawn_error';
155
+ else if (run.status !== 0) reason = `exit_${run.status == null ? 'unknown' : run.status}`;
156
+ else if (!hasOutput) reason = 'no_output';
157
+ return { ok: reason === 'ok', reason, timed_out: timedOut, stdout, stderr: spawnError ? `${stderr}${spawnError.message}` : stderr };
158
+ }
159
+
160
+ function skipped(job, reason, extra = {}) {
161
+ return { ok: false, job, reason, brief: '', lines: 0, bytes: 0, ...extra };
162
+ }
163
+
164
+ // Who preps and with what, or { skip } when prep cannot run at all.
165
+ function planPrepPass({ prepJob, forJob = 'build', task, prompt = '', worktreePath, root = process.cwd() }) {
166
+ const key = rosterJobKey(prepJob);
167
+ const label = key ? rosterJobLabel(key) : String(prepJob || '');
168
+ const forKey = rosterJobKey(forJob);
169
+ const forLabel = forKey ? rosterJobLabel(forKey) : String(forJob || 'build');
170
+ if (!key) return { skip: skipped(label, `"${prepJob}" is not a job`) };
171
+ if (key === forKey) return { skip: skipped(label, `${forLabel} cannot prep for itself`) };
172
+ let lead = null;
173
+ try {
174
+ lead = resolveJobTeam(key, root, { lowStakes: false }).lead;
175
+ } catch {
176
+ lead = null;
177
+ }
178
+ if (!lead) return { skip: skipped(label, `${label} has no ready worker`) };
179
+ const capSeconds = Number(lead.roster_max_seconds) > 0 ? Number(lead.roster_max_seconds) : PREP_DEFAULT_SECONDS;
180
+ return {
181
+ label,
182
+ forLabel,
183
+ lead,
184
+ engine: lead.id,
185
+ model: lead.roster_model || '',
186
+ capSeconds,
187
+ request: {
188
+ engine: lead.id,
189
+ model: lead.roster_model || '',
190
+ effort: lead.roster_effort || '',
191
+ prompt: prepPrompt({ task, prompt, forJob: forLabel }),
192
+ cwd: worktreePath || root,
193
+ timeoutMs: capSeconds * 1000,
194
+ },
195
+ };
196
+ }
197
+
198
+ // Judge the prep worker's answer, write its run line, and hand back the brief.
199
+ function finishPrepPass(plan, answer, { startedMs, endedMs, task, root = process.cwd(), record = {} }) {
200
+ const { label, forLabel, lead, engine, model, capSeconds } = plan;
201
+ answer = answer || { ok: false, reason: 'no_answer', stdout: '' };
202
+ const brief = answer.ok && !answer.timed_out ? capBrief(answer.stdout) : capBrief('');
203
+ let outcome = 'landed';
204
+ let reason = '';
205
+ if (answer.timed_out) {
206
+ outcome = 'stalled';
207
+ reason = `stalled at ${capText(capSeconds)}`;
208
+ } else if (!answer.ok) {
209
+ outcome = 'failed';
210
+ const exit = /^exit_(\d+)$/.exec(String(answer.reason || ''));
211
+ reason = exit ? `${engine} exited ${exit[1]}`
212
+ : answer.reason === 'no_output' ? 'returned nothing'
213
+ : answer.reason === 'spawn_error' ? `${engine} could not start`
214
+ : `${engine} failed`;
215
+ } else if (!brief.text) {
216
+ outcome = 'failed';
217
+ reason = 'returned nothing';
218
+ }
219
+ appendRosterRun(root, {
220
+ at: new Date(startedMs).toISOString(),
221
+ job: label,
222
+ engine,
223
+ ...(model ? { model } : {}),
224
+ ...(lead.roster_effort ? { effort: lead.roster_effort } : {}),
225
+ max_seconds: capSeconds,
226
+ seconds: Math.max(0, (endedMs - startedMs) / 1000),
227
+ outcome,
228
+ detail: reason || `brief for ${forLabel}, ${brief.lines} lines, ${sizeText(brief.bytes)}`,
229
+ source: 'prep',
230
+ ...(taskRef(task) ? { task: taskRef(task) } : {}),
231
+ ...(record.member ? { member: record.member } : {}),
232
+ ...parseRunUsage({ stdout: answer.stdout, stderr: answer.stderr }),
233
+ });
234
+ if (reason) return skipped(label, reason, { engine });
235
+ return { ok: true, job: label, engine, brief: brief.text, lines: brief.lines, bytes: brief.bytes, cut: brief.cut };
236
+ }
237
+
238
+ function askFailure(error) {
239
+ return { ok: false, reason: 'runner_error', stderr: String(error && error.message || error), stdout: '' };
240
+ }
241
+
242
+ // One prep pass for one task. Never throws: every way it can go wrong comes
243
+ // back as { ok: false, reason } so the heavy worker still runs.
244
+ async function runPrepPass({
245
+ ask = defaultPrepAsk,
246
+ clock = Date.now,
247
+ record = {},
248
+ ...options
249
+ } = {}) {
250
+ const plan = planPrepPass(options);
251
+ if (plan.skip) return plan.skip;
252
+ const startedMs = clock();
253
+ let answer;
254
+ try {
255
+ answer = await ask(plan.request);
256
+ } catch (error) {
257
+ answer = askFailure(error);
258
+ }
259
+ return finishPrepPass(plan, answer, { startedMs, endedMs: clock(), task: options.task, root: options.root, record });
260
+ }
261
+
262
+ // The same pass for a synchronous caller. The ask must answer synchronously.
263
+ function runPrepPassSync({
264
+ ask = defaultPrepAskSync,
265
+ clock = Date.now,
266
+ record = {},
267
+ ...options
268
+ } = {}) {
269
+ const plan = planPrepPass(options);
270
+ if (plan.skip) return plan.skip;
271
+ const startedMs = clock();
272
+ let answer;
273
+ try {
274
+ answer = ask(plan.request);
275
+ } catch (error) {
276
+ answer = askFailure(error);
277
+ }
278
+ return finishPrepPass(plan, answer, { startedMs, endedMs: clock(), task: options.task, root: options.root, record });
279
+ }
280
+
281
+ module.exports = {
282
+ PREP_MAX_LINES,
283
+ PREP_MAX_BYTES,
284
+ capBrief,
285
+ withPrepBrief,
286
+ prepRecordFields,
287
+ runPrepPass,
288
+ runPrepPassSync,
289
+ };