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
@@ -1239,17 +1239,32 @@ function buildDigestPrompt(briefs = []) {
1239
1239
 
1240
1240
  function defaultDigestRunner(prompt, deps = {}) {
1241
1241
  const spawn = deps.spawnSync || spawnSync;
1242
- return spawn('claude', ['-p', prompt, '--model', 'claude-haiku-5-5'], {
1242
+ // Machine memory and load at both ends of the launch, so one killed with
1243
+ // SIGKILL says whether the machine was short on memory.
1244
+ const endResource = require('../lib/engine-resource').startEngineRun(deps.cwd || process.cwd(), { engine: 'claude', task: 'youtube digest' });
1245
+ const result = spawn('claude', ['-p', prompt, '--model', 'claude-haiku-5-5'], {
1243
1246
  encoding: 'utf8',
1244
1247
  timeout: DIGEST_ENGINE_TIMEOUT_MS,
1245
1248
  maxBuffer: 10 * 1024 * 1024,
1246
1249
  });
1250
+ // spawnSync does not throw, so a failure is passed in the thrown shape.
1251
+ const failed = !result || result.error || result.signal || result.status !== 0;
1252
+ const end = endResource(failed ? {
1253
+ code: result && result.error && result.error.code,
1254
+ signal: result && result.signal,
1255
+ stderr: result && result.stderr,
1256
+ message: result && result.error ? result.error.message : `claude exited ${result && result.status}`,
1257
+ } : null);
1258
+ return end && end.cause && result && !(result.error && result.error.code === 'ETIMEDOUT') ? { ...result, cause: end.cause } : result;
1247
1259
  }
1248
1260
 
1249
1261
  function invokeDigestEngine(prompt, deps = {}) {
1250
1262
  const runner = deps.runner || ((nextPrompt) => defaultDigestRunner(nextPrompt, deps));
1251
1263
  const result = runner(prompt, deps);
1252
1264
  if (typeof result === 'string') return result.trim();
1265
+ if (result && result.signal && !result.error) {
1266
+ throw new Error(`digest engine stopped by ${result.signal}${result.cause ? `; ${result.cause}` : ''}`);
1267
+ }
1253
1268
  if (!result || result.error || (result.status != null && result.status !== 0)) {
1254
1269
  const detail = String(result?.stderr || result?.error?.message || 'digest engine failed').trim();
1255
1270
  throw new Error(detail || 'digest engine failed');
@@ -1401,7 +1416,7 @@ function runYoutubeDigest(args = [], deps = {}) {
1401
1416
  const prompt = buildDigestPrompt(briefs);
1402
1417
  let text;
1403
1418
  try {
1404
- text = invokeDigestEngine(prompt, deps);
1419
+ text = invokeDigestEngine(prompt, { ...deps, cwd });
1405
1420
  } catch (err) {
1406
1421
  output(err.message || 'digest engine failed');
1407
1422
  return 1;
@@ -123,4 +123,5 @@ function evaluateAcceptVerify(task, workspaceRoot, { cache = null } = {}) {
123
123
  module.exports = {
124
124
  storedVerifyCommand,
125
125
  evaluateAcceptVerify,
126
+ nonFalsifyingIssue,
126
127
  };
@@ -20,6 +20,14 @@ function isProvisionedAgentWorkspace(cwd = process.cwd()) {
20
20
  return fs.existsSync(path.join(root, '.atris', 'agent-token.json'));
21
21
  }
22
22
 
23
+ // A Desktop project folder carries an atris/ directory but no business.json.
24
+ // It is a real workspace, not a scratch folder, so account reads may run
25
+ // there. Writes (send, dm, archive, connect) still need the business binding.
26
+ function isProjectWorkspace(cwd = process.cwd()) {
27
+ const root = path.resolve(cwd);
28
+ return fs.existsSync(path.join(root, 'atris'));
29
+ }
30
+
23
31
  function isHelpArg(arg) {
24
32
  return arg === '--help' || arg === '-h' || arg === 'help';
25
33
  }
@@ -33,6 +41,8 @@ function looksLikeBusinessSlug(token) {
33
41
  /**
34
42
  * Account-scoped verbs dump live CRM/cloud state. Outside a bound business
35
43
  * workspace they must opt in with --account. Help always passes.
44
+ * Pass { read: true } for read-only verbs: a project folder with atris/ (no
45
+ * business.json) then counts as bound. Writes never get that relaxation.
36
46
  * Returns { ok, args } where args has --account stripped when continuing.
37
47
  */
38
48
  function requireAccountBound(args = [], options = {}) {
@@ -44,6 +54,9 @@ function requireAccountBound(args = [], options = {}) {
44
54
  if (isBoundBusinessWorkspace(cwd) || isProvisionedAgentWorkspace(cwd)) {
45
55
  return { ok: true, args: list.filter((arg) => arg !== '--account'), bound: true };
46
56
  }
57
+ if (options.read === true && isProjectWorkspace(cwd)) {
58
+ return { ok: true, args: list.filter((arg) => arg !== '--account'), bound: true, project: true };
59
+ }
47
60
  if (list.includes('--account')) {
48
61
  return { ok: true, args: list.filter((arg) => arg !== '--account'), account: true };
49
62
  }
@@ -1398,6 +1398,7 @@ module.exports = {
1398
1398
  DENIED_TAGS,
1399
1399
  deniedTagOf,
1400
1400
  candidatePolicyGate,
1401
+ declaredCandidatePathValues,
1401
1402
  declaredProtectedLane,
1402
1403
  sniffedProtectedLane,
1403
1404
  protectedLaneOf,
package/lib/autoland.js CHANGED
@@ -267,6 +267,8 @@ function acceptedInLastDay(tasks, now = Date.now()) {
267
267
  result: String(t.result || t.metadata?.result || '').slice(0, 220),
268
268
  happened: String(t.review?.landing?.happened || t.metadata?.landing_happened || '').slice(0, 300),
269
269
  member: String(t.claimed_by || t.metadata?.executed_by || t.assigned_to || '').trim(),
270
+ // Accepted is a review verdict; only a passed landing proof is landed.
271
+ landed: t.metadata?.landing?.status === 'landed',
270
272
  };
271
273
  if (t.metadata?.auto_accepted_at) auto.push(entry);
272
274
  else human.push(entry);
@@ -481,6 +483,9 @@ function unexplainedSummaryRows(items, label) {
481
483
  if (label === 'landed') {
482
484
  return [`${plur(count, 'landed result')} stayed hidden because ${count === 1 ? 'its outcome was' : 'their outcomes were'} unclear`];
483
485
  }
486
+ if (label === 'accepted') {
487
+ return [`${plur(count, 'accepted result')} stayed hidden because ${count === 1 ? 'its outcome was' : 'their outcomes were'} unclear`];
488
+ }
484
489
  if (label === 'in review') {
485
490
  return [`${plur(count, 'review')} stayed hidden because ${count === 1 ? 'its next action was' : 'their next actions were'} unclear`];
486
491
  }
@@ -547,6 +552,10 @@ function composeDigest({ accepted, waiting, protectedWaiting = [], waitingWishes
547
552
  lines.push(`atris ${project}: last 24 hours`);
548
553
  const autoCount = accepted.auto.length;
549
554
  const humanCount = accepted.human.length;
555
+ const landedCount = accepted.auto.filter((item) => item.landed === true).length;
556
+ // "N landed" only when every one of them proved it; otherwise say both.
557
+ const finished = landedCount === autoCount ? `${autoCount} landed` : `${autoCount} accepted, ${landedCount} landed`;
558
+ const finishedWord = landedCount === autoCount ? 'landed' : 'accepted';
550
559
  const wishWaits = (Array.isArray(waitingWishes) ? waitingWishes : [])
551
560
  .filter((wish) => wish && String(wish.text || '').trim());
552
561
  if (autoCount > 0) {
@@ -564,8 +573,8 @@ function composeDigest({ accepted, waiting, protectedWaiting = [], waitingWishes
564
573
  // The header states the actual bar: under accept-all the claim
565
574
  // "verified twice" would be a lie the operator builds trust on.
566
575
  lines.push(acceptAll
567
- ? `${autoCount} landed; ${plur(ready.length, 'clear result')}, protected lanes held back for you:`
568
- : `${autoCount} landed; ${plur(ready.length, 'clear result')}, each verified twice with proof on file:`);
576
+ ? `${finished}; ${plur(ready.length, 'clear result')}, protected lanes held back for you:`
577
+ : `${finished}; ${plur(ready.length, 'clear result')}, each verified twice with proof on file:`);
569
578
  for (const { item, line } of visibleWork) {
570
579
  lines.push('');
571
580
  lines.push(`- ${line || item.ref}${memberTrustLabel(item.member, root)}`);
@@ -577,9 +586,9 @@ function composeDigest({ accepted, waiting, protectedWaiting = [], waitingWishes
577
586
  : `${plur(held, 'more result')} when you want them: atris autoland digest`);
578
587
  }
579
588
  } else {
580
- lines.push(`${autoCount} landed with proof on file; no clear result was ready to show:`);
589
+ lines.push(`${finished} with proof on file; no clear result was ready to show:`);
581
590
  }
582
- for (const row of unexplainedSummaryRows(unexplainedWork, 'landed')) lines.push(row);
591
+ for (const row of unexplainedSummaryRows(unexplainedWork, finishedWord)) lines.push(row);
583
592
  }
584
593
  if (autoCount + humanCount === 0) lines.push('nothing finished in the last day');
585
594
  if (waiting.length > 0) {
@@ -0,0 +1,131 @@
1
+ // Work and review in rounds (Astra's rule: let agents work 50 minutes, then
2
+ // take 10). In the work window the campaign loop runs: it plans, runs missions
3
+ // at once, and judges. In the break the judge runs if due, reserves are
4
+ // reconciled, and the owner writes a short review of the round into the
5
+ // campaign's lessons file, which the next round's planning reads. Each round
6
+ // leaves the campaign a little smarter than the last.
7
+
8
+ 'use strict';
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const { getCampaign, openCampaignStore } = require('./campaign-store');
13
+
14
+ const LESSON_LINES_FOR_PLANNING = 30;
15
+
16
+ function parseDuration(text, fallbackMs) {
17
+ if (text === undefined || text === null || text === '') return fallbackMs;
18
+ const match = /^(\d+(?:\.\d+)?)\s*(s|m|h)?$/i.exec(String(text).trim());
19
+ if (!match) throw new Error(`durations look like 50m, 10m, 90s, or 1h (got ${text})`);
20
+ const unit = (match[2] || 'm').toLowerCase();
21
+ return Math.round(Number(match[1]) * { s: 1000, m: 60000, h: 3600000 }[unit]);
22
+ }
23
+
24
+ function lessonsPath(root, campaignId) {
25
+ return path.join(root, 'atris', 'campaigns', campaignId, 'lessons.md');
26
+ }
27
+
28
+ // The newest lessons, for the planner.
29
+ function readLessons(root, campaignId, lines = LESSON_LINES_FOR_PLANNING) {
30
+ try {
31
+ const text = fs.readFileSync(lessonsPath(root, campaignId), 'utf8');
32
+ return text.split('\n').filter((line) => line.trim().startsWith('- ')).slice(-lines).join('\n');
33
+ } catch {
34
+ return '';
35
+ }
36
+ }
37
+
38
+ function roundSummary(before, after) {
39
+ const fresh = after.missions.filter((m) => !before.missions.some((b) => b.id === m.id && b.status === m.status));
40
+ const kept = fresh.filter((m) => m.status === 'ended' && m.outcome === 'kept').length;
41
+ const failed = fresh.filter((m) => m.status === 'ended' && m.outcome !== 'kept').length;
42
+ const vetoed = fresh.filter((m) => m.status === 'vetoed').length;
43
+ const spent = Math.round((after.budget.spent - before.budget.spent) * 100) / 100;
44
+ return { kept, failed, vetoed, spent, fresh };
45
+ }
46
+
47
+ function reviewPrompt(view, summary, lessons) {
48
+ const missions = summary.fresh.map((m) => `- ${m.id}: ${m.status}${m.outcome ? ` (${m.outcome})` : ''}${m.cost_usd !== null ? `, $${m.cost_usd}` : ''}`).join('\n') || '- none ended this round';
49
+ return [
50
+ 'You review one round of an Atris campaign. Reply with 2 to 6 short lessons, one per line, each starting with "- ".',
51
+ 'A lesson says what to do differently next round, in plain words. Do not repeat a lesson already listed.',
52
+ '',
53
+ `Campaign: ${view.title}`,
54
+ `Judge: ${view.judge}`,
55
+ `Judge now: ${view.last_judge ? `${view.last_judge.passed ? 'passed' : 'failed'}${view.last_judge.reading !== null ? ` (reading ${view.last_judge.reading})` : ''}: ${view.answer.text || ''}` : 'never run'}`,
56
+ `This round: ${summary.kept} kept, ${summary.failed} failed, ${summary.vetoed} could not start, $${summary.spent} spent`,
57
+ 'Missions this round:',
58
+ missions,
59
+ '',
60
+ 'Lessons so far:',
61
+ lessons || '- none yet',
62
+ ].join('\n');
63
+ }
64
+
65
+ function makeCadence(root, loop, options = {}, deps = {}) {
66
+ const store = deps.store || openCampaignStore(root);
67
+ const log = deps.log || ((line) => console.log(line));
68
+ const now = deps.now || (() => Date.now());
69
+ const sleep = deps.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
70
+ const ask = deps.ask;
71
+ const workMs = parseDuration(options.work, 50 * 60000);
72
+ const breakMs = parseDuration(options.break, 10 * 60000);
73
+ const rounds = Number(options.rounds) > 0 ? Number(options.rounds) : Infinity;
74
+
75
+ async function review(id, before) {
76
+ const after = getCampaign(store, id);
77
+ const summary = roundSummary(before, after);
78
+ const lessons = readLessons(root, id, 200);
79
+ let written = [];
80
+ if (ask) {
81
+ const answer = await ask(root, after, reviewPrompt(after, summary, lessons));
82
+ // A lesson already on file is not written twice.
83
+ const known = new Set(lessons.split('\n').map((line) => line.trim().toLowerCase()));
84
+ written = String((answer && answer.text) || '').split('\n').map((line) => line.trim())
85
+ .filter((line) => line.startsWith('- ') && !known.has(line.toLowerCase())).slice(0, 6);
86
+ }
87
+ const file = lessonsPath(root, id);
88
+ fs.mkdirSync(path.dirname(file), { recursive: true });
89
+ if (!fs.existsSync(file)) fs.writeFileSync(file, `# Lessons: ${after.title}\n\nWritten by the owner at each review; the next round plans from them.\n`);
90
+ const stamp = new Date(now()).toISOString().slice(0, 16).replace('T', ' ');
91
+ const header = `\n## ${stamp}: ${summary.kept} kept, ${summary.failed} failed, $${summary.spent} spent\n`;
92
+ fs.appendFileSync(file, `${header}${(written.length ? written : ['- (no review written this round)']).join('\n')}\n`);
93
+ return { summary, lessons: written };
94
+ }
95
+
96
+ async function run(id) {
97
+ let round = 0;
98
+ let last = { reason: 'not started' };
99
+ while (round < rounds) {
100
+ round += 1;
101
+ const before = getCampaign(store, id);
102
+ const until = now() + workMs;
103
+ log(`round ${round}: working until ${new Date(until).toISOString().slice(11, 16)}`);
104
+ let stopped = null;
105
+ while (now() < until) {
106
+ const result = await loop.step(id);
107
+ if (result.stop) {
108
+ stopped = result.reason;
109
+ break;
110
+ }
111
+ }
112
+ const breakStarted = now();
113
+ log(`round ${round}: review`);
114
+ const { summary, lessons } = await review(id, before);
115
+ log(` ${summary.kept} kept, ${summary.failed} failed, $${summary.spent} spent; ${lessons.length} lesson${lessons.length === 1 ? '' : 's'} written`);
116
+ const view = getCampaign(store, id);
117
+ last = { reason: stopped || 'work window ended', round };
118
+ // Finished, out of money, or waiting on a person: no point in another round.
119
+ if (['done', 'out_of_budget', 'needs_you', 'paused'].includes(view.state) || (stopped && !/missions running|nothing usable/.test(stopped))) {
120
+ return { rounds: round, reason: stopped || view.state, campaign: view };
121
+ }
122
+ const rest = breakMs - (now() - breakStarted);
123
+ if (round < rounds && rest > 0) await sleep(rest);
124
+ }
125
+ return { rounds: round, reason: last.reason, campaign: getCampaign(store, id) };
126
+ }
127
+
128
+ return { run, review };
129
+ }
130
+
131
+ module.exports = { lessonsPath, makeCadence, parseDuration, readLessons, reviewPrompt };
@@ -0,0 +1,330 @@
1
+ // Campaign core: fold(events) -> view, decide(view) -> next move.
2
+ // Pure: no I/O, no clock, no network. The rules are the cross-surface
3
+ // contract in test/fixtures/campaign/README.md; every surface's fold must
4
+ // match the golden files there.
5
+
6
+ 'use strict';
7
+
8
+ const { TIMESTAMP, validateEvent, isHumanActor } = require('./campaign-schema');
9
+
10
+ function cents(amount) {
11
+ return Math.floor(amount * 100 + 0.5);
12
+ }
13
+
14
+ function dollars(value) {
15
+ return value / 100;
16
+ }
17
+
18
+ // An instant at full precision: whole seconds since the epoch plus nine
19
+ // fraction digits, so two events a microsecond apart never tie.
20
+ function instant(at) {
21
+ const [, date, time, fraction = '', zone] = TIMESTAMP.exec(at);
22
+ return { seconds: Date.parse(`${date}T${time}${zone}`) / 1000, nanos: fraction.padEnd(9, '0') };
23
+ }
24
+
25
+ function compareEvents(a, b) {
26
+ const x = instant(a.at);
27
+ const y = instant(b.at);
28
+ if (x.seconds !== y.seconds) return x.seconds - y.seconds;
29
+ if (x.nanos !== y.nanos) return x.nanos < y.nanos ? -1 : 1;
30
+ if (a.origin !== b.origin) return a.origin < b.origin ? -1 : 1;
31
+ if (a.seq !== b.seq) return a.seq - b.seq;
32
+ if (a.id !== b.id) return a.id < b.id ? -1 : 1;
33
+ return 0;
34
+ }
35
+
36
+ function seatFrom(seat) {
37
+ if (typeof seat === 'string') return { member: seat, role: null };
38
+ return { member: seat.member, role: seat.role === undefined ? null : seat.role };
39
+ }
40
+
41
+ function addSeat(seats, seat) {
42
+ if (seats.some((s) => s.member === seat.member && s.role === seat.role)) return;
43
+ seats.push(seat);
44
+ }
45
+
46
+ // Statuses only move forward; dropped and vetoed are final.
47
+ const STATUS_RANK = { proposed: 0, claimed: 1, running: 2, ended: 3 };
48
+
49
+ function moveTo(m, status) {
50
+ const from = STATUS_RANK[m.status];
51
+ if (from === undefined || from >= STATUS_RANK[status]) return;
52
+ m.status = status;
53
+ }
54
+
55
+ function newMission(id) {
56
+ return {
57
+ id, member: null, status: 'proposed', outcome: null, costCents: null,
58
+ estimate_usd: null, steps: 0, kept: 0, reverted: 0,
59
+ };
60
+ }
61
+
62
+ // Returns the folded view, or { error } when the events must be refused.
63
+ function fold(events) {
64
+ if (!Array.isArray(events)) return { error: 'invalid_event' };
65
+ for (const event of events) {
66
+ if (!validateEvent(event).ok) return { error: 'invalid_event' };
67
+ }
68
+ const campaignIds = new Set(events.map((e) => e.campaign_id));
69
+ if (campaignIds.size > 1) return { error: 'mixed_campaigns' };
70
+
71
+ const seen = new Set();
72
+ const unique = [];
73
+ for (const event of events) {
74
+ if (seen.has(event.id)) continue;
75
+ seen.add(event.id);
76
+ unique.push(event);
77
+ }
78
+ const ordered = unique.slice().sort(compareEvents);
79
+ const start = ordered.find((e) => e.type === 'campaign_started');
80
+ if (!start) return { error: 'no_start' };
81
+
82
+ const s = {
83
+ finished: null,
84
+ paused: false,
85
+ seats: [],
86
+ bet: null,
87
+ parent: null,
88
+ home: start.origin,
89
+ missions: new Map(),
90
+ claims: new Map(),
91
+ reservations: new Map(),
92
+ totalCents: cents(start.data.budget),
93
+ spentCents: 0,
94
+ asks: new Map(),
95
+ judgeRuns: [],
96
+ lastEndedPos: -1,
97
+ judgeSetPos: -1,
98
+ judge: start.data.judge,
99
+ lastJudgePos: -1,
100
+ warnings: [],
101
+ };
102
+ const d = start.data;
103
+ const owner = typeof d.owner === 'string' && d.owner ? d.owner : 'mission-lead';
104
+ for (const seat of d.seats || []) addSeat(s.seats, seatFrom(seat));
105
+ if (d.bet) s.bet = { text: d.bet, wrong_if: null };
106
+
107
+ const warn = (event, code) => s.warnings.push({ event_id: event.id, code });
108
+ const mission = (id) => {
109
+ if (!s.missions.has(id)) s.missions.set(id, newMission(id));
110
+ return s.missions.get(id);
111
+ };
112
+
113
+ const sequence = [start, ...ordered.filter((e) => e !== start)];
114
+ sequence.forEach((event, pos) => {
115
+ const data = event.data;
116
+ switch (event.type) {
117
+ case 'campaign_started':
118
+ if (event !== start) warn(event, 'duplicate_start');
119
+ break;
120
+ case 'seat_filled':
121
+ addSeat(s.seats, { member: data.member, role: data.role });
122
+ break;
123
+ case 'bet_set':
124
+ s.bet = { text: data.bet, wrong_if: data.wrong_if || null };
125
+ break;
126
+ case 'judge_set':
127
+ if (event.actor !== owner && !isHumanActor(event.actor)) {
128
+ warn(event, 'judge_set_not_owner');
129
+ } else {
130
+ s.judge = data.judge;
131
+ s.judgeSetPos = pos;
132
+ }
133
+ break;
134
+ case 'attached':
135
+ s.parent = data.parent_id;
136
+ break;
137
+ case 'mission_proposed':
138
+ if (!s.missions.has(data.mission_id)) {
139
+ const m = mission(data.mission_id);
140
+ m.member = data.member || null;
141
+ m.estimate_usd = typeof data.estimate_usd === 'number' ? dollars(cents(data.estimate_usd)) : null;
142
+ }
143
+ break;
144
+ case 'mission_claimed': {
145
+ const holder = s.claims.get(data.claim_key);
146
+ const m = mission(data.mission_id);
147
+ if (holder !== undefined && holder !== data.mission_id) {
148
+ warn(event, 'duplicate_claim');
149
+ if (m.status === 'proposed') m.status = 'dropped';
150
+ } else {
151
+ s.claims.set(data.claim_key, data.mission_id);
152
+ moveTo(m, 'claimed');
153
+ }
154
+ break;
155
+ }
156
+ case 'mission_vetoed': {
157
+ if (event.actor !== owner && !isHumanActor(event.actor)) {
158
+ warn(event, 'veto_not_owner');
159
+ break;
160
+ }
161
+ const m = mission(data.mission_id);
162
+ if (m.status === 'proposed' || m.status === 'claimed') m.status = 'vetoed';
163
+ break;
164
+ }
165
+ case 'mission_started': {
166
+ const m = mission(data.mission_id);
167
+ moveTo(m, 'running');
168
+ if (m.member === null) m.member = data.member;
169
+ else if (m.member !== data.member) warn(event, 'second_assignee');
170
+ break;
171
+ }
172
+ case 'step':
173
+ case 'kept':
174
+ case 'reverted': {
175
+ const m = mission(data.mission_id);
176
+ const key = event.type === 'step' ? 'steps' : event.type;
177
+ m[key] += 1;
178
+ break;
179
+ }
180
+ case 'mission_ended': {
181
+ const m = mission(data.mission_id);
182
+ m.status = 'ended';
183
+ m.outcome = data.outcome;
184
+ m.costCents = cents(data.cost_usd);
185
+ s.spentCents += m.costCents;
186
+ s.reservations.delete(data.mission_id);
187
+ s.lastEndedPos = pos;
188
+ break;
189
+ }
190
+ case 'budget_reserved': {
191
+ const m = s.missions.get(data.mission_id);
192
+ if (!m || m.status !== 'ended') s.reservations.set(data.mission_id, cents(data.amount_usd));
193
+ break;
194
+ }
195
+ case 'budget_released':
196
+ s.reservations.delete(data.mission_id);
197
+ break;
198
+ case 'budget_added':
199
+ s.totalCents += cents(data.amount_usd);
200
+ break;
201
+ case 'judge_ran':
202
+ s.judgeRuns.push({
203
+ passed: data.passed,
204
+ reading: typeof data.reading === 'number' ? data.reading : null,
205
+ summary: typeof data.summary === 'string' ? data.summary : null,
206
+ confidence: data.confidence || null,
207
+ at: event.at,
208
+ });
209
+ s.lastJudgePos = pos;
210
+ if (data.passed && !d.standing && !s.finished) s.finished = 'judge_passed';
211
+ break;
212
+ case 'ask_opened':
213
+ if (!s.asks.has(data.ask_id)) s.asks.set(data.ask_id, { id: data.ask_id, question: data.question, mission_id: data.mission_id || null });
214
+ break;
215
+ case 'ask_answered':
216
+ if (!isHumanActor(event.actor)) warn(event, 'ask_answered_by_agent');
217
+ else if (!s.asks.has(data.ask_id)) warn(event, 'unknown_ask');
218
+ else s.asks.delete(data.ask_id);
219
+ break;
220
+ case 'paused':
221
+ s.paused = true;
222
+ break;
223
+ case 'resumed':
224
+ s.paused = false;
225
+ break;
226
+ case 'stopped':
227
+ if (!s.finished) s.finished = 'stopped';
228
+ break;
229
+ case 'home_moved':
230
+ s.home = data.to;
231
+ break;
232
+ case 'done':
233
+ if (!isHumanActor(event.actor)) warn(event, 'done_without_judge');
234
+ else if (!s.finished) s.finished = 'done_by_human';
235
+ break;
236
+ default:
237
+ break;
238
+ }
239
+ });
240
+
241
+ const missions = [...s.missions.values()];
242
+ let state;
243
+ let stopReason = null;
244
+ if (s.finished) {
245
+ state = 'done';
246
+ stopReason = s.finished;
247
+ } else if (s.spentCents >= s.totalCents) {
248
+ state = 'out_of_budget';
249
+ stopReason = 'budget_spent';
250
+ } else if (s.paused) {
251
+ state = 'paused';
252
+ } else if (s.asks.size > 0) {
253
+ state = 'needs_you';
254
+ } else if (missions.some((m) => m.status === 'running')) {
255
+ state = 'running';
256
+ } else if (missions.some((m) => m.status === 'proposed' || m.status === 'claimed')) {
257
+ state = 'queued';
258
+ } else {
259
+ state = 'resting';
260
+ }
261
+
262
+ const runs = s.judgeRuns;
263
+ const latest = runs.length ? runs[runs.length - 1] : null;
264
+ let confidence = 'open';
265
+ if (latest && s.finished === 'judge_passed') {
266
+ confidence = 'answered';
267
+ } else if (latest && latest.passed) {
268
+ let streak = 0;
269
+ for (let i = runs.length - 1; i >= 0 && runs[i].passed; i--) streak++;
270
+ confidence = streak >= 3 ? 'stronger' : 'leaning';
271
+ } else if (latest) {
272
+ confidence = latest.confidence || 'open';
273
+ }
274
+
275
+ const reservedCents = [...s.reservations.values()].reduce((sum, value) => sum + value, 0);
276
+ return {
277
+ id: start.campaign_id,
278
+ title: d.title,
279
+ judge: s.judge,
280
+ owner,
281
+ standing: d.standing === true,
282
+ bet: s.bet,
283
+ parent: s.parent,
284
+ home: s.home,
285
+ created_from: d.created_from === undefined ? null : d.created_from,
286
+ seats: s.seats,
287
+ state,
288
+ stop_reason: stopReason,
289
+ answer: { text: latest ? latest.summary : null, confidence },
290
+ budget: {
291
+ total: dollars(s.totalCents),
292
+ spent: dollars(s.spentCents),
293
+ reserved: dollars(reservedCents),
294
+ available: dollars(s.totalCents - s.spentCents - reservedCents),
295
+ },
296
+ asks: [...s.asks.values()],
297
+ missions: missions.map((m) => ({
298
+ id: m.id,
299
+ member: m.member,
300
+ status: m.status,
301
+ outcome: m.outcome,
302
+ cost_usd: m.costCents === null ? null : dollars(m.costCents),
303
+ estimate_usd: m.estimate_usd,
304
+ steps: m.steps,
305
+ kept: m.kept,
306
+ reverted: m.reverted,
307
+ })),
308
+ judge_runs: runs.length,
309
+ judge_due: runs.length === 0 || Math.max(s.lastEndedPos, s.judgeSetPos) > s.lastJudgePos,
310
+ last_judge: latest ? { passed: latest.passed, reading: latest.reading, at: latest.at } : null,
311
+ warnings: s.warnings,
312
+ };
313
+ }
314
+
315
+ function decide(view) {
316
+ if (view.state === 'done') return { action: 'none', reason: 'finished' };
317
+ if (view.state === 'out_of_budget') return { action: 'none', reason: 'budget spent' };
318
+ if (view.state === 'paused') return { action: 'none', reason: 'paused' };
319
+ if (view.asks.length > 0) return { action: 'wait', reason: 'open asks', asks: view.asks.map((a) => a.id) };
320
+ if (view.judge_due) return { action: 'run_judge' };
321
+ const claimed = view.missions.filter((m) => m.status === 'claimed');
322
+ const available = cents(view.budget.available);
323
+ const fits = claimed.find((m) => cents(m.estimate_usd || 0) <= available);
324
+ if (fits) return { action: 'start_mission', mission_id: fits.id };
325
+ if (claimed.length > 0) return { action: 'wait', reason: 'budget' };
326
+ if (view.missions.some((m) => m.status === 'running')) return { action: 'wait', reason: 'missions running' };
327
+ return { action: 'propose', reason: 'no missions queued' };
328
+ }
329
+
330
+ module.exports = { cents, compareEvents, decide, fold };
@@ -0,0 +1,65 @@
1
+ // Held-out judge data: `atris campaign start --holdout <file>` moves the file
2
+ // out of the repo into the campaign's own folder beside the campaign store, so
3
+ // agents working in the checkout never read it. The judge finds it through
4
+ // ATRIS_CAMPAIGN_HOLDOUT, and any of its lines the judge echoes are scrubbed
5
+ // before the reading is stored, so show and export cannot print them.
6
+
7
+ 'use strict';
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ const MASK = '[held out]';
13
+
14
+ function holdoutDir(root, campaignId) {
15
+ return path.join(root, 'campaigns', campaignId, 'holdout');
16
+ }
17
+
18
+ // The held-out file for a campaign, or null when it has none.
19
+ function findHoldout(root, campaignId) {
20
+ const dir = holdoutDir(root, campaignId);
21
+ let names;
22
+ try { names = fs.readdirSync(dir); } catch { return null; }
23
+ const name = names.sort().find((n) => fs.statSync(path.join(dir, n)).isFile());
24
+ return name ? path.join(dir, name) : null;
25
+ }
26
+
27
+ function moveFile(from, to) {
28
+ try {
29
+ fs.renameSync(from, to);
30
+ } catch (error) {
31
+ if (error.code !== 'EXDEV') throw error;
32
+ fs.copyFileSync(from, to);
33
+ fs.unlinkSync(from);
34
+ }
35
+ }
36
+
37
+ // Moves the file in and returns its new path, or { error } in plain words.
38
+ function moveHoldoutIn(root, campaignId, source) {
39
+ const from = path.resolve(source);
40
+ if (!fs.existsSync(from) || !fs.statSync(from).isFile()) return { error: `--holdout ${source} is not a file` };
41
+ const dir = holdoutDir(root, campaignId);
42
+ fs.mkdirSync(dir, { recursive: true });
43
+ const to = path.join(dir, path.basename(from));
44
+ moveFile(from, to);
45
+ return { path: to, from };
46
+ }
47
+
48
+ // Puts the file back where it came from, for a start that was refused.
49
+ function moveHoldoutBack(moved) {
50
+ if (moved && moved.path && fs.existsSync(moved.path) && !fs.existsSync(moved.from)) moveFile(moved.path, moved.from);
51
+ }
52
+
53
+ // Replaces every held-out line that shows up in text.
54
+ function scrub(text, holdoutPath) {
55
+ if (typeof text !== 'string' || !text || !holdoutPath) return text;
56
+ let content;
57
+ try { content = fs.readFileSync(holdoutPath, 'utf8'); } catch { return text; }
58
+ const lines = [...new Set(content.split('\n').map((line) => line.trim()).filter((line) => line.length >= 4))];
59
+ lines.sort((a, b) => b.length - a.length);
60
+ let out = text;
61
+ for (const line of lines) out = out.split(line).join(MASK);
62
+ return out;
63
+ }
64
+
65
+ module.exports = { findHoldout, holdoutDir, moveHoldoutBack, moveHoldoutIn, scrub };