atris 3.70.0 → 3.71.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 (79) 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 +38 -8
  7. package/commands/autoland.js +176 -7
  8. package/commands/autopilot.js +71 -11
  9. package/commands/campaign.js +779 -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/integrations.js +17 -5
  17. package/commands/interview.js +15 -3
  18. package/commands/land.js +22 -6
  19. package/commands/link.js +1 -0
  20. package/commands/member.js +359 -94
  21. package/commands/mission.js +249 -36
  22. package/commands/report.js +42 -8
  23. package/commands/run.js +9 -2
  24. package/commands/scout.js +9 -1
  25. package/commands/task.js +71 -17
  26. package/commands/team.js +212 -4
  27. package/commands/unknowns.js +15 -1
  28. package/commands/verify.js +46 -0
  29. package/commands/worktree.js +108 -15
  30. package/commands/write.js +12 -1
  31. package/commands/youtube.js +17 -2
  32. package/lib/accept-verify-gate.js +1 -0
  33. package/lib/account-bound.js +13 -0
  34. package/lib/auto-accept-certified.js +1 -0
  35. package/lib/autoland.js +13 -4
  36. package/lib/campaign-cadence.js +131 -0
  37. package/lib/campaign-core.js +330 -0
  38. package/lib/campaign-holdout.js +65 -0
  39. package/lib/campaign-loop.js +246 -0
  40. package/lib/campaign-missions.js +237 -0
  41. package/lib/campaign-new.js +251 -0
  42. package/lib/campaign-protect.js +75 -0
  43. package/lib/campaign-reading.js +147 -0
  44. package/lib/campaign-run-lock.js +59 -0
  45. package/lib/campaign-schema.js +196 -0
  46. package/lib/campaign-store.js +380 -0
  47. package/lib/campaign-sync.js +302 -0
  48. package/lib/campaign-trace.js +300 -0
  49. package/lib/conductor-artifacts.js +15 -1
  50. package/lib/daily-log.js +11 -1
  51. package/lib/dispatch-scout.js +18 -0
  52. package/lib/engine-ask.js +33 -3
  53. package/lib/engine-registry.js +2 -0
  54. package/lib/engine-resource.js +518 -0
  55. package/lib/fleet.js +125 -16
  56. package/lib/init-commit.js +80 -0
  57. package/lib/intents.js +8 -0
  58. package/lib/known-commands.js +1 -1
  59. package/lib/land-green.js +58 -7
  60. package/lib/landing-proof.js +352 -0
  61. package/lib/member-autoland.js +395 -18
  62. package/lib/member-board.js +7 -1
  63. package/lib/member-map-fold.js +126 -0
  64. package/lib/member-map-inbox.js +80 -0
  65. package/lib/member-progress.js +48 -1
  66. package/lib/member-report.js +127 -8
  67. package/lib/member-self-next-job.js +97 -0
  68. package/lib/member-task-pick.js +41 -14
  69. package/lib/member-up.js +249 -16
  70. package/lib/roster-prep.js +25 -2
  71. package/lib/runner-command.js +5 -0
  72. package/lib/task-db.js +47 -11
  73. package/lib/task-explanation.js +7 -0
  74. package/lib/task-list-keeper.js +8 -1
  75. package/lib/task-receipt.js +6 -1
  76. package/mcp/atris-mcp/index.mjs +212 -10
  77. package/package.json +2 -1
  78. package/scripts/det/engine-reading.js +37 -0
  79. package/scripts/det/ytnotes +29 -5
