atris 3.71.2 → 3.72.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 (52) hide show
  1. package/atris/skills/campaigns/SKILL.md +1 -1
  2. package/bin/atris.js +8 -2
  3. package/commands/autoland.js +102 -2
  4. package/commands/campaign.js +263 -11
  5. package/commands/clean.js +60 -4
  6. package/commands/gm.js +1273 -7
  7. package/commands/improve.js +3 -2
  8. package/commands/member.js +28 -3
  9. package/commands/mission.js +82 -27
  10. package/commands/pulse.js +89 -5
  11. package/commands/sync.js +2 -2
  12. package/commands/task.js +49 -13
  13. package/commands/worktree.js +24 -2
  14. package/commands/zen.js +183 -0
  15. package/lib/auto-accept-certified.js +9 -4
  16. package/lib/campaign-cadence.js +53 -14
  17. package/lib/campaign-core.js +98 -17
  18. package/lib/campaign-keep.js +91 -0
  19. package/lib/campaign-lanes.js +11 -6
  20. package/lib/campaign-loop.js +173 -32
  21. package/lib/campaign-reading.js +14 -1
  22. package/lib/campaign-rsi.js +697 -0
  23. package/lib/campaign-run-lock.js +9 -1
  24. package/lib/campaign-schedule.js +124 -0
  25. package/lib/campaign-schema.js +3 -1
  26. package/lib/campaign-store.js +34 -2
  27. package/lib/campaign-trace.js +77 -29
  28. package/lib/engine-resource.js +5 -1
  29. package/lib/gm-bet.js +2219 -0
  30. package/lib/gm-phone-page.js +1102 -0
  31. package/lib/gm-phone.js +682 -0
  32. package/lib/gm-season.js +1162 -0
  33. package/lib/gm-target.js +292 -0
  34. package/lib/job-code-verify.js +169 -0
  35. package/lib/known-commands.js +1 -1
  36. package/lib/member-autoland.js +105 -14
  37. package/lib/member-progress.js +2 -2
  38. package/lib/member-report.js +11 -1
  39. package/lib/member-self-next-job.js +6 -1
  40. package/lib/member-up.js +17 -8
  41. package/lib/merged-review-close.js +206 -0
  42. package/lib/mission-code-provenance.js +118 -0
  43. package/lib/mission-protected-lane.js +52 -4
  44. package/lib/pulse.js +2 -1
  45. package/lib/qr.js +338 -0
  46. package/lib/rsi-record.js +88 -0
  47. package/lib/task-receipt.js +24 -6
  48. package/lib/yes-lock.js +530 -0
  49. package/lib/zen-apply.js +511 -0
  50. package/lib/zen-workspace.js +121 -0
  51. package/lib/zen.js +395 -0
  52. package/package.json +1 -1
@@ -60,7 +60,47 @@ function newMission(id) {
60
60
  }
61
61
 
62
62
  // Returns the folded view, or { error } when the events must be refused.
63
+ // A judge reading can carry a saved wait: nothing to do until wake_at, then
64
+ // check the judge again. It rides as extra data on judge_ran so a reader that
65
+ // predates it still folds the campaign. Anything new (a mission, an answer, a
66
+ // new judge, more money, a resume) ends the wait.
67
+ const WAIT_ENDS = new Set(['mission_proposed', 'mission_claimed', 'mission_started', 'mission_ended', 'judge_set', 'ask_answered', 'resumed', 'budget_added']);
68
+
69
+ function savedWait(data) {
70
+ const w = data.wait;
71
+ if (data.passed || !w || !Number.isFinite(w.wake_at) || !(w.wait_ms > 0)) return null;
72
+ return { wake_at: w.wake_at, wait_ms: w.wait_ms };
73
+ }
74
+
63
75
  function fold(events) {
76
+ return foldEvents(events);
77
+ }
78
+
79
+ // Internal consumers can project selected canonical prefix counts in the same
80
+ // traversal. The synchronous projector is trusted and read-only; only its
81
+ // result is retained, so callers need not keep every full historical view.
82
+ function foldWithPrefixes(events, prefixLengths, project = (view) => view) {
83
+ return collectPrefixes(events, prefixLengths, (state, start, owner, count) =>
84
+ project(renderView(state, start, owner), count));
85
+ }
86
+
87
+ // Trace history needs counts, not detached copies of every earlier mission.
88
+ // Use the same transitions and rendering rules, retaining only summary fields.
89
+ function foldWithSummaryPrefixes(events, prefixLengths) {
90
+ return collectPrefixes(events, prefixLengths, (state, start, owner, count, missionCounts) =>
91
+ renderView(state, start, owner, missionCounts), true);
92
+ }
93
+
94
+ function collectPrefixes(events, prefixLengths, project, trackMissionCounts = false) {
95
+ const requested = new Set(prefixLengths.filter((n) => Number.isSafeInteger(n) && n >= 1));
96
+ const prefixes = new Map();
97
+ const view = foldEvents(events, (state, start, owner, count, missionCounts) => {
98
+ if (requested.has(count)) prefixes.set(count, project(state, start, owner, count, missionCounts));
99
+ }, trackMissionCounts);
100
+ return view.error ? view : { view, prefixes };
101
+ }
102
+
103
+ function foldEvents(events, visit, trackMissionCounts = false) {
64
104
  if (!Array.isArray(events)) return { error: 'invalid_event' };
65
105
  for (const event of events) {
66
106
  if (!validateEvent(event).ok) return { error: 'invalid_event' };
@@ -94,13 +134,18 @@ function fold(events) {
94
134
  asks: new Map(),
95
135
  judgeRuns: [],
96
136
  lastEndedPos: -1,
137
+ wait: null,
97
138
  judgeSetPos: -1,
98
139
  judge: start.data.judge,
99
140
  lastJudgePos: -1,
141
+ // Jobs that did real work and ended since the last reading, waiting on it.
142
+ awaitingReading: [],
100
143
  warnings: [],
101
144
  };
102
145
  const d = start.data;
103
146
  const owner = typeof d.owner === 'string' && d.owner ? d.owner : 'mission-lead';
147
+ const missionCounts = trackMissionCounts
148
+ ? { proposed: 0, claimed: 0, running: 0, ended: 0, vetoed: 0, dropped: 0 } : null;
104
149
  for (const seat of d.seats || []) addSeat(s.seats, seatFrom(seat));
105
150
  if (d.bet) s.bet = { text: d.bet, wrong_if: null };
106
151
 
@@ -113,6 +158,9 @@ function fold(events) {
113
158
  const sequence = [start, ...ordered.filter((e) => e !== start)];
114
159
  sequence.forEach((event, pos) => {
115
160
  const data = event.data;
161
+ const missionId = missionCounts ? data.mission_id : undefined;
162
+ const previousStatus = missionCounts ? s.missions.get(missionId)?.status : undefined;
163
+ if (WAIT_ENDS.has(event.type)) s.wait = null;
116
164
  switch (event.type) {
117
165
  case 'campaign_started':
118
166
  if (event !== start) warn(event, 'duplicate_start');
@@ -129,6 +177,7 @@ function fold(events) {
129
177
  } else {
130
178
  s.judge = data.judge;
131
179
  s.judgeSetPos = pos;
180
+ s.awaitingReading = [];
132
181
  }
133
182
  break;
134
183
  case 'attached':
@@ -181,6 +230,8 @@ function fold(events) {
181
230
  const m = mission(data.mission_id);
182
231
  m.status = 'ended';
183
232
  m.outcome = data.outcome;
233
+ if (data.outcome === 'fault' && typeof data.reason === 'string') m.reason = data.reason;
234
+ if (m.steps > 0 && data.outcome !== 'kept' && data.outcome !== 'fault') s.awaitingReading.push(m);
184
235
  m.costCents = cents(data.cost_usd);
185
236
  s.spentCents += m.costCents;
186
237
  s.reservations.delete(data.mission_id);
@@ -207,6 +258,11 @@ function fold(events) {
207
258
  at: event.at,
208
259
  });
209
260
  s.lastJudgePos = pos;
261
+ s.wait = savedWait(data);
262
+ // A job that worked but stopped short is credited when the very next
263
+ // reading passes: its work is what moved the check.
264
+ if (data.passed) for (const m of s.awaitingReading) m.credited = true;
265
+ s.awaitingReading = [];
210
266
  if (data.passed && !d.standing && !s.finished) s.finished = 'judge_passed';
211
267
  break;
212
268
  case 'ask_opened':
@@ -236,9 +292,22 @@ function fold(events) {
236
292
  default:
237
293
  break;
238
294
  }
295
+ if (missionCounts) {
296
+ const status = s.missions.get(missionId)?.status;
297
+ if (status !== previousStatus) {
298
+ if (previousStatus !== undefined) missionCounts[previousStatus]--;
299
+ if (status !== undefined) missionCounts[status]++;
300
+ }
301
+ }
302
+ if (visit) visit(s, start, owner, pos + 1, missionCounts);
239
303
  });
240
304
 
241
- const missions = [...s.missions.values()];
305
+ return renderView(s, start, owner);
306
+ }
307
+
308
+ function renderView(s, start, owner, missionCounts = null) {
309
+ const d = start.data;
310
+ const missions = missionCounts ? null : [...s.missions.values()];
242
311
  let state;
243
312
  let stopReason = null;
244
313
  if (s.finished) {
@@ -251,9 +320,9 @@ function fold(events) {
251
320
  state = 'paused';
252
321
  } else if (s.asks.size > 0) {
253
322
  state = 'needs_you';
254
- } else if (missions.some((m) => m.status === 'running')) {
323
+ } else if (missionCounts ? missionCounts.running > 0 : missions.some((m) => m.status === 'running')) {
255
324
  state = 'running';
256
- } else if (missions.some((m) => m.status === 'proposed' || m.status === 'claimed')) {
325
+ } else if (missionCounts ? missionCounts.proposed + missionCounts.claimed > 0 : missions.some((m) => m.status === 'proposed' || m.status === 'claimed')) {
257
326
  state = 'queued';
258
327
  } else {
259
328
  state = 'resting';
@@ -266,37 +335,43 @@ function fold(events) {
266
335
  confidence = 'answered';
267
336
  } else if (latest && latest.passed) {
268
337
  let streak = 0;
269
- for (let i = runs.length - 1; i >= 0 && runs[i].passed; i--) streak++;
338
+ for (let i = runs.length - 1; i >= 0 && streak < 3 && runs[i].passed; i--) streak++;
270
339
  confidence = streak >= 3 ? 'stronger' : 'leaning';
271
340
  } else if (latest) {
272
341
  confidence = latest.confidence || 'open';
273
342
  }
274
343
 
275
344
  const reservedCents = [...s.reservations.values()].reduce((sum, value) => sum + value, 0);
345
+ const budget = {
346
+ total: dollars(s.totalCents),
347
+ spent: dollars(s.spentCents),
348
+ reserved: dollars(reservedCents),
349
+ available: dollars(s.totalCents - s.spentCents - reservedCents),
350
+ };
351
+ if (missionCounts) {
352
+ return { state, confidence, judge_runs: runs.length, budget, mission_counts: { ...missionCounts }, open_asks: s.asks.size };
353
+ }
276
354
  return {
277
355
  id: start.campaign_id,
278
356
  title: d.title,
279
357
  judge: s.judge,
280
358
  owner,
281
359
  standing: d.standing === true,
282
- bet: s.bet,
360
+ bet: s.bet ? { ...s.bet } : null,
283
361
  parent: s.parent,
284
362
  home: s.home,
285
363
  // Only when the start event names one, so the shared golden views stay as
286
364
  // pinned; no basis means the person's own engines.
287
365
  ...(d.basis === 'atris_cloud' || d.basis === 'own_engines' ? { basis: d.basis } : {}),
288
366
  created_from: d.created_from === undefined ? null : d.created_from,
289
- seats: s.seats,
367
+ seats: s.seats.map((seat) => ({ ...seat })),
290
368
  state,
291
369
  stop_reason: stopReason,
292
370
  answer: { text: latest ? latest.summary : null, confidence },
293
- budget: {
294
- total: dollars(s.totalCents),
295
- spent: dollars(s.spentCents),
296
- reserved: dollars(reservedCents),
297
- available: dollars(s.totalCents - s.spentCents - reservedCents),
298
- },
299
- asks: [...s.asks.values()],
371
+ budget,
372
+ asks: [...s.asks.values()].map((ask) => ({ ...ask })),
373
+ // Only while a wait is saved, so the shared golden views stay as pinned.
374
+ ...(s.wait ? { next_move: { action: 'wait', reason: 'waiting on the world', ...s.wait } } : {}),
300
375
  missions: missions.map((m) => ({
301
376
  id: m.id,
302
377
  member: m.member,
@@ -307,11 +382,13 @@ function fold(events) {
307
382
  steps: m.steps,
308
383
  kept: m.kept,
309
384
  reverted: m.reverted,
385
+ ...(m.reason ? { reason: m.reason } : {}),
386
+ ...(m.credited ? { credited: true } : {}),
310
387
  })),
311
388
  judge_runs: runs.length,
312
389
  judge_due: runs.length === 0 || Math.max(s.lastEndedPos, s.judgeSetPos) > s.lastJudgePos,
313
390
  last_judge: latest ? { passed: latest.passed, reading: latest.reading, at: latest.at } : null,
314
- warnings: s.warnings,
391
+ warnings: s.warnings.map((warning) => ({ ...warning })),
315
392
  };
316
393
  }
317
394
 
@@ -319,9 +396,13 @@ function decide(view) {
319
396
  if (view.state === 'done') return { action: 'none', reason: 'finished' };
320
397
  if (view.state === 'out_of_budget') return { action: 'none', reason: 'budget spent' };
321
398
  if (view.state === 'paused') return { action: 'none', reason: 'paused' };
322
- if (view.asks.length > 0) return { action: 'wait', reason: 'open asks', asks: view.asks.map((a) => a.id) };
399
+ // An open ask holds only the mission it names. When every claimed mission is
400
+ // held, the campaign waits for the person; an ask that names no mission holds nothing.
401
+ const held = new Set(view.asks.map((a) => a.mission_id).filter(Boolean));
402
+ const queued = view.missions.filter((m) => m.status === 'claimed');
403
+ const claimed = queued.filter((m) => !held.has(m.id));
404
+ if (queued.length > 0 && claimed.length === 0) return { action: 'wait', reason: 'open asks', asks: view.asks.map((a) => a.id) };
323
405
  if (view.judge_due) return { action: 'run_judge' };
324
- const claimed = view.missions.filter((m) => m.status === 'claimed');
325
406
  const available = cents(view.budget.available);
326
407
  const fits = claimed.find((m) => cents(m.estimate_usd || 0) <= available);
327
408
  if (fits) return { action: 'start_mission', mission_id: fits.id };
@@ -338,4 +419,4 @@ function paysDollars(view) {
338
419
  return Boolean(view && view.basis === 'atris_cloud');
339
420
  }
340
421
 
341
- module.exports = { cents, compareEvents, decide, fold, paysDollars };
422
+ module.exports = { cents, compareEvents, decide, fold, foldWithPrefixes, foldWithSummaryPrefixes, paysDollars };
@@ -0,0 +1,91 @@
1
+ // atris campaign keep: a one-minute timer that runs `atris campaign wake`,
2
+ // so a campaign's promise to check again holds while no terminal is open
3
+ // (atris/features/campaign/promise.md). launchd on macOS, cron elsewhere.
4
+
5
+ 'use strict';
6
+
7
+ const crypto = require('crypto');
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+ const { spawnSync } = require('child_process');
12
+
13
+ const MARKER = 'ATRIS_CAMPAIGN_WAKE';
14
+
15
+ function xml(text) {
16
+ return String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
17
+ }
18
+
19
+ function shellQuote(text) {
20
+ return `'${String(text).replace(/'/g, `'\\''`)}'`;
21
+ }
22
+
23
+ // One timer per checkout: the label and marker carry a short hash of its root.
24
+ function planKeep({ root, home, platform = process.platform, homeDir = os.homedir(), nodeBin = 'node', atrisBin, envPath = process.env.PATH || '' }) {
25
+ const slot = crypto.createHash('sha256').update(root).digest('hex').slice(0, 8);
26
+ // node by name on the saved PATH, so upgrading node does not break the timer.
27
+ const command = ['/usr/bin/env', nodeBin, atrisBin, 'campaign', 'wake', '--json'];
28
+ const logPath = path.join(home, 'campaign-wake.log');
29
+ const base = { root, slot, log_path: logPath, command: command.join(' '), every_seconds: 60 };
30
+ if (platform === 'darwin') {
31
+ const label = `ai.atris.campaign-wake.${slot}`;
32
+ const file = path.join(homeDir, 'Library', 'LaunchAgents', `${label}.plist`);
33
+ const contents = `<?xml version="1.0" encoding="UTF-8"?>
34
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
35
+ <plist version="1.0">
36
+ <dict>
37
+ <key>Label</key><string>${xml(label)}</string>
38
+ <key>ProgramArguments</key>
39
+ <array>${command.map((part) => `<string>${xml(part)}</string>`).join('')}</array>
40
+ <key>WorkingDirectory</key><string>${xml(root)}</string>
41
+ <key>EnvironmentVariables</key>
42
+ <dict><key>PATH</key><string>${xml(envPath)}</string><key>ATRIS_SKIP_UPDATE_CHECK</key><string>1</string></dict>
43
+ <key>StartInterval</key><integer>60</integer>
44
+ <key>StandardOutPath</key><string>${xml(logPath)}</string>
45
+ <key>StandardErrorPath</key><string>${xml(logPath)}</string>
46
+ </dict>
47
+ </plist>
48
+ `;
49
+ return { ...base, kind: 'launchd', label, file, contents };
50
+ }
51
+ const marker = `${MARKER}_${slot}`;
52
+ const line = `* * * * * cd ${shellQuote(root)} && PATH=${shellQuote(envPath)} ATRIS_SKIP_UPDATE_CHECK=1 ${command.map(shellQuote).join(' ')} >> ${shellQuote(logPath)} 2>&1 # ${marker}`;
53
+ return { ...base, kind: 'cron', marker, crontab_line: line };
54
+ }
55
+
56
+ function withoutMarker(crontab, marker) {
57
+ return String(crontab || '').split('\n').filter((l) => l.trim() && !l.includes(marker)).join('\n');
58
+ }
59
+
60
+ // Replaces any timer this checkout already has. Returns { ok, error? }.
61
+ function installKeep(plan, { spawn = spawnSync, uid = process.getuid && process.getuid() } = {}) {
62
+ fs.mkdirSync(path.dirname(plan.log_path), { recursive: true });
63
+ if (plan.kind === 'launchd') {
64
+ fs.mkdirSync(path.dirname(plan.file), { recursive: true });
65
+ fs.writeFileSync(plan.file, plan.contents, 'utf8');
66
+ spawn('launchctl', ['bootout', `gui/${uid}/${plan.label}`], { encoding: 'utf8', timeout: 10000 });
67
+ const r = spawn('launchctl', ['bootstrap', `gui/${uid}`, plan.file], { encoding: 'utf8', timeout: 10000 });
68
+ return r.status === 0 ? { ok: true } : { ok: false, error: `launchctl bootstrap failed: ${String(r.stderr || r.stdout || '').trim()}` };
69
+ }
70
+ const existing = spawn('crontab', ['-l'], { encoding: 'utf8', timeout: 10000 });
71
+ const kept = withoutMarker(existing.status === 0 ? existing.stdout : '', plan.marker);
72
+ const r = spawn('crontab', ['-'], { input: `${kept ? `${kept}\n` : ''}${plan.crontab_line}\n`, encoding: 'utf8', timeout: 10000 });
73
+ return r.status === 0 ? { ok: true } : { ok: false, error: `crontab failed: ${String(r.stderr || r.stdout || '').trim()}` };
74
+ }
75
+
76
+ function removeKeep(plan, { spawn = spawnSync, uid = process.getuid && process.getuid() } = {}) {
77
+ if (plan.kind === 'launchd') {
78
+ spawn('launchctl', ['bootout', `gui/${uid}/${plan.label}`], { encoding: 'utf8', timeout: 10000 });
79
+ const had = fs.existsSync(plan.file);
80
+ if (had) fs.unlinkSync(plan.file);
81
+ return { ok: true, removed: had };
82
+ }
83
+ const existing = spawn('crontab', ['-l'], { encoding: 'utf8', timeout: 10000 });
84
+ const prior = existing.status === 0 ? String(existing.stdout || '') : '';
85
+ if (!prior.includes(plan.marker)) return { ok: true, removed: false };
86
+ const kept = withoutMarker(prior, plan.marker);
87
+ const r = spawn('crontab', ['-'], { input: kept ? `${kept}\n` : '', encoding: 'utf8', timeout: 10000 });
88
+ return r.status === 0 ? { ok: true, removed: true } : { ok: false, error: `crontab failed: ${String(r.stderr || r.stdout || '').trim()}` };
89
+ }
90
+
91
+ module.exports = { MARKER, installKeep, planKeep, removeKeep };
@@ -32,15 +32,20 @@ function approvedLanes(events) {
32
32
  return lanes;
33
33
  }
34
34
 
35
- // True when this campaign already has an open ask for the lane, so the loop asks once.
36
- function laneAskOpen(events, lane) {
37
- const open = new Set();
35
+ // Lanes with an open ask in this campaign.
36
+ function openLaneAsks(events) {
37
+ const open = new Map();
38
38
  for (const event of events || []) {
39
39
  const data = event && event.data ? event.data : {};
40
- if (event.type === 'ask_opened' && data.kind === LANE_ASK_KIND && data.lane === lane) open.add(data.ask_id);
40
+ if (event.type === 'ask_opened' && data.kind === LANE_ASK_KIND && data.lane) open.set(data.ask_id, data.lane);
41
41
  if (event.type === 'ask_answered') open.delete(data.ask_id);
42
42
  }
43
- return open.size > 0;
43
+ return new Set(open.values());
44
+ }
45
+
46
+ // True when this campaign already has an open ask for the lane, so the loop asks once.
47
+ function laneAskOpen(events, lane) {
48
+ return openLaneAsks(events).has(lane);
44
49
  }
45
50
 
46
51
  function laneQuestion(lane, member, objective) {
@@ -49,4 +54,4 @@ function laneQuestion(lane, member, objective) {
49
54
  return `${member}'s mission touches ${lane}: "${short}". Missions that touch ${lane} wait for you. Let this campaign work on ${lane}? Answer yes to allow it.`;
50
55
  }
51
56
 
52
- module.exports = { LANE_ASK_KIND, pausedLane, approvedLanes, laneAskOpen, laneQuestion };
57
+ module.exports = { LANE_ASK_KIND, pausedLane, approvedLanes, laneAskOpen, laneQuestion, openLaneAsks };