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
@@ -12,7 +12,7 @@ tags:
12
12
 
13
13
  # Campaigns
14
14
 
15
- A campaign is a goal plus a judge. The judge is a command that exits 0 only when the goal is met. The campaign runs missions until the judge passes, a budget (cloud only) runs out, or a person has to decide something.
15
+ A campaign is a goal plus a judge. The judge is a command that exits 0 only when the goal is met. The campaign runs missions until the judge passes, a budget (cloud only) runs out, or a person has to decide something. A pass only counts when a second, fresh run of the judge passes right after it; a pass that does not repeat is recorded as a failure, so a flaky judge cannot end a campaign by luck.
16
16
 
17
17
  ## Size it first
18
18
 
package/bin/atris.js CHANGED
@@ -533,6 +533,7 @@ function showHelpShort() {
533
533
  console.log(' atris spaceship Keep working for a few hours');
534
534
  console.log(' atris autopilot Keep working until you stop');
535
535
  console.log(' atris mission the one goal');
536
+ console.log(' atris gm play your team as a front office');
536
537
  console.log('');
537
538
  console.log('More: atris help --all · machine list: atris help --json');
538
539
  }
@@ -606,6 +607,7 @@ function showHelpAll() {
606
607
  console.log(' who - Show local engines and team members working, waiting, done, or stale');
607
608
  console.log(' stream - Watch the whole team work live in one terminal');
608
609
  console.log(' team - One team view: members, roles, engine assignments (presence for live)');
610
+ console.log(' gm - Play your team as a front office: ratings, stuck jobs, league, replay, phone');
609
611
  console.log(' host - The Host: gets to know people, makes two-yes introductions (atris member install host)');
610
612
  console.log(' watch - Turn one sentence into an always-on background watcher');
611
613
  console.log(' ctop - Show a process-first live agent CPU/memory view');
@@ -698,6 +700,7 @@ function showHelpAll() {
698
700
  console.log(' pull - Pull journals + member data from cloud');
699
701
  console.log(' push - Push workspace files to cloud');
700
702
  console.log(' sync-checkout - fetch and fast-forward a clean current branch');
703
+ console.log(' zen - make this repo calm and current, keeping everything (--check to only look)');
701
704
  console.log(' cloud - Delete cloud files not present locally (cloud clean --dry-run|--yes)');
702
705
  console.log(' live - Keep a business brain fresh (doctor, pull, watch, push)');
703
706
  console.log(' clean-workspace <slug> - Analyze & remove junk files from a workspace (alias: cw)');
@@ -2018,7 +2021,7 @@ if (command === 'guide') {
2018
2021
  .catch((err) => { console.error(`\nerror: ${err.message || err}`); process.exit(1); });
2019
2022
  } else if (command === 'campaign') {
2020
2023
  Promise.resolve(require('../commands/campaign').campaignCommand(process.argv.slice(3)))
2021
- .then((code) => process.exit(code || 0))
2024
+ .then((code) => exitAfterStdoutDrain(code || 0))
2022
2025
  .catch((err) => { console.error(`\nerror: ${err.message || err}`); process.exit(1); });
2023
2026
  } else if (command === 'who') {
2024
2027
  Promise.resolve(require('../commands/who').whoCommand(process.argv.slice(3)))
@@ -2305,6 +2308,9 @@ if (command === 'guide') {
2305
2308
  } else if (command === 'sync-checkout') {
2306
2309
  const code = require('../commands/sync-checkout').syncCheckoutCommand(process.argv.slice(3));
2307
2310
  process.exit(code);
2311
+ } else if (command === 'zen') {
2312
+ const code = require('../commands/zen').zenCommand(process.argv.slice(3));
2313
+ process.exit(code);
2308
2314
  } else if (command === 'update' || command === 'sync') {
2309
2315
  const args = process.argv.slice(3);
2310
2316
  const firstSyncArg = process.argv[3];
@@ -2971,7 +2977,7 @@ if (command === 'guide') {
2971
2977
  .catch((err) => { console.error(`✗ Error: ${err.message || err}`); process.exit(1); });
2972
2978
  } else if (command === 'gm') {
2973
2979
  require('../commands/gm').gmCommand(...process.argv.slice(3))
2974
- .then(() => process.exit(0))
2980
+ .then(() => process.exit(process.exitCode || 0))
2975
2981
  .catch((err) => { console.error(`✗ Error: ${err.message || err}`); process.exit(1); });
2976
2982
  } else if (command === 'game') {
2977
2983
  require('../commands/game').gameCommand(process.argv.slice(3))
@@ -742,6 +742,7 @@ async function runTick(root, args) {
742
742
  else console.log('autoland is off; tick did nothing.');
743
743
  return 0;
744
744
  }
745
+ if (args.includes('--dry-run')) return runTickPreview(root, { json, receipt });
745
746
 
746
747
  // One live tick at a time: verify re-runs can take minutes each, and a
747
748
  // second hourly cron firing into the same snapshot double-accepts,
@@ -769,6 +770,44 @@ async function runTick(root, args) {
769
770
  }
770
771
  }
771
772
 
773
+ // `atris autoland tick --dry-run`: what the hourly pass would close, send
774
+ // back, and check, read only. No lock, no receipt, no GitHub writes, no job
775
+ // changes, no fetch.
776
+ function runTickPreview(root, { json, receipt }) {
777
+ receipt.dry_run = true;
778
+ runMergedWorkClose(root, receipt, { dryRun: true });
779
+ // The second check's next turn, in the order it would take the jobs.
780
+ try {
781
+ const taskDb = require('../lib/task-db');
782
+ const workspaceRoot = taskDb.workspaceRoot(mainCheckoutRoot(root) || root);
783
+ const certify = require('./task').cmdCertifyVerified([], { preview: true, silent: true, workspaceRoot });
784
+ receipt.second_check = {
785
+ would_check: certify.results.filter((r) => r.action === 'would_certify').map((r) => ({ ref: r.ref, verify: r.verify })),
786
+ skipped: certify.results.filter((r) => r.action === 'skipped').map((r) => ({ ref: r.ref, reason: r.reason })),
787
+ };
788
+ } catch (err) {
789
+ receipt.second_check_error = String((err && err.message) || err).slice(0, 200);
790
+ }
791
+ if (json) {
792
+ console.log(JSON.stringify(receipt));
793
+ return 0;
794
+ }
795
+ const close = receipt.merged_close || {};
796
+ console.log(`autoland tick (dry run): would close ${(close.would_close || []).length} merged job${(close.would_close || []).length === 1 ? '' : 's'}, would send ${(close.would_send_back || []).length} clashing job${(close.would_send_back || []).length === 1 ? '' : 's'} back`);
797
+ const prLine = (row) => (row.action === 'merged_earlier'
798
+ ? `merged earlier, closed without counting it as a new landing: its own pull request #${row.number} merged over a day ago`
799
+ : `its own pull request #${row.number} merged`);
800
+ for (const row of close.would_close || []) console.log(` close ${row.ref || row.id}: ${row.proof || prLine(row)}`);
801
+ for (const row of close.would_send_back || []) console.log(` send back ${row.id}: pull request #${row.number} clashes`);
802
+ for (const line of close.merge_line || []) console.log(` ${line}`);
803
+ if (receipt.merged_close_error) console.log(` could not check merged work: ${receipt.merged_close_error}`);
804
+ const check = receipt.second_check || { would_check: [], skipped: [] };
805
+ console.log(`second check: would check ${check.would_check.length} job${check.would_check.length === 1 ? '' : 's'} next, ${check.skipped.length} skipped`);
806
+ for (const row of check.would_check) console.log(` check ${row.ref}: ${row.verify}`);
807
+ if (receipt.second_check_error) console.log(` could not preview the second check: ${receipt.second_check_error}`);
808
+ return 0;
809
+ }
810
+
772
811
  function persistTickReceipt(root, receipt) {
773
812
  const runsDir = path.join(root, 'atris', 'runs');
774
813
  fs.mkdirSync(runsDir, { recursive: true });
@@ -916,6 +955,57 @@ function engineAnswerValidationLine(receipt) {
916
955
  return `graded ${graded} engine answer${graded === 1 ? '' : 's'}, scoreboard updated`;
917
956
  }
918
957
 
958
+ // Merged work closes its jobs; clashing work goes back on the list. Until
959
+ // 2026-10-22 this ran only inside the team wake loop, which last swept on
960
+ // 2026-10-15, so four merged jobs waited a week. Two sources: the autoland
961
+ // list (pull requests GitHub merged at the member's own commit, through the
962
+ // team loop's shared five-minute pass) and git itself (lib/merged-review-close.js)
963
+ // for jobs that left the list. dryRun reads and reports, and writes nothing.
964
+ function runMergedWorkClose(root, receipt, { dryRun = false, now = Date.now() } = {}) {
965
+ const out = dryRun
966
+ ? { would_close: [], would_send_back: [], merge_line: [] }
967
+ : { closed: [], sent_back: [], merge_line: [] };
968
+ const closedList = dryRun ? out.would_close : out.closed;
969
+ const sentList = dryRun ? out.would_send_back : out.sent_back;
970
+ try {
971
+ const memberAutoland = require('../lib/member-autoland');
972
+ const queue = dryRun
973
+ ? memberAutoland.requeueDroppedAutolands({ cwd: root, now, dryRun: true })
974
+ : memberAutoland.sweepAfterMissionRun({ cwd: root, now });
975
+ for (const r of (queue && queue.results) || []) {
976
+ if (r.line) out.merge_line.push(r.line);
977
+ for (const id of r.closed || []) closedList.push({ id, how: 'pull_request', number: r.number, action: r.stale ? 'merged_earlier' : 'merged' });
978
+ for (const id of r.sent_back || []) sentList.push({ id, number: r.number });
979
+ }
980
+ const taskDb = require('../lib/task-db');
981
+ const db = taskDb.open();
982
+ const workspace = taskDb.workspaceRoot(mainCheckoutRoot(root) || root);
983
+ const tasks = taskDb.listTasks(db, { workspaceRoot: workspace, status: 'review' })
984
+ .filter((t) => !closedList.some((c) => c.id === t.id));
985
+ const scan = require('../lib/merged-review-close').closeMergedReviewJobs({ root, tasks, now, dryRun });
986
+ for (const row of dryRun ? scan.matched : scan.closed) closedList.push(row);
987
+ out.checked = scan.checked;
988
+ } catch (err) {
989
+ receipt.merged_close_error = String((err && err.message) || err).slice(0, 200);
990
+ }
991
+ receipt.merged_close = out;
992
+ return out;
993
+ }
994
+
995
+ function mainCheckoutRoot(root) {
996
+ const result = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd: root, encoding: 'utf8' });
997
+ if (result.status !== 0 || !result.stdout.trim()) return null;
998
+ return path.dirname(result.stdout.trim());
999
+ }
1000
+
1001
+ function mergedCloseNote(receipt) {
1002
+ const closed = (receipt.merged_close?.closed || []).length;
1003
+ const sent = (receipt.merged_close?.sent_back || []).length;
1004
+ return (closed ? `, closed ${closed} merged job${closed === 1 ? '' : 's'}` : '')
1005
+ + (sent ? `, sent ${sent} clashing job${sent === 1 ? '' : 's'} back on the list` : '')
1006
+ + (receipt.merged_close_error ? ', could not check which jobs already merged' : '');
1007
+ }
1008
+
919
1009
  async function runTickBody(root, { json, policy, receipt, engineValidationDeps = {} }) {
920
1010
 
921
1011
  try {
@@ -940,7 +1030,7 @@ async function runTickBody(root, { json, policy, receipt, engineValidationDeps =
940
1030
  // Refresh origin once, before anything is accepted: the landing proof reads
941
1031
  // origin's target, and a stale ref would call landed work unlanded.
942
1032
  refreshLandingRefs(root);
943
- const cliArgs = ['task', 'auto-accept-certified', '--json', '--certify-first'];
1033
+ const cliArgs =['task', 'auto-accept-certified', '--json', '--certify-first'];
944
1034
  if (policy.accept_all) cliArgs.push('--all');
945
1035
  else if (policy.strict_verify === false) cliArgs.push('--no-strict-verify');
946
1036
  const pendingReviewRows = readProjection(root)
@@ -1008,6 +1098,14 @@ async function runTickBody(root, { json, policy, receipt, engineValidationDeps =
1008
1098
  receipt.accept_error = describeAcceptKillCause(accept) || accept.stderr.slice(0, 200) || 'auto-accept output unreadable';
1009
1099
  }
1010
1100
 
1101
+ // 2a. merged work closes its jobs and clashing work goes back on the list,
1102
+ // every hour, whether or not a team loop is running. After the accept step,
1103
+ // so work that can be checked and landed the normal way is landed first.
1104
+ const mergedClose = runMergedWorkClose(root, receipt);
1105
+ if ((mergedClose.closed || []).length + (mergedClose.sent_back || []).length > 0) {
1106
+ try { require('./task').refreshKeptTaskList(root); } catch {}
1107
+ }
1108
+
1011
1109
  // 2b. tell the operator the moment something lands: one text, one landing
1012
1110
  // sentence per piece (the day-one PM sentence written at finish time),
1013
1111
  // capped at three with the rest counted. Off with live_updates: false.
@@ -1312,7 +1410,7 @@ async function runTickBody(root, { json, policy, receipt, engineValidationDeps =
1312
1410
  // Always print the standing backlog, and name work that cleared every
1313
1411
  // gate and still did not land. That is the shape of a stuck loop.
1314
1412
  const stuckNote = stuckBacklogNote(receipt);
1315
- const summary = `autoland tick: ${receipt.reviews_certified ?? 0} reviews certified, ${receipt.landed.length} landed${receipt.landed.length ? ` (${receipt.landed.join(', ')})` : ''}${acceptedNotLandedNote(receipt)}, ${receipt.alarms} alarms${stuckNote}, digest ${digestTickStatus(receipt)}${reapNote}${janitorNote}${heldNote}${wishNote}${engineSweepNote}${sshAgentSweepNote(receipt)}${lostRunSweepNote(receipt)}${receiptNote}`;
1413
+ const summary = `autoland tick: ${receipt.reviews_certified ?? 0} reviews certified, ${receipt.landed.length} landed${receipt.landed.length ? ` (${receipt.landed.join(', ')})` : ''}${acceptedNotLandedNote(receipt)}${mergedCloseNote(receipt)}, ${receipt.alarms} alarms${stuckNote}, digest ${digestTickStatus(receipt)}${reapNote}${janitorNote}${heldNote}${wishNote}${engineSweepNote}${sshAgentSweepNote(receipt)}${lostRunSweepNote(receipt)}${receiptNote}`;
1316
1414
  const validationLine = engineAnswerValidationLine(receipt);
1317
1415
  if (validationLine) console.log(validationLine);
1318
1416
  console.log(gateForHuman(summary).text);
@@ -1346,6 +1444,8 @@ function showHelp() {
1346
1444
  console.log(' atris autoland off back to approving every item');
1347
1445
  console.log(' atris autoland digest [--send] the daily message, now');
1348
1446
  console.log(' atris autoland tick [--json] one heartbeat (what the hourly cron runs)');
1447
+ console.log(' atris autoland tick --dry-run what the heartbeat would close, send back,');
1448
+ console.log(' and check, without changing anything');
1349
1449
  console.log('');
1350
1450
  console.log('help is always read-only: atris autoland tick --help never lands work.');
1351
1451
  console.log('');
@@ -14,9 +14,10 @@ const { CAMPAIGN_ID, isHumanActor, validateCampaign } = require('../lib/campaign
14
14
  const { decide, paysDollars } = require('../lib/campaign-core');
15
15
  const { campaignDbPath, getCampaign, listCampaigns, newCampaignId, openCampaignStore } = require('../lib/campaign-store');
16
16
  const { findHoldout, moveHoldoutBack, moveHoldoutIn, scrub } = require('../lib/campaign-holdout');
17
- const { judgeRanData, judgeReading, outputTail } = require('../lib/campaign-reading');
17
+ const { confirmedReading, judgeRanData, judgeReading, outputTail } = require('../lib/campaign-reading');
18
18
  const { CLOUD_FEATURES, cloudLink, cloudRunStatus, runInCloud, sendPersonAction, syncCampaign, takeCampaignToken } = require('../lib/campaign-sync');
19
19
  const { campaignHome, changedProtected, checkoutTop, headCommit, planProtect, readProtect, saveProtect } = require('../lib/campaign-protect');
20
+ const schedules = require('../lib/campaign-schedule');
20
21
 
21
22
  const JUDGE_TIMEOUT_SECONDS = 15 * 60;
22
23
  // Who pays for a campaign's engines (atris/features/campaign/api.md, Credits).
@@ -50,8 +51,17 @@ function printHelp() {
50
51
  console.log(' the campaign runs itself: proposes missions, runs them at once, judges, repeats until done, broke, or needing you');
51
52
  console.log(' add --work 50m --break 10m [--rounds N] to work in rounds; each break judges and writes lessons the next round plans from');
52
53
  console.log(' atris campaign step <id> one step of that loop');
54
+ console.log(' atris campaign tick [--max-steps 4] [--parallel 1] push the next campaign here that has work, one mission at a time (the overnight heartbeat runs this)');
55
+ console.log(' atris campaign wake one step for every due campaign; a wait not yet due is skipped');
56
+ console.log(' atris campaign keep --install|--remove [--dry-run] a one-minute timer that runs wake (launchd or cron)');
57
+ console.log(' atris campaign schedule <id> --every "weekdays 09:00" [--steps 4] a person says yes once; wake and tick');
58
+ console.log(' start the campaign at those times until its budget is spent. --every also takes "daily 18:30",');
59
+ console.log(' "mon,thu 07:00", or "30m". atris campaign schedule <id> --off removes it; no id lists every schedule');
53
60
  console.log(' atris campaign propose <id> [--count 3] ask the owner\'s engine for the next missions');
54
61
  console.log(' atris campaign export <id> --trace [--out <file.jsonl>] [--full] one row per mission for learning; redacted unless --full');
62
+ console.log(' atris campaign rsi [id] [--no-write] distill trace rewards and lessons across campaigns');
63
+ console.log(' --loop [--rounds N] runs one real campaign step per cycle, rotating through open campaigns');
64
+ console.log(' --dry-run previews the cycles without running campaign work');
55
65
  console.log(' atris campaign sync <id> trade events with the Atris cloud so web and iOS show it');
56
66
  console.log(' start --cloud syncs at once; run syncs before and after every step of a synced campaign;');
57
67
  console.log(' on a computer that has never seen <id>, sync pulls the whole campaign from the cloud');
@@ -79,7 +89,7 @@ const NO_LIMIT_BUDGET = 1e9;
79
89
  const noLimit = (view) => view.budget.total >= NO_LIMIT_BUDGET;
80
90
 
81
91
  // The flags this command understands, so positional words are what is left.
82
- const VALUE_FLAGS = ['--mission-minutes', '--basis', '--work', '--break', '--rounds', '--mission', '--parallel', '--max-steps', '--max-ticks', '--runner', '--count', '--judge', '--budget', '--owner', '--seat', '--bet', '--wrong-if', '--parent', '--id', '--role', '--add', '--data', '--timeout', '--as', '--holdout', '--protect'];
92
+ const VALUE_FLAGS = ['--mission-minutes', '--basis', '--work', '--break', '--rounds', '--mission', '--parallel', '--max-steps', '--max-ticks', '--runner', '--count', '--judge', '--budget', '--owner', '--seat', '--bet', '--wrong-if', '--parent', '--id', '--role', '--add', '--data', '--timeout', '--as', '--holdout', '--protect', '--every', '--steps'];
83
93
 
84
94
  function positionals(args) {
85
95
  const out = [];
@@ -251,7 +261,7 @@ function makeContext(args, deps) {
251
261
  const append = (campaignId, type, data, as = actor) => store.append({ campaign_id: campaignId, type, actor: as, data });
252
262
  // A campaign's own files (its judge copy, held-out data) live beside the store, outside the repo.
253
263
  const folder = deps.campaignFolder || ((id) => path.join(holdoutRoot, 'campaigns', id));
254
- return { asJson, store, actor, judge, holdoutRoot, out, fail, append, forgedHuman, folder, loopDeps: deps.loopDeps, cloud: deps.cloud || {} };
264
+ return { asJson, store, actor, judge, holdoutRoot, out, fail, append, forgedHuman, folder, loopDeps: deps.loopDeps, keepDeps: deps.keep, cloud: deps.cloud || {} };
255
265
  }
256
266
 
257
267
  function requireCampaign(ctx, id) {
@@ -301,7 +311,21 @@ function renderShow(view) {
301
311
  row('owner', view.owner + (view.standing ? ' · standing (a passing judge means healthy, not done)' : ''));
302
312
  if (view.bet) row('bet', `${view.bet.text}${view.bet.wrong_if ? ` · wrong if ${view.bet.wrong_if}` : ''}`);
303
313
  if (view.parent) row('parent', view.parent);
304
- row('seats', view.seats.length ? view.seats.map((s) => (s.role ? `${s.member} (${s.role})` : s.member)).join(', ') : 'none');
314
+ let ovrByMember = new Map();
315
+ try {
316
+ const { openCampaignStore: openStore } = require('../lib/campaign-store');
317
+ const { summarizeStoreRsi } = require('../lib/campaign-rsi');
318
+ const summary = summarizeStoreRsi(openStore(process.cwd()));
319
+ for (const m of summary.members || []) {
320
+ if (m && m.member && typeof m.overall === 'number') ovrByMember.set(m.member, m.overall);
321
+ }
322
+ } catch {}
323
+ const formatSeat = (s) => {
324
+ const ovr = ovrByMember.has(s.member) ? `${ovrByMember.get(s.member)} ovr` : '';
325
+ const detail = [s.role, ovr].filter(Boolean).join(' · ');
326
+ return detail ? `${s.member} (${detail})` : s.member;
327
+ };
328
+ row('seats', view.seats.length ? view.seats.map(formatSeat).join(', ') : 'none');
305
329
  row('missions', view.missions.length ? '' : 'none');
306
330
  for (const m of view.missions) {
307
331
  const cost = m.cost_usd === null || !paysDollars(view) ? '' : ` · ${money(m.cost_usd)}`;
@@ -502,7 +526,9 @@ function describeCloudRun(data) {
502
526
  function cmdShow(args, ctx) {
503
527
  const { view, error } = requireCampaign(ctx, positionals(args)[1]);
504
528
  if (error !== undefined) return error;
505
- ctx.out(renderShow(view), { ok: true, campaign: view, next: decide(view) });
529
+ const schedule = schedules.readSchedule(ctx.campaignHome, view.id);
530
+ const text = schedule ? `${renderShow(view)}\nschedule ${schedules.describe(schedule)}` : renderShow(view);
531
+ ctx.out(text, { ok: true, campaign: view, next: decide(view), schedule });
506
532
  return 0;
507
533
  }
508
534
 
@@ -524,7 +550,7 @@ async function cmdSetJudge(args, ctx) {
524
550
  return ctx.fail(`the new judge already passes (${reading.summary || 'exit 0'}). Write a judge that fails first.`, 1);
525
551
  }
526
552
  ctx.append(view.id, 'judge_set', { judge, from: view.judge });
527
- ctx.append(view.id, 'judge_ran', { passed: reading.passed, summary: reading.summary || `exit ${reading.exit}`, seconds: reading.seconds }, 'judge');
553
+ ctx.append(view.id, 'judge_ran', judgeRanData(reading), 'judge');
528
554
  const after = getCampaign(ctx.store, view.id);
529
555
  ctx.out(`judge changed to: ${judge}\n${renderShow(after)}`, { ok: true, action: 'judge_set', campaign: after, next: decide(after) });
530
556
  return 0;
@@ -535,7 +561,9 @@ async function cmdJudge(args, ctx) {
535
561
  if (error !== undefined) return error;
536
562
  const timeout = Number(readFlag(args, '--timeout', '')) || JUDGE_TIMEOUT_SECONDS;
537
563
  if (!ctx.asJson) console.error(`running the judge: ${view.judge}`);
538
- const reading = await ctx.judge(view.judge, timeout, view.id);
564
+ // A pass that would finish the campaign must pass a second, fresh run first.
565
+ const run = () => ctx.judge(view.judge, timeout, view.id);
566
+ const reading = view.standing ? await run() : await confirmedReading(run);
539
567
  ctx.append(view.id, 'judge_ran', judgeRanData(reading), 'judge');
540
568
  const after = getCampaign(ctx.store, view.id);
541
569
  const verdict = reading.passed ? 'passed' : 'failed';
@@ -544,6 +572,56 @@ async function cmdJudge(args, ctx) {
544
572
  return reading.passed ? 0 : 1;
545
573
  }
546
574
 
575
+ async function cmdRsi(args, ctx) {
576
+ const id = positionals(args)[1] || null;
577
+ if (id) {
578
+ const { error } = requireCampaign(ctx, id);
579
+ if (error !== undefined) return error;
580
+ }
581
+ const {
582
+ formatNextCampaigns,
583
+ formatOvernightRsi,
584
+ formatRsiReport,
585
+ runOvernightRsi,
586
+ selectNextCampaigns,
587
+ summarizeStoreRsi,
588
+ writeRsiSnapshot,
589
+ } = require('../lib/campaign-rsi');
590
+ if (args.includes('--loop')) {
591
+ const rounds = Number(readFlag(args, '--rounds', '1'));
592
+ if (!Number.isSafeInteger(rounds) || rounds < 1) return ctx.fail('--rounds must be a positive whole number');
593
+ const res = await runOvernightRsi(process.cwd(), {
594
+ store: ctx.store,
595
+ rounds,
596
+ campaignId: id,
597
+ dryRun: args.includes('--dry-run'),
598
+ stepCampaign: async (campaignId, selected, prepare) => {
599
+ let step = null;
600
+ const code = await cmdLoop(['step', campaignId, ...args.slice(1)], {
601
+ ...ctx,
602
+ out: (text, data) => { step = data; },
603
+ fail: (error, code = 2) => { step = { ok: false, error }; return code; },
604
+ }, 'step', prepare);
605
+ // Keep receipts small: campaign state is captured by the after reading.
606
+ const { campaign, next, ...result } = step || {};
607
+ return { ...result, ok: code === 0 && result.ok !== false };
608
+ },
609
+ });
610
+ ctx.out(formatOvernightRsi(res), { ok: true, action: 'campaign_rsi_loop', ...res });
611
+ return res.ok ? 0 : 1;
612
+ }
613
+ const rsi = args.includes('--no-write')
614
+ ? summarizeStoreRsi(ctx.store, id)
615
+ : writeRsiSnapshot(process.cwd(), ctx.store, id);
616
+ if (args.includes('--next')) {
617
+ const nextList = selectNextCampaigns(ctx.store, rsi);
618
+ ctx.out(formatNextCampaigns(nextList), { ok: true, action: 'campaign_rsi_next', next_campaigns: nextList, rsi });
619
+ return 0;
620
+ }
621
+ ctx.out(formatRsiReport(rsi), { ok: true, action: 'campaign_rsi', rsi });
622
+ return 0;
623
+ }
624
+
547
625
  // Exit 1 when a protected path changed since start, naming each one.
548
626
  function cmdGuard(args, ctx) {
549
627
  const { view, error } = requireCampaign(ctx, positionals(args)[1]);
@@ -661,7 +739,7 @@ const cmdEvent = (args, ctx) => {
661
739
  return 0;
662
740
  };
663
741
 
664
- async function cmdLoop(args, ctx, verb) {
742
+ async function cmdLoop(args, ctx, verb, prepare = null) {
665
743
  const { view, error } = requireCampaign(ctx, positionals(args)[1]);
666
744
  if (error !== undefined) return error;
667
745
  // One driver per campaign: a second loop would plan and start missions
@@ -676,9 +754,13 @@ async function cmdLoop(args, ctx, verb) {
676
754
  process.once('exit', lock.release);
677
755
  }
678
756
  try {
757
+ if (prepare) prepare();
679
758
  return await driveLoop(args, ctx, verb, view);
680
759
  } finally {
681
- if (lock) lock.release();
760
+ if (lock) {
761
+ process.removeListener('exit', lock.release);
762
+ lock.release();
763
+ }
682
764
  }
683
765
  }
684
766
 
@@ -731,7 +813,7 @@ async function driveLoop(args, ctx, verb, view) {
731
813
  const result = await loop.step(view.id);
732
814
  const after = getCampaign(ctx.store, view.id);
733
815
  ctx.out(`${result.stop ? `stopped: ${result.reason}` : `did: ${result.did}`}\n${renderShow(after)}`, { ok: true, ...result, campaign: after, next: decide(after), sync_warnings: syncWarnings });
734
- return 0;
816
+ return result.ok === false ? 1 : 0;
735
817
  }
736
818
  const work = readFlag(args, '--work', '');
737
819
  const pause = readFlag(args, '--break', '');
@@ -747,19 +829,189 @@ async function driveLoop(args, ctx, verb, view) {
747
829
  }
748
830
  const result = await cadence.run(view.id);
749
831
  ctx.out(`stopped after ${result.rounds} round${result.rounds === 1 ? '' : 's'}: ${result.reason}\n${renderShow(result.campaign)}`, { ok: true, ...result, next: decide(result.campaign) });
750
- return 0;
832
+ return result.ok === false ? 1 : 0;
751
833
  }
752
834
  const maxSteps = Number(readFlag(args, '--max-steps', '')) || 20;
753
835
  const result = await loop.run(view.id, maxSteps);
754
836
  ctx.out(`stopped after ${result.steps} step${result.steps === 1 ? '' : 's'}: ${result.reason}\n${renderShow(result.campaign)}`, { ok: true, ...result, next: decide(result.campaign), sync_warnings: syncWarnings });
837
+ return result.ok === false ? 1 : 0;
838
+ }
839
+
840
+ // atris campaign tick: one bounded push on the next campaign in this workspace that has
841
+ // work to do. The overnight heartbeat (atris pulse tick) calls it, so campaigns stacked
842
+ // before bed keep moving all night: when one finishes, the next tick picks the next.
843
+ // Skipped: done, paused, out of money, waiting on a person, or driven by another run.
844
+ async function cmdTick(args, ctx) {
845
+ const { listCampaigns } = require('../lib/campaign-store');
846
+ const { liveHolder } = require('../lib/campaign-run-lock');
847
+ const now = (ctx.loopDeps && ctx.loopDeps.now) || Date.now;
848
+ let maxSteps = Math.max(1, Number(readFlag(args, '--max-steps', '')) || 4);
849
+ const skipped = [];
850
+ let pick = null;
851
+ for (const view of listCampaigns(ctx.store)) {
852
+ if (view.error) { skipped.push({ id: view.id, why: `unreadable: ${view.error}` }); continue; }
853
+ const move = decide(view);
854
+ if (move.action === 'none') continue;
855
+ // A scheduled campaign moves only when its time comes (lib/campaign-schedule.js).
856
+ const schedule = schedules.readSchedule(ctx.campaignHome, view.id);
857
+ if (schedule && !schedules.isDue(schedule, now())) { skipped.push({ id: view.id, why: `scheduled: next run ${schedules.whenText(schedule.next_at)}` }); continue; }
858
+ if (move.action === 'wait' && move.reason === 'open asks') { skipped.push({ id: view.id, why: `needs you: ${move.asks.length} open ask${move.asks.length === 1 ? '' : 's'}` }); continue; }
859
+ if (move.action === 'wait' && move.reason === 'budget') { skipped.push({ id: view.id, why: 'waiting for budget' }); continue; }
860
+ // A campaign sleeping until its next check is skipped, so the next one with work gets the push.
861
+ if (view.next_move && view.next_move.action === 'wait' && Date.now() < view.next_move.wake_at) { skipped.push({ id: view.id, why: `next check at ${new Date(view.next_move.wake_at).toISOString()}` }); continue; }
862
+ const holder = ctx.campaignHome ? liveHolder(ctx.campaignHome, view.id) : null;
863
+ if (holder) { skipped.push({ id: view.id, why: `another run drives it (pid ${holder.pid})` }); continue; }
864
+ if (schedule) {
865
+ schedules.markFired(ctx.campaignHome, view.id, schedule, now());
866
+ if (!readFlag(args, '--max-steps', '')) maxSteps = schedule.steps;
867
+ }
868
+ pick = view;
869
+ break;
870
+ }
871
+ if (!pick) {
872
+ const why = skipped.length ? skipped.map((s) => `${s.id}: ${s.why}`).join('; ') : 'every campaign here is done, paused, or out of money';
873
+ ctx.out(`nothing to advance: ${why}`, { ok: true, picked: null, skipped });
874
+ return 0;
875
+ }
876
+ if (!ctx.asJson) console.log(`advancing ${pick.id} (up to ${maxSteps} steps)${skipped.length ? `; skipped ${skipped.map((s) => s.id).join(', ')}` : ''}`);
877
+ // One mission at a time unless asked: nobody is watching overnight, and missions
878
+ // in one folder can write the same files.
879
+ const passthrough = ['--parallel', readFlag(args, '--parallel', '') || '1'];
880
+ for (const flag of ['--runner', '--max-ticks', '--mission-minutes']) {
881
+ const value = readFlag(args, flag, '');
882
+ if (value) passthrough.push(flag, value);
883
+ }
884
+ return cmdLoop(['run', pick.id, '--max-steps', String(maxSteps), ...passthrough, ...(ctx.asJson ? ['--json'] : [])], ctx, 'run');
885
+ }
886
+
887
+ // One step for every due campaign: a saved wait whose time has come, or work
888
+ // with no wait saved. Finished, paused, and broke campaigns are skipped, and so
889
+ // is a wait not yet due. Exits 0 when a step fails, so a timer keeps firing;
890
+ // each campaign's result is in the summary.
891
+ async function cmdWake(args, ctx) {
892
+ const now = (ctx.loopDeps && ctx.loopDeps.now) || Date.now;
893
+ const flags = args.slice(args.indexOf('wake') + 1);
894
+ const woke = [];
895
+ const skipped = [];
896
+ for (const view of listCampaigns(ctx.store)) {
897
+ if (view.error) { skipped.push({ campaign_id: view.id, reason: `cannot be read: ${view.error}` }); continue; }
898
+ const move = decide(view);
899
+ if (move.action === 'none') { skipped.push({ campaign_id: view.id, reason: move.reason }); continue; }
900
+ // A scheduled campaign waits for its time, then gets a whole bounded run,
901
+ // one mission at a time, instead of one step a minute.
902
+ const schedule = schedules.readSchedule(ctx.campaignHome, view.id);
903
+ if (schedule && !schedules.isDue(schedule, now())) {
904
+ skipped.push({ campaign_id: view.id, reason: `scheduled: next run ${schedules.whenText(schedule.next_at)}` });
905
+ continue;
906
+ }
907
+ const saved = view.next_move;
908
+ if (!schedule && saved && saved.action === 'wait' && now() < saved.wake_at) {
909
+ skipped.push({ campaign_id: view.id, reason: `next check at ${new Date(saved.wake_at).toISOString()}` });
910
+ continue;
911
+ }
912
+ if (schedule) schedules.markFired(ctx.campaignHome, view.id, schedule, now());
913
+ const loopArgs = schedule
914
+ ? ['run', view.id, '--max-steps', String(schedule.steps), '--parallel', '1', ...flags]
915
+ : ['step', view.id, ...flags];
916
+ let step = null;
917
+ let code;
918
+ try {
919
+ code = await cmdLoop(loopArgs, {
920
+ ...ctx,
921
+ out: (text, data) => { step = data; },
922
+ fail: (error, failCode = 2) => { step = { ok: false, error }; return failCode; },
923
+ }, schedule ? 'run' : 'step');
924
+ } catch (err) {
925
+ code = 1;
926
+ step = { ok: false, error: err.message };
927
+ }
928
+ const { campaign, next, ...result } = step || {};
929
+ woke.push({ campaign_id: view.id, ...result, ...(schedule ? { scheduled: true } : {}), ok: code === 0 && result.ok !== false });
930
+ }
931
+ const said = (w) => {
932
+ if (!w.ok) return `failed: ${w.error || w.reason}`;
933
+ if (w.scheduled) return `ran on schedule, ${w.steps} step${w.steps === 1 ? '' : 's'}: ${w.reason}`;
934
+ return w.stop ? `stopped: ${w.reason}` : `did: ${w.did}`;
935
+ };
936
+ const lines = [
937
+ ...woke.map((w) => `${w.campaign_id} ${said(w)}`),
938
+ ...skipped.map((s) => `${s.campaign_id} skipped: ${s.reason}`),
939
+ ];
940
+ const head = `woke ${woke.length} campaign${woke.length === 1 ? '' : 's'}, skipped ${skipped.length}`;
941
+ ctx.out([head, ...lines].join('\n'), { ok: true, action: 'campaign_wake', at: new Date(now()).toISOString(), woke, skipped });
942
+ return 0;
943
+ }
944
+
945
+ // A person's standing yes: the campaign starts on its own at set times until
946
+ // its budget is spent. Agents may remove a schedule but never set one.
947
+ function cmdSchedule(args, ctx) {
948
+ const now = (ctx.loopDeps && ctx.loopDeps.now) || Date.now;
949
+ const id = positionals(args)[1];
950
+ if (!id) {
951
+ const rows = listCampaigns(ctx.store).filter((view) => !view.error)
952
+ .map((view) => ({ view, schedule: schedules.readSchedule(ctx.campaignHome, view.id) }))
953
+ .filter((row) => row.schedule);
954
+ const text = rows.length ? rows.map((row) => `${row.view.id} ${schedules.describe(row.schedule)}`).join('\n') : 'no campaign here has a schedule';
955
+ ctx.out(text, { ok: true, schedules: rows.map((row) => row.schedule) });
956
+ return 0;
957
+ }
958
+ const { view, error } = requireCampaign(ctx, id);
959
+ if (error !== undefined) return error;
960
+ if (args.includes('--off')) {
961
+ const removed = schedules.removeSchedule(ctx.campaignHome, view.id);
962
+ ctx.out(removed ? `${view.id} no longer runs on a schedule` : `${view.id} had no schedule`, { ok: true, removed });
963
+ return 0;
964
+ }
965
+ const every = readFlag(args, '--every', '');
966
+ if (!every) {
967
+ const schedule = schedules.readSchedule(ctx.campaignHome, view.id);
968
+ ctx.out(schedule ? `${view.id} ${schedules.describe(schedule)}` : `${view.id} has no schedule. Set one: atris campaign schedule ${view.id} --every "weekdays 09:00"`, { ok: true, schedule });
969
+ return 0;
970
+ }
971
+ if (!isHumanActor(ctx.actor)) return ctx.fail('a schedule starts work with nobody watching, so only a person can set one. Ask them: atris campaign ask <id> "Run this every ...?"');
972
+ if (view.state === 'done') return ctx.fail(`${view.id} is done; a schedule would never run`);
973
+ if (noLimit(view)) return ctx.fail(`${view.id} has no budget, and a schedule runs with nobody watching. Start it with --budget, then schedule it`);
974
+ const parsed = schedules.parseEvery(every);
975
+ if (parsed.error) return ctx.fail(parsed.error);
976
+ const stepsFlag = readFlag(args, '--steps', '');
977
+ const steps = stepsFlag ? Math.floor(Number(stepsFlag)) : schedules.DEFAULT_STEPS;
978
+ if (!(steps >= 1)) return ctx.fail('--steps must be a whole number, 1 or more');
979
+ const schedule = schedules.saveSchedule(ctx.campaignHome, view.id, { every: parsed.every, rule: parsed.rule, steps, by: ctx.actor, now: now() });
980
+ const left = `stops on its own when the ${money(view.budget.available)} left is spent`;
981
+ const fires = 'atris campaign keep --install starts it on time here; the overnight heartbeat (atris pulse) also fires it';
982
+ ctx.out(`${view.id} runs ${schedules.describe(schedule)}. It ${left}.\n${fires}`, { ok: true, schedule });
983
+ return 0;
984
+ }
985
+
986
+ // A one-minute timer that runs `atris campaign wake` for this checkout.
987
+ function cmdKeep(args, ctx) {
988
+ const { installKeep, planKeep, removeKeep } = require('../lib/campaign-keep');
989
+ const install = args.includes('--install');
990
+ if (install === args.includes('--remove')) return ctx.fail('say which: atris campaign keep --install|--remove [--dry-run]');
991
+ const keepDeps = ctx.keepDeps || {};
992
+ const plan = planKeep({ root: process.cwd(), home: ctx.campaignHome, atrisBin: path.resolve(__dirname, '..', 'bin', 'atris.js'), ...(keepDeps.plan || {}) });
993
+ const action = install ? 'keep_install' : 'keep_remove';
994
+ const where = plan.kind === 'launchd' ? plan.file : 'your crontab';
995
+ const describe = `a ${plan.kind} timer that runs every minute:\n ${plan.command}\n in ${plan.root}\n at ${where}\n log ${plan.log_path}`;
996
+ if (args.includes('--dry-run')) {
997
+ ctx.out(`would ${install ? 'install' : 'remove'} ${describe}`, { ok: true, action, dry_run: true, plan });
998
+ return 0;
999
+ }
1000
+ const result = (install ? installKeep : removeKeep)(plan, keepDeps);
1001
+ if (!result.ok) return ctx.fail(result.error, 1);
1002
+ const done = install ? `installed ${describe}` : (result.removed ? `removed the timer at ${where}` : 'no timer was installed for this checkout');
1003
+ ctx.out(done, { ok: true, action, ...result, plan });
755
1004
  return 0;
756
1005
  }
757
1006
 
758
1007
  const VERBS = {
1008
+ wake: cmdWake, keep: cmdKeep, schedule: cmdSchedule,
759
1009
  start: cmdStart, show: cmdShow, judge: cmdJudge, guard: cmdGuard, seat: cmdSeat, bet: cmdBet, budget: cmdBudget,
760
1010
  pause: cmdPause, resume: cmdResume, stop: cmdStop, event: cmdEvent,
761
1011
  ask: cmdAsk, answer: cmdAnswer, 'set-judge': cmdSetJudge, sync: cmdSync, 'cloud-run': cmdCloudRun,
1012
+ rsi: cmdRsi, learn: cmdRsi,
762
1013
  run: (args, ctx) => cmdLoop(args, ctx, 'run'),
1014
+ tick: cmdTick,
763
1015
  step: (args, ctx) => cmdLoop(args, ctx, 'step'),
764
1016
  propose: (args, ctx) => cmdLoop(args, ctx, 'propose'),
765
1017
  // atris campaign new "<goal>": the owner's engine drafts a judge that fails today (lib/campaign-new.js).