@@ -0,0 +1,251 @@
1
+ // atris campaign new "<goal>": the owner's engine drafts a judge for the goal,
2
+ // the CLI runs it to prove it fails today, shows the draft, and with --yes
3
+ // starts the campaign. A judge is a script outside the model: exit 0 means the
4
+ // goal is met. Asking goes through the read-only engine ask runner, the same
5
+ // way the campaign loop asks the owner for missions.
6
+
7
+ 'use strict';
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const { newCampaignId } = require('./campaign-store');
12
+
13
+ const DEFAULT_BUDGET = 20;
14
+ const DEFAULT_OWNER = 'mission-lead';
15
+ const DRAFT_TIMEOUT_SECONDS = 10 * 60;
16
+ const ASK_TIMEOUT_MS = 5 * 60 * 1000;
17
+ const ATTEMPTS = 2;
18
+ const MAX_SEATS = 3;
19
+ const MAX_SCRIPT_BYTES = 8 * 1024;
20
+
21
+ function money(value) {
22
+ return `$${Number(value).toFixed(2)}`;
23
+ }
24
+
25
+ function shellQuote(value) {
26
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
27
+ }
28
+
29
+ // Members with a MEMBER.md can take a seat.
30
+ function teamMembers(root) {
31
+ const dir = path.join(root, 'atris', 'team');
32
+ try {
33
+ return fs.readdirSync(dir, { withFileTypes: true })
34
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith('_') && fs.existsSync(path.join(dir, entry.name, 'MEMBER.md')))
35
+ .map((entry) => entry.name)
36
+ .sort();
37
+ } catch {
38
+ return [];
39
+ }
40
+ }
41
+
42
+ // A few facts about the workspace so the judge uses commands that exist here.
43
+ function workspaceHints(root) {
44
+ const lines = [];
45
+ try {
46
+ const entries = fs.readdirSync(root).filter((name) => !name.startsWith('.')).sort().slice(0, 30);
47
+ if (entries.length) lines.push(`Top-level files: ${entries.join(', ')}`);
48
+ } catch {}
49
+ try {
50
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
51
+ const scripts = Object.entries(pkg.scripts || {}).slice(0, 15).map(([name, cmd]) => ` npm run ${name}: ${String(cmd).slice(0, 120)}`);
52
+ if (scripts.length) lines.push('package.json scripts:', ...scripts);
53
+ } catch {}
54
+ return lines;
55
+ }
56
+
57
+ function judgePrompt({ goal, members, hints, previous }) {
58
+ return [
59
+ 'You write the judge for a new Atris campaign. Answer with JSON only.',
60
+ '',
61
+ `Goal: ${goal}`,
62
+ ...hints,
63
+ members.length ? `Members who can work on it: ${members.join(', ')}` : '',
64
+ '',
65
+ 'A judge is a POSIX sh script that runs from the workspace root. It exits 0 only when the goal is met.',
66
+ 'Today the goal is not met, so today it must exit non-zero.',
67
+ 'Rules:',
68
+ '- Check real outcomes outside any model: run tests, count files, grep output, run a local command. Never ask a model whether it is done.',
69
+ '- Print graded progress so a person sees how close it is: a line "ℹ pass N" and a line "ℹ fail M" (checks met, checks not met).',
70
+ '- Read and run only; change nothing. Finish in under two minutes. Use only commands that exist here.',
71
+ previous ? `Your last draft did not work: ${previous}. Write a different judge.` : '',
72
+ '',
73
+ 'Reply with one JSON object and nothing else:',
74
+ `{"title": "<short name, under 60 characters>", "judge_script": "<the whole sh script>", "why": "<one sentence: what it checks and why it fails today>", "seats": ["<up to ${MAX_SEATS} members from the list who should work on this>"]}`,
75
+ ].filter((line) => line !== '').join('\n');
76
+ }
77
+
78
+ function parseDraft(text, members) {
79
+ const match = String(text || '').match(/\{[\s\S]*\}/);
80
+ if (!match) return null;
81
+ let row;
82
+ try {
83
+ row = JSON.parse(match[0]);
84
+ } catch {
85
+ return null;
86
+ }
87
+ if (!row || typeof row.judge_script !== 'string' || !row.judge_script.trim()) return null;
88
+ if (Buffer.byteLength(row.judge_script) > MAX_SCRIPT_BYTES) return null;
89
+ const known = new Set(members);
90
+ const seats = Array.isArray(row.seats) ? [...new Set(row.seats.filter((s) => typeof s === 'string' && known.has(s)))].slice(0, MAX_SEATS) : [];
91
+ return {
92
+ title: typeof row.title === 'string' && row.title.trim() ? row.title.trim().slice(0, 80) : '',
93
+ script: row.judge_script.trim(),
94
+ why: typeof row.why === 'string' ? row.why.trim().slice(0, 300) : '',
95
+ seats,
96
+ };
97
+ }
98
+
99
+ // The owner's engine through the read-only ask runner.
100
+ async function askOwner(root, view, prompt) {
101
+ const { resolveEngineForMember } = require('./member-engine');
102
+ const { runEngineAskJobs } = require('./engine-ask');
103
+ const pick = resolveEngineForMember(view.owner, root) || {};
104
+ const engine = (pick.engine && pick.engine.id) || 'claude';
105
+ const [answer] = await runEngineAskJobs([{ engine, model: pick.model || '', prompt, label: 'campaign new judge' }], { root, timeoutMs: ASK_TIMEOUT_MS });
106
+ return { text: (answer && answer.stdout) || '', engine, ok: Boolean(answer && answer.ok) };
107
+ }
108
+
109
+ function writeJudge(root, id, script) {
110
+ const rel = path.join('atris', 'campaigns', id, 'judge.sh');
111
+ const file = path.join(root, rel);
112
+ fs.mkdirSync(path.dirname(file), { recursive: true });
113
+ const body = script.startsWith('#!') ? script : `#!/bin/sh\n${script}`;
114
+ fs.writeFileSync(file, body.endsWith('\n') ? body : `${body}\n`, { mode: 0o755 });
115
+ return rel.split(path.sep).join('/');
116
+ }
117
+
118
+ // 126 and 127 mean the shell could not run a command; null means it hung or
119
+ // never started. Those are broken judges, not failing ones.
120
+ function brokenReading(reading) {
121
+ return reading.exit === null || reading.exit === undefined || reading.exit === 126 || reading.exit === 127;
122
+ }
123
+
124
+ // Ask, write, run; one retry with the reason when the draft passes today,
125
+ // cannot run, or is not a script.
126
+ async function draftJudge({ root, goal, owner, idFlag, timeout, ask, judge }) {
127
+ const members = teamMembers(root);
128
+ const hints = workspaceHints(root);
129
+ let previous = '';
130
+ let id = idFlag || '';
131
+ let last = null;
132
+ for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
133
+ const prompt = judgePrompt({ goal, members, hints, previous });
134
+ const answer = await ask(root, { id: id || 'new', owner, title: goal }, prompt);
135
+ const draft = parseDraft(answer.text, members);
136
+ if (!draft) {
137
+ previous = 'the reply was not one JSON object with a judge_script';
138
+ last = { error: `${owner}'s engine (${answer.engine || 'unknown'}) did not return a judge script` };
139
+ continue;
140
+ }
141
+ if (!draft.title) draft.title = goal.slice(0, 80);
142
+ if (!id) id = newCampaignId(draft.title);
143
+ const judgeFile = writeJudge(root, id, draft.script);
144
+ const command = `sh ${judgeFile}`;
145
+ const reading = await judge(command, timeout);
146
+ const result = { id, draft, judgeFile, command, reading, engine: answer.engine };
147
+ if (reading.passed) {
148
+ previous = `it passed today (${reading.summary || 'exit 0'}), so it cannot tell when the goal is met`;
149
+ last = { ...result, error: `the drafted judge already passes (${reading.summary || 'exit 0'}); a judge must fail first` };
150
+ continue;
151
+ }
152
+ if (brokenReading(reading)) {
153
+ previous = `it could not run (${reading.summary || `exit ${reading.exit}`})`;
154
+ last = { ...result, error: `the drafted judge could not run: ${reading.summary || `exit ${reading.exit}`}` };
155
+ continue;
156
+ }
157
+ return { ok: true, ...result };
158
+ }
159
+ if (id && !idFlag) fs.rmSync(path.join(root, 'atris', 'campaigns', id), { recursive: true, force: true });
160
+ return { ok: false, ...last };
161
+ }
162
+
163
+ function startArgs(result, { budget, owner, run }) {
164
+ const args = ['start', result.draft.title, '--judge', result.command, '--budget', String(budget), '--id', result.id, '--owner', owner];
165
+ for (const seat of result.draft.seats) args.push('--seat', seat);
166
+ if (run) args.push('--run');
167
+ return args;
168
+ }
169
+
170
+ function startCommand(result, options) {
171
+ const seats = result.draft.seats.map((seat) => ` --seat ${seat}`).join('');
172
+ return `atris campaign start ${shellQuote(result.draft.title)} --judge ${shellQuote(result.command)} --budget ${options.budget} --id ${result.id} --owner ${options.owner}${seats}`;
173
+ }
174
+
175
+ function renderDraft(result, options) {
176
+ const lines = [
177
+ `draft for: ${options.goal}`,
178
+ '',
179
+ `title ${result.draft.title}`,
180
+ `judge ${result.command}`,
181
+ `today fails, as it should: ${result.reading.summary || `exit ${result.reading.exit}`}`,
182
+ ];
183
+ if (result.draft.why) lines.push(`checks ${result.draft.why}`);
184
+ lines.push(`owner ${options.owner}`);
185
+ lines.push(`seats ${result.draft.seats.length ? result.draft.seats.join(', ') : 'none yet'}`);
186
+ lines.push(`budget ${money(options.budget)}`);
187
+ lines.push('', `read or edit the judge: ${result.judgeFile}`);
188
+ return lines.join('\n');
189
+ }
190
+
191
+ function askAtTerminal(question) {
192
+ if (!process.stdin.isTTY || !process.stderr.isTTY) return Promise.resolve(false);
193
+ const readline = require('readline');
194
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
195
+ return new Promise((resolve) => rl.question(question, (answer) => {
196
+ rl.close();
197
+ resolve(/^y(es)?$/i.test(answer.trim()));
198
+ }));
199
+ }
200
+
201
+ // deps.start is the start verb; deps.ask replaces the owner's engine in tests.
202
+ async function draftAndStart(args, ctx, deps = {}) {
203
+ const { readFlag } = require('./arg-parser');
204
+ const goal = String(deps.goal || '').trim();
205
+ if (!goal) return ctx.fail('say the goal: atris campaign new "<goal>" [--budget 20] [--owner <member>] [--yes] [--run]');
206
+ const budgetText = readFlag(args, '--budget', '');
207
+ const budget = budgetText ? Number(budgetText) : DEFAULT_BUDGET;
208
+ if (!(budget > 0)) return ctx.fail('--budget must be dollars above 0');
209
+ const owner = readFlag(args, '--owner', '') || DEFAULT_OWNER;
210
+ const timeout = Number(readFlag(args, '--timeout', '')) || DRAFT_TIMEOUT_SECONDS;
211
+ const options = { goal, budget, owner, run: args.includes('--run') };
212
+ if (!ctx.asJson) console.error(`asking ${owner}'s engine to draft a judge that fails today`);
213
+
214
+ const result = await draftJudge({
215
+ root: deps.root || process.cwd(), goal, owner, timeout,
216
+ idFlag: readFlag(args, '--id', ''),
217
+ ask: deps.ask || askOwner,
218
+ judge: ctx.judge,
219
+ });
220
+ if (!result.ok) {
221
+ if (ctx.asJson) {
222
+ console.log(JSON.stringify({ ok: false, error: result.error, draft: result.draft || null, reading: result.reading || null }));
223
+ return 1;
224
+ }
225
+ return ctx.fail(`${result.error}. Write the judge yourself: atris campaign start "<title>" --judge "<command that fails today>" --budget ${budget}`, 1);
226
+ }
227
+
228
+ const draft = { id: result.id, title: result.draft.title, judge: result.command, judge_file: result.judgeFile, why: result.draft.why, seats: result.draft.seats, owner, budget, engine: result.engine };
229
+ // Only a person at a terminal can say yes here; agents and pipes pass --yes.
230
+ const confirm = deps.confirm || askAtTerminal;
231
+ const canAsk = deps.confirm ? true : Boolean(process.stdin.isTTY && process.stderr.isTTY);
232
+ const asked = !args.includes('--yes') && !ctx.asJson && canAsk;
233
+ const yes = args.includes('--yes') || (asked && await confirm(`${renderDraft(result, options)}\n\nstart it${options.run ? ' and run it' : ''}? [y/N] `));
234
+ if (!yes) {
235
+ const command = startCommand(result, options);
236
+ const shown = asked ? 'not started.' : renderDraft(result, options);
237
+ ctx.out(`${shown}\nstart it: ${command}\n(or run this again with --yes)`,
238
+ { ok: true, action: 'campaign_drafted', draft, reading: result.reading, start_command: command });
239
+ return 0;
240
+ }
241
+ // Start with the reading already taken, so the judge does not run twice.
242
+ if (!ctx.asJson && args.includes('--yes')) console.log(renderDraft(result, options));
243
+ const startCtx = {
244
+ ...ctx,
245
+ judge: async () => result.reading,
246
+ out: (human, json) => ctx.out(human, { ...json, draft }),
247
+ };
248
+ return deps.start(startArgs(result, options), startCtx);
249
+ }
250
+
251
+ module.exports = { draftAndStart, judgePrompt, parseDraft };
@@ -0,0 +1,75 @@
1
+ // Protected paths: files a campaign's agents must not touch, like the judge's
2
+ // questions. Start saves the paths and the commit they started from in the
3
+ // campaign folder beside the campaign store, outside the checkout, so agents
4
+ // cannot edit the list; guard names any of them changed since, committed or not.
5
+
6
+ 'use strict';
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const { spawnSync } = require('child_process');
11
+
12
+ function git(root, args) {
13
+ const result = spawnSync('git', args, { cwd: root, encoding: 'utf8' });
14
+ return { ok: result.status === 0, out: (result.stdout || '').trim(), err: (result.stderr || '').trim() };
15
+ }
16
+
17
+ function checkoutTop(root) {
18
+ const top = git(root, ['rev-parse', '--show-toplevel']);
19
+ return top.ok ? fs.realpathSync(top.out) : null;
20
+ }
21
+
22
+ // The folder holding every campaign's own files, beside the campaign store.
23
+ function campaignHome(store) {
24
+ const { campaignDbPath } = require('./campaign-store');
25
+ return path.dirname((store && store.path) || campaignDbPath());
26
+ }
27
+
28
+ function protectFile(home, campaignId) {
29
+ return path.join(home, 'campaigns', campaignId, 'protect.json');
30
+ }
31
+
32
+ // The commit and checkout-relative paths to save, or an error in plain words.
33
+ function planProtect(root, paths) {
34
+ const topDir = checkoutTop(root);
35
+ if (!topDir) return { error: '--protect needs a git checkout' };
36
+ const head = git(root, ['rev-parse', 'HEAD']);
37
+ if (!head.ok) return { error: '--protect needs a checkout with at least one commit' };
38
+ const here = fs.realpathSync(root);
39
+ const rels = [];
40
+ for (const given of paths) {
41
+ const rel = path.relative(topDir, path.resolve(here, given)).split(path.sep).join('/');
42
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return { error: `--protect ${given} is not inside this checkout` };
43
+ if (!rels.includes(rel)) rels.push(rel);
44
+ }
45
+ return { paths: rels, base_commit: head.out };
46
+ }
47
+
48
+ function saveProtect(home, campaignId, plan) {
49
+ const file = protectFile(home, campaignId);
50
+ fs.mkdirSync(path.dirname(file), { recursive: true });
51
+ fs.writeFileSync(file, `${JSON.stringify({ paths: plan.paths, base_commit: plan.base_commit }, null, 2)}\n`);
52
+ return file;
53
+ }
54
+
55
+ function readProtect(home, campaignId) {
56
+ try {
57
+ return JSON.parse(fs.readFileSync(protectFile(home, campaignId), 'utf8'));
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ // Protected paths that differ from the base commit: committed, staged,
64
+ // unstaged, deleted, or new and untracked. Root is the checkout top, since
65
+ // the saved paths are relative to it.
66
+ function changedProtected(root, saved) {
67
+ const spec = ['--', ...saved.paths.map((rel) => `:(top,literal)${rel}`)];
68
+ const diff = git(root, ['diff', '--name-only', saved.base_commit, ...spec]);
69
+ if (!diff.ok) return { error: `cannot compare with ${String(saved.base_commit).slice(0, 12)}: ${diff.err || 'git diff failed'}` };
70
+ const untracked = git(root, ['ls-files', '--others', '--exclude-standard', '--full-name', ...spec]);
71
+ const changed = [...diff.out.split('\n'), ...untracked.out.split('\n')].filter(Boolean);
72
+ return { changed: [...new Set(changed)].sort() };
73
+ }
74
+
75
+ module.exports = { campaignHome, changedProtected, checkoutTop, planProtect, protectFile, readProtect, saveProtect };
@@ -0,0 +1,147 @@
1
+ // Graded judge readings: a number from 0 to 1 pulled from a judge's output,
2
+ // so a campaign learns "3 of 5 tests pass" instead of only "failed". Pure, no
3
+ // I/O. The reading rides on judge_ran as data.reading (lib/campaign-schema.js
4
+ // already allows it); traces use it as the reward signal
5
+ // (atris/features/campaign/traces.md).
6
+ //
7
+ // Formats, first match wins:
8
+ // reading: 0.6 a judge that states its own reading
9
+ // 3 of 5 live / 3/5 ok the judge's final line
10
+ // node --test ℹ pass 3 / ℹ fail 2 (also TAP "# pass 3", and "pass 3, fail 2")
11
+ // jest/vitest Tests: 1 failed, 4 passed, 5 total / Tests 1 failed | 4 passed (5)
12
+ // pytest 1 failed, 4 passed in 0.12s
13
+ // mocha 4 passing / 1 failing
14
+ // Output with no counts gives null: a reading is never invented.
15
+
16
+ 'use strict';
17
+
18
+ function round(value) {
19
+ return Math.round(Math.min(1, Math.max(0, value)) * 10000) / 10000;
20
+ }
21
+
22
+ function ratio(good, total) {
23
+ return total > 0 ? round(good / total) : null;
24
+ }
25
+
26
+ function count(line, word) {
27
+ const match = line.match(new RegExp(`(\\d+)\\s+${word}\\b`, 'i'));
28
+ return match ? Number(match[1]) : 0;
29
+ }
30
+
31
+ function stated(lines) {
32
+ for (let i = lines.length - 1; i >= 0; i--) {
33
+ const match = lines[i].match(/^reading\s*[:=]\s*(\d*\.?\d+)\s*$/i);
34
+ if (match) return round(Number(match[1]));
35
+ }
36
+ return null;
37
+ }
38
+
39
+ function finalOf(lines) {
40
+ const last = lines[lines.length - 1] || '';
41
+ const match = last.match(/^(\d+)\s*(?:of|\/)\s*(\d+)\b/i);
42
+ if (!match || Number(match[1]) > Number(match[2])) return null;
43
+ return ratio(Number(match[1]), Number(match[2]));
44
+ }
45
+
46
+ // node --test prints one summary block per run; a judge that chains runs
47
+ // (a && b) gets the sum of every block.
48
+ function nodeCounts(text) {
49
+ const tokens = /(?:^|,)[ \t]*(?:ℹ|#)?[ \t]*(pass|fail|cancelled)[ \t]+(\d+)[ \t]*(?=,|$)/gim;
50
+ const sums = { pass: 0, fail: 0, cancelled: 0 };
51
+ let seen = false;
52
+ for (const match of text.matchAll(tokens)) {
53
+ sums[match[1].toLowerCase()] += Number(match[2]);
54
+ seen = true;
55
+ }
56
+ return seen ? ratio(sums.pass, sums.pass + sums.fail + sums.cancelled) : null;
57
+ }
58
+
59
+ function lastLine(lines, pattern) {
60
+ for (let i = lines.length - 1; i >= 0; i--) if (pattern.test(lines[i])) return lines[i];
61
+ return null;
62
+ }
63
+
64
+ function jestCounts(lines) {
65
+ const line = lastLine(lines, /^Tests:?\s+.*\d+\s+(passed|failed)/i);
66
+ if (!line) return null;
67
+ return ratio(count(line, 'passed'), count(line, 'passed') + count(line, 'failed'));
68
+ }
69
+
70
+ function pytestCounts(lines) {
71
+ const line = lastLine(lines, /\d+\s+(passed|failed)\b/i);
72
+ if (!line) return null;
73
+ const errors = count(line, 'errors') || count(line, 'error');
74
+ return ratio(count(line, 'passed'), count(line, 'passed') + count(line, 'failed') + errors);
75
+ }
76
+
77
+ function mochaCounts(lines) {
78
+ const passing = lastLine(lines, /^\d+\s+passing\b/i);
79
+ if (!passing) return null;
80
+ const failing = lastLine(lines, /^\d+\s+failing\b/i);
81
+ const good = count(passing, 'passing');
82
+ return ratio(good, good + (failing ? count(failing, 'failing') : 0));
83
+ }
84
+
85
+ // The reading in [0, 1], or null when the output has no counts.
86
+ function judgeReading(output) {
87
+ const text = String(output || '').replace(/\r/g, '');
88
+ // Strip color codes so a colored reporter reads the same as a piped one.
89
+ const plain = text.replace(/\x1b\[[0-9;]*m/g, '');
90
+ const lines = plain.split('\n').map((line) => line.trim()).filter(Boolean);
91
+ if (!lines.length) return null;
92
+ for (const read of [stated, finalOf]) {
93
+ const value = read(lines);
94
+ if (value !== null) return value;
95
+ }
96
+ const node = nodeCounts(lines.join('\n'));
97
+ if (node !== null) return node;
98
+ for (const read of [jestCounts, pytestCounts, mochaCounts]) {
99
+ const value = read(lines);
100
+ if (value !== null) return value;
101
+ }
102
+ return null;
103
+ }
104
+
105
+ const TAIL_LINES = 40;
106
+ const TAIL_BYTES = 4096;
107
+
108
+ // The end of the judge's output, so the planner sees more than one line:
109
+ // the last 40 lines, at most 4 KB, color codes removed.
110
+ function outputTail(output) {
111
+ const lines = String(output || '').replace(/\r/g, '').replace(/\x1b\[[0-9;]*m/g, '').split('\n');
112
+ while (lines.length && !lines[lines.length - 1].trim()) lines.pop();
113
+ let tail = lines.slice(-TAIL_LINES).join('\n');
114
+ const bytes = Buffer.from(tail, 'utf8');
115
+ if (bytes.length > TAIL_BYTES) {
116
+ // Cut on a character boundary: drop continuation bytes at the cut.
117
+ let start = bytes.length - TAIL_BYTES;
118
+ while (start < bytes.length && (bytes[start] & 0xc0) === 0x80) start++;
119
+ tail = bytes.subarray(start).toString('utf8');
120
+ }
121
+ return tail;
122
+ }
123
+
124
+ // The data a judge_ran event carries for one run of the judge command.
125
+ // A judge that prints no pass/fail counts still said something; its last line
126
+ // beats a bare exit code.
127
+ function lastSaidLine(text) {
128
+ const lines = String(text || '').split('\n').map((line) => line.trim()).filter(Boolean);
129
+ return lines.length ? lines[lines.length - 1].slice(0, 200) : '';
130
+ }
131
+
132
+ function judgeRanData(result) {
133
+ const data = { passed: result.passed, summary: result.summary || lastSaidLine(result.output_tail) || `exit ${result.exit}`, seconds: result.seconds };
134
+ if (typeof result.reading === 'number' && Number.isFinite(result.reading)) data.reading = result.reading;
135
+ if (typeof result.output_tail === 'string' && result.output_tail) data.output_tail = result.output_tail;
136
+ return data;
137
+ }
138
+
139
+ // The output_tail of the latest judge run in fold order, or null.
140
+ function latestJudgeOutput(events) {
141
+ const { compareEvents } = require('./campaign-core');
142
+ const runs = (events || []).filter((e) => e.type === 'judge_ran').sort(compareEvents);
143
+ const last = runs[runs.length - 1];
144
+ return last && typeof last.data.output_tail === 'string' && last.data.output_tail ? last.data.output_tail : null;
145
+ }
146
+
147
+ module.exports = { judgeReading, judgeRanData, latestJudgeOutput, outputTail };
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+
3
+ // One driver per campaign on a computer. Two `campaign run` loops on the same
4
+ // campaign each planned and started missions, so they raced for the same
5
+ // budget and edited the same files (2026-10-15, cmp-suite-3min). The lock is
6
+ // a file beside the store naming the process that holds it; a lock whose
7
+ // process is gone is stale and is taken over.
8
+
9
+ const fs = require('fs');
10
+ const os = require('os');
11
+ const path = require('path');
12
+
13
+ function lockPath(home, campaignId) {
14
+ return path.join(home, 'campaigns', campaignId, 'run.lock');
15
+ }
16
+
17
+ function alive(pid) {
18
+ if (!Number.isInteger(pid) || pid <= 0) return false;
19
+ try {
20
+ process.kill(pid, 0);
21
+ return true;
22
+ } catch (error) {
23
+ return error.code === 'EPERM';
24
+ }
25
+ }
26
+
27
+ function readHolder(file) {
28
+ try {
29
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ // Returns { ok: true, release } or { ok: false, holder } when a live run holds it.
36
+ function acquireRunLock(home, campaignId, { pid = process.pid, host = os.hostname(), cwd = process.cwd(), isAlive = alive } = {}) {
37
+ const file = lockPath(home, campaignId);
38
+ fs.mkdirSync(path.dirname(file), { recursive: true });
39
+ const mine = { pid, host, cwd, started_at: new Date().toISOString() };
40
+ for (let attempt = 0; attempt < 2; attempt += 1) {
41
+ try {
42
+ fs.writeFileSync(file, `${JSON.stringify(mine)}\n`, { flag: 'wx' });
43
+ const release = () => {
44
+ const holder = readHolder(file);
45
+ if (holder && holder.pid === pid && holder.host === host) fs.rmSync(file, { force: true });
46
+ };
47
+ return { ok: true, release };
48
+ } catch (error) {
49
+ if (error.code !== 'EEXIST') throw error;
50
+ }
51
+ const holder = readHolder(file);
52
+ if (holder && holder.host === host && isAlive(holder.pid)) return { ok: false, holder };
53
+ if (holder && holder.host !== host) return { ok: false, holder };
54
+ fs.rmSync(file, { force: true });
55
+ }
56
+ return { ok: false, holder: readHolder(file) };
57
+ }
58
+
59
+ module.exports = { acquireRunLock, lockPath };