atris 3.71.3 → 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.
@@ -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)');
@@ -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).
@@ -53,6 +54,9 @@ function printHelp() {
53
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)');
54
55
  console.log(' atris campaign wake one step for every due campaign; a wait not yet due is skipped');
55
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');
56
60
  console.log(' atris campaign propose <id> [--count 3] ask the owner\'s engine for the next missions');
57
61
  console.log(' atris campaign export <id> --trace [--out <file.jsonl>] [--full] one row per mission for learning; redacted unless --full');
58
62
  console.log(' atris campaign rsi [id] [--no-write] distill trace rewards and lessons across campaigns');
@@ -85,7 +89,7 @@ const NO_LIMIT_BUDGET = 1e9;
85
89
  const noLimit = (view) => view.budget.total >= NO_LIMIT_BUDGET;
86
90
 
87
91
  // The flags this command understands, so positional words are what is left.
88
- 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'];
89
93
 
90
94
  function positionals(args) {
91
95
  const out = [];
@@ -522,7 +526,9 @@ function describeCloudRun(data) {
522
526
  function cmdShow(args, ctx) {
523
527
  const { view, error } = requireCampaign(ctx, positionals(args)[1]);
524
528
  if (error !== undefined) return error;
525
- 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 });
526
532
  return 0;
527
533
  }
528
534
 
@@ -555,7 +561,9 @@ async function cmdJudge(args, ctx) {
555
561
  if (error !== undefined) return error;
556
562
  const timeout = Number(readFlag(args, '--timeout', '')) || JUDGE_TIMEOUT_SECONDS;
557
563
  if (!ctx.asJson) console.error(`running the judge: ${view.judge}`);
558
- 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);
559
567
  ctx.append(view.id, 'judge_ran', judgeRanData(reading), 'judge');
560
568
  const after = getCampaign(ctx.store, view.id);
561
569
  const verdict = reading.passed ? 'passed' : 'failed';
@@ -821,12 +829,12 @@ async function driveLoop(args, ctx, verb, view) {
821
829
  }
822
830
  const result = await cadence.run(view.id);
823
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) });
824
- return 0;
832
+ return result.ok === false ? 1 : 0;
825
833
  }
826
834
  const maxSteps = Number(readFlag(args, '--max-steps', '')) || 20;
827
835
  const result = await loop.run(view.id, maxSteps);
828
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 });
829
- return 0;
837
+ return result.ok === false ? 1 : 0;
830
838
  }
831
839
 
832
840
  // atris campaign tick: one bounded push on the next campaign in this workspace that has
@@ -836,19 +844,27 @@ async function driveLoop(args, ctx, verb, view) {
836
844
  async function cmdTick(args, ctx) {
837
845
  const { listCampaigns } = require('../lib/campaign-store');
838
846
  const { liveHolder } = require('../lib/campaign-run-lock');
839
- const maxSteps = Math.max(1, Number(readFlag(args, '--max-steps', '')) || 4);
847
+ const now = (ctx.loopDeps && ctx.loopDeps.now) || Date.now;
848
+ let maxSteps = Math.max(1, Number(readFlag(args, '--max-steps', '')) || 4);
840
849
  const skipped = [];
841
850
  let pick = null;
842
851
  for (const view of listCampaigns(ctx.store)) {
843
852
  if (view.error) { skipped.push({ id: view.id, why: `unreadable: ${view.error}` }); continue; }
844
853
  const move = decide(view);
845
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; }
846
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; }
847
859
  if (move.action === 'wait' && move.reason === 'budget') { skipped.push({ id: view.id, why: 'waiting for budget' }); continue; }
848
860
  // A campaign sleeping until its next check is skipped, so the next one with work gets the push.
849
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; }
850
862
  const holder = ctx.campaignHome ? liveHolder(ctx.campaignHome, view.id) : null;
851
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
+ }
852
868
  pick = view;
853
869
  break;
854
870
  }
@@ -881,28 +897,44 @@ async function cmdWake(args, ctx) {
881
897
  if (view.error) { skipped.push({ campaign_id: view.id, reason: `cannot be read: ${view.error}` }); continue; }
882
898
  const move = decide(view);
883
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
+ }
884
907
  const saved = view.next_move;
885
- if (saved && saved.action === 'wait' && now() < saved.wake_at) {
908
+ if (!schedule && saved && saved.action === 'wait' && now() < saved.wake_at) {
886
909
  skipped.push({ campaign_id: view.id, reason: `next check at ${new Date(saved.wake_at).toISOString()}` });
887
910
  continue;
888
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];
889
916
  let step = null;
890
917
  let code;
891
918
  try {
892
- code = await cmdLoop(['step', view.id, ...flags], {
919
+ code = await cmdLoop(loopArgs, {
893
920
  ...ctx,
894
921
  out: (text, data) => { step = data; },
895
922
  fail: (error, failCode = 2) => { step = { ok: false, error }; return failCode; },
896
- }, 'step');
923
+ }, schedule ? 'run' : 'step');
897
924
  } catch (err) {
898
925
  code = 1;
899
926
  step = { ok: false, error: err.message };
900
927
  }
901
928
  const { campaign, next, ...result } = step || {};
902
- woke.push({ campaign_id: view.id, ...result, ok: code === 0 && result.ok !== false });
929
+ woke.push({ campaign_id: view.id, ...result, ...(schedule ? { scheduled: true } : {}), ok: code === 0 && result.ok !== false });
903
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
+ };
904
936
  const lines = [
905
- ...woke.map((w) => `${w.campaign_id} ${w.ok ? (w.stop ? `stopped: ${w.reason}` : `did: ${w.did}`) : `failed: ${w.error || w.reason}`}`),
937
+ ...woke.map((w) => `${w.campaign_id} ${said(w)}`),
906
938
  ...skipped.map((s) => `${s.campaign_id} skipped: ${s.reason}`),
907
939
  ];
908
940
  const head = `woke ${woke.length} campaign${woke.length === 1 ? '' : 's'}, skipped ${skipped.length}`;
@@ -910,6 +942,47 @@ async function cmdWake(args, ctx) {
910
942
  return 0;
911
943
  }
912
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
+
913
986
  // A one-minute timer that runs `atris campaign wake` for this checkout.
914
987
  function cmdKeep(args, ctx) {
915
988
  const { installKeep, planKeep, removeKeep } = require('../lib/campaign-keep');
@@ -932,7 +1005,7 @@ function cmdKeep(args, ctx) {
932
1005
  }
933
1006
 
934
1007
  const VERBS = {
935
- wake: cmdWake, keep: cmdKeep,
1008
+ wake: cmdWake, keep: cmdKeep, schedule: cmdSchedule,
936
1009
  start: cmdStart, show: cmdShow, judge: cmdJudge, guard: cmdGuard, seat: cmdSeat, bet: cmdBet, budget: cmdBudget,
937
1010
  pause: cmdPause, resume: cmdResume, stop: cmdStop, event: cmdEvent,
938
1011
  ask: cmdAsk, answer: cmdAnswer, 'set-judge': cmdSetJudge, sync: cmdSync, 'cloud-run': cmdCloudRun,
package/commands/clean.js CHANGED
@@ -634,6 +634,42 @@ function findStalePages(cwd, atrisDir) {
634
634
  return stalePages;
635
635
  }
636
636
 
637
+ /**
638
+ * When a source last changed. A checkout or rebase touches mtimes without
639
+ * changing content, so a file git holds unchanged counts from its last commit.
640
+ */
641
+ function contentChangedAt(sourcePath, mtime) {
642
+ const dir = path.dirname(sourcePath);
643
+ const name = path.basename(sourcePath);
644
+ const status = spawnSync('git', ['status', '--porcelain', '--', name], { cwd: dir, encoding: 'utf8' });
645
+ if (status.status !== 0 || status.stdout.trim()) return mtime;
646
+ const log = spawnSync('git', ['log', '-1', '--format=%cI', '--', name], { cwd: dir, encoding: 'utf8' });
647
+ const committed = log.status === 0 ? log.stdout.trim() : '';
648
+ return committed ? new Date(committed) : mtime;
649
+ }
650
+
651
+ function looksLikePath(text) {
652
+ if (!text || /\s/.test(text) || /^[a-z][a-z0-9+.-]*:\/\//i.test(text)) return false;
653
+ return text.includes('/') || /\.[A-Za-z0-9]+$/.test(text);
654
+ }
655
+
656
+ // A relative source can be written from the repo root, the page's own folder,
657
+ // or any folder in between (a brief citing "briefs/x.md" from the wiki root).
658
+ // The first place it exists wins; if none, the repo-root path is reported.
659
+ function resolveSource(cwd, pagePath, filePart) {
660
+ if (path.isAbsolute(filePart)) return filePart;
661
+ const root = path.resolve(cwd);
662
+ const fromRoot = path.join(root, filePart);
663
+ if (fs.existsSync(fromRoot)) return fromRoot;
664
+ let dir = path.dirname(path.resolve(pagePath));
665
+ while (dir.startsWith(root + path.sep)) {
666
+ const candidate = path.join(dir, filePart);
667
+ if (fs.existsSync(candidate)) return candidate;
668
+ dir = path.dirname(dir);
669
+ }
670
+ return fromRoot;
671
+ }
672
+
637
673
  /**
638
674
  * Parse frontmatter from a markdown file and check if any source is newer than last_compiled.
639
675
  * Returns { page, staleSource, compiledDate, sourceDate } or null if not stale.
@@ -675,15 +711,19 @@ function checkPageStaleness(cwd, filePath) {
675
711
  .replace(/\s*\([^)]*\)\s*$/, '') // drop trailing "(annotation)"
676
712
  .replace(/:\d+(-\d+)?$/, '') // drop trailing ":N" or ":N-M"
677
713
  .trim();
678
- const sourcePath = path.isAbsolute(filePart) ? filePart : path.join(cwd, filePart);
714
+ // A plain-language source ("Voyage AI documentation") or a web link names
715
+ // no file, so it can never be missing on disk. Only paths are checked.
716
+ if (!looksLikePath(filePart)) continue;
717
+ const sourcePath = resolveSource(cwd, filePath, filePart);
679
718
  try {
680
719
  const stat = fs.statSync(sourcePath);
681
- if (stat.mtime > compiledDate) {
720
+ const changed = stat.mtime > compiledDate ? contentChangedAt(sourcePath, stat.mtime) : null;
721
+ if (changed && changed > compiledDate) {
682
722
  return {
683
723
  page: filePath,
684
724
  staleSource: source,
685
725
  compiledDate: compiledMatch[1],
686
- sourceDate: stat.mtime.toISOString().split('T')[0]
726
+ sourceDate: changed.toISOString().split('T')[0]
687
727
  };
688
728
  }
689
729
  } catch {
@@ -700,6 +740,22 @@ function checkPageStaleness(cwd, filePath) {
700
740
  return null;
701
741
  }
702
742
 
743
+ // A day can already be archived when a late entry lands in its journal.
744
+ // Renaming over the archive would erase it, so the late entry is added to the
745
+ // end instead, unless the archive already holds it word for word.
746
+ function moveIntoArchive(src, dest) {
747
+ if (!fs.existsSync(dest)) {
748
+ fs.renameSync(src, dest);
749
+ return;
750
+ }
751
+ const late = fs.readFileSync(src, 'utf8');
752
+ const kept = fs.readFileSync(dest, 'utf8');
753
+ if (late.trim() && !kept.includes(late.trim())) {
754
+ fs.writeFileSync(dest, `${kept.replace(/\n*$/, '\n')}\n${late}`);
755
+ }
756
+ fs.rmSync(src);
757
+ }
758
+
703
759
  /**
704
760
  * Archive journals older than 30 days
705
761
  */
@@ -734,7 +790,7 @@ function archiveOldJournals(atrisDir, dryRun = false) {
734
790
  }
735
791
  const src = path.join(yearDir, file);
736
792
  const dest = path.join(archiveYearDir, file);
737
- fs.renameSync(src, dest);
793
+ moveIntoArchive(src, dest);
738
794
  }
739
795
  archived++;
740
796
  }