atris 3.71.2 → 3.71.3

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.
package/bin/atris.js CHANGED
@@ -2018,7 +2018,7 @@ if (command === 'guide') {
2018
2018
  .catch((err) => { console.error(`\nerror: ${err.message || err}`); process.exit(1); });
2019
2019
  } else if (command === 'campaign') {
2020
2020
  Promise.resolve(require('../commands/campaign').campaignCommand(process.argv.slice(3)))
2021
- .then((code) => process.exit(code || 0))
2021
+ .then((code) => exitAfterStdoutDrain(code || 0))
2022
2022
  .catch((err) => { console.error(`\nerror: ${err.message || err}`); process.exit(1); });
2023
2023
  } else if (command === 'who') {
2024
2024
  Promise.resolve(require('../commands/who').whoCommand(process.argv.slice(3)))
@@ -50,8 +50,14 @@ function printHelp() {
50
50
  console.log(' the campaign runs itself: proposes missions, runs them at once, judges, repeats until done, broke, or needing you');
51
51
  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
52
  console.log(' atris campaign step <id> one step of that loop');
53
+ 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
+ console.log(' atris campaign wake one step for every due campaign; a wait not yet due is skipped');
55
+ console.log(' atris campaign keep --install|--remove [--dry-run] a one-minute timer that runs wake (launchd or cron)');
53
56
  console.log(' atris campaign propose <id> [--count 3] ask the owner\'s engine for the next missions');
54
57
  console.log(' atris campaign export <id> --trace [--out <file.jsonl>] [--full] one row per mission for learning; redacted unless --full');
58
+ console.log(' atris campaign rsi [id] [--no-write] distill trace rewards and lessons across campaigns');
59
+ console.log(' --loop [--rounds N] runs one real campaign step per cycle, rotating through open campaigns');
60
+ console.log(' --dry-run previews the cycles without running campaign work');
55
61
  console.log(' atris campaign sync <id> trade events with the Atris cloud so web and iOS show it');
56
62
  console.log(' start --cloud syncs at once; run syncs before and after every step of a synced campaign;');
57
63
  console.log(' on a computer that has never seen <id>, sync pulls the whole campaign from the cloud');
@@ -251,7 +257,7 @@ function makeContext(args, deps) {
251
257
  const append = (campaignId, type, data, as = actor) => store.append({ campaign_id: campaignId, type, actor: as, data });
252
258
  // A campaign's own files (its judge copy, held-out data) live beside the store, outside the repo.
253
259
  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 || {} };
260
+ return { asJson, store, actor, judge, holdoutRoot, out, fail, append, forgedHuman, folder, loopDeps: deps.loopDeps, keepDeps: deps.keep, cloud: deps.cloud || {} };
255
261
  }
256
262
 
257
263
  function requireCampaign(ctx, id) {
@@ -301,7 +307,21 @@ function renderShow(view) {
301
307
  row('owner', view.owner + (view.standing ? ' · standing (a passing judge means healthy, not done)' : ''));
302
308
  if (view.bet) row('bet', `${view.bet.text}${view.bet.wrong_if ? ` · wrong if ${view.bet.wrong_if}` : ''}`);
303
309
  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');
310
+ let ovrByMember = new Map();
311
+ try {
312
+ const { openCampaignStore: openStore } = require('../lib/campaign-store');
313
+ const { summarizeStoreRsi } = require('../lib/campaign-rsi');
314
+ const summary = summarizeStoreRsi(openStore(process.cwd()));
315
+ for (const m of summary.members || []) {
316
+ if (m && m.member && typeof m.overall === 'number') ovrByMember.set(m.member, m.overall);
317
+ }
318
+ } catch {}
319
+ const formatSeat = (s) => {
320
+ const ovr = ovrByMember.has(s.member) ? `${ovrByMember.get(s.member)} ovr` : '';
321
+ const detail = [s.role, ovr].filter(Boolean).join(' · ');
322
+ return detail ? `${s.member} (${detail})` : s.member;
323
+ };
324
+ row('seats', view.seats.length ? view.seats.map(formatSeat).join(', ') : 'none');
305
325
  row('missions', view.missions.length ? '' : 'none');
306
326
  for (const m of view.missions) {
307
327
  const cost = m.cost_usd === null || !paysDollars(view) ? '' : ` · ${money(m.cost_usd)}`;
@@ -524,7 +544,7 @@ async function cmdSetJudge(args, ctx) {
524
544
  return ctx.fail(`the new judge already passes (${reading.summary || 'exit 0'}). Write a judge that fails first.`, 1);
525
545
  }
526
546
  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');
547
+ ctx.append(view.id, 'judge_ran', judgeRanData(reading), 'judge');
528
548
  const after = getCampaign(ctx.store, view.id);
529
549
  ctx.out(`judge changed to: ${judge}\n${renderShow(after)}`, { ok: true, action: 'judge_set', campaign: after, next: decide(after) });
530
550
  return 0;
@@ -544,6 +564,56 @@ async function cmdJudge(args, ctx) {
544
564
  return reading.passed ? 0 : 1;
545
565
  }
546
566
 
567
+ async function cmdRsi(args, ctx) {
568
+ const id = positionals(args)[1] || null;
569
+ if (id) {
570
+ const { error } = requireCampaign(ctx, id);
571
+ if (error !== undefined) return error;
572
+ }
573
+ const {
574
+ formatNextCampaigns,
575
+ formatOvernightRsi,
576
+ formatRsiReport,
577
+ runOvernightRsi,
578
+ selectNextCampaigns,
579
+ summarizeStoreRsi,
580
+ writeRsiSnapshot,
581
+ } = require('../lib/campaign-rsi');
582
+ if (args.includes('--loop')) {
583
+ const rounds = Number(readFlag(args, '--rounds', '1'));
584
+ if (!Number.isSafeInteger(rounds) || rounds < 1) return ctx.fail('--rounds must be a positive whole number');
585
+ const res = await runOvernightRsi(process.cwd(), {
586
+ store: ctx.store,
587
+ rounds,
588
+ campaignId: id,
589
+ dryRun: args.includes('--dry-run'),
590
+ stepCampaign: async (campaignId, selected, prepare) => {
591
+ let step = null;
592
+ const code = await cmdLoop(['step', campaignId, ...args.slice(1)], {
593
+ ...ctx,
594
+ out: (text, data) => { step = data; },
595
+ fail: (error, code = 2) => { step = { ok: false, error }; return code; },
596
+ }, 'step', prepare);
597
+ // Keep receipts small: campaign state is captured by the after reading.
598
+ const { campaign, next, ...result } = step || {};
599
+ return { ...result, ok: code === 0 && result.ok !== false };
600
+ },
601
+ });
602
+ ctx.out(formatOvernightRsi(res), { ok: true, action: 'campaign_rsi_loop', ...res });
603
+ return res.ok ? 0 : 1;
604
+ }
605
+ const rsi = args.includes('--no-write')
606
+ ? summarizeStoreRsi(ctx.store, id)
607
+ : writeRsiSnapshot(process.cwd(), ctx.store, id);
608
+ if (args.includes('--next')) {
609
+ const nextList = selectNextCampaigns(ctx.store, rsi);
610
+ ctx.out(formatNextCampaigns(nextList), { ok: true, action: 'campaign_rsi_next', next_campaigns: nextList, rsi });
611
+ return 0;
612
+ }
613
+ ctx.out(formatRsiReport(rsi), { ok: true, action: 'campaign_rsi', rsi });
614
+ return 0;
615
+ }
616
+
547
617
  // Exit 1 when a protected path changed since start, naming each one.
548
618
  function cmdGuard(args, ctx) {
549
619
  const { view, error } = requireCampaign(ctx, positionals(args)[1]);
@@ -661,7 +731,7 @@ const cmdEvent = (args, ctx) => {
661
731
  return 0;
662
732
  };
663
733
 
664
- async function cmdLoop(args, ctx, verb) {
734
+ async function cmdLoop(args, ctx, verb, prepare = null) {
665
735
  const { view, error } = requireCampaign(ctx, positionals(args)[1]);
666
736
  if (error !== undefined) return error;
667
737
  // One driver per campaign: a second loop would plan and start missions
@@ -676,9 +746,13 @@ async function cmdLoop(args, ctx, verb) {
676
746
  process.once('exit', lock.release);
677
747
  }
678
748
  try {
749
+ if (prepare) prepare();
679
750
  return await driveLoop(args, ctx, verb, view);
680
751
  } finally {
681
- if (lock) lock.release();
752
+ if (lock) {
753
+ process.removeListener('exit', lock.release);
754
+ lock.release();
755
+ }
682
756
  }
683
757
  }
684
758
 
@@ -731,7 +805,7 @@ async function driveLoop(args, ctx, verb, view) {
731
805
  const result = await loop.step(view.id);
732
806
  const after = getCampaign(ctx.store, view.id);
733
807
  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;
808
+ return result.ok === false ? 1 : 0;
735
809
  }
736
810
  const work = readFlag(args, '--work', '');
737
811
  const pause = readFlag(args, '--break', '');
@@ -755,11 +829,116 @@ async function driveLoop(args, ctx, verb, view) {
755
829
  return 0;
756
830
  }
757
831
 
832
+ // atris campaign tick: one bounded push on the next campaign in this workspace that has
833
+ // work to do. The overnight heartbeat (atris pulse tick) calls it, so campaigns stacked
834
+ // before bed keep moving all night: when one finishes, the next tick picks the next.
835
+ // Skipped: done, paused, out of money, waiting on a person, or driven by another run.
836
+ async function cmdTick(args, ctx) {
837
+ const { listCampaigns } = require('../lib/campaign-store');
838
+ const { liveHolder } = require('../lib/campaign-run-lock');
839
+ const maxSteps = Math.max(1, Number(readFlag(args, '--max-steps', '')) || 4);
840
+ const skipped = [];
841
+ let pick = null;
842
+ for (const view of listCampaigns(ctx.store)) {
843
+ if (view.error) { skipped.push({ id: view.id, why: `unreadable: ${view.error}` }); continue; }
844
+ const move = decide(view);
845
+ if (move.action === 'none') continue;
846
+ 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
+ if (move.action === 'wait' && move.reason === 'budget') { skipped.push({ id: view.id, why: 'waiting for budget' }); continue; }
848
+ // A campaign sleeping until its next check is skipped, so the next one with work gets the push.
849
+ 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
+ const holder = ctx.campaignHome ? liveHolder(ctx.campaignHome, view.id) : null;
851
+ if (holder) { skipped.push({ id: view.id, why: `another run drives it (pid ${holder.pid})` }); continue; }
852
+ pick = view;
853
+ break;
854
+ }
855
+ if (!pick) {
856
+ const why = skipped.length ? skipped.map((s) => `${s.id}: ${s.why}`).join('; ') : 'every campaign here is done, paused, or out of money';
857
+ ctx.out(`nothing to advance: ${why}`, { ok: true, picked: null, skipped });
858
+ return 0;
859
+ }
860
+ if (!ctx.asJson) console.log(`advancing ${pick.id} (up to ${maxSteps} steps)${skipped.length ? `; skipped ${skipped.map((s) => s.id).join(', ')}` : ''}`);
861
+ // One mission at a time unless asked: nobody is watching overnight, and missions
862
+ // in one folder can write the same files.
863
+ const passthrough = ['--parallel', readFlag(args, '--parallel', '') || '1'];
864
+ for (const flag of ['--runner', '--max-ticks', '--mission-minutes']) {
865
+ const value = readFlag(args, flag, '');
866
+ if (value) passthrough.push(flag, value);
867
+ }
868
+ return cmdLoop(['run', pick.id, '--max-steps', String(maxSteps), ...passthrough, ...(ctx.asJson ? ['--json'] : [])], ctx, 'run');
869
+ }
870
+
871
+ // One step for every due campaign: a saved wait whose time has come, or work
872
+ // with no wait saved. Finished, paused, and broke campaigns are skipped, and so
873
+ // is a wait not yet due. Exits 0 when a step fails, so a timer keeps firing;
874
+ // each campaign's result is in the summary.
875
+ async function cmdWake(args, ctx) {
876
+ const now = (ctx.loopDeps && ctx.loopDeps.now) || Date.now;
877
+ const flags = args.slice(args.indexOf('wake') + 1);
878
+ const woke = [];
879
+ const skipped = [];
880
+ for (const view of listCampaigns(ctx.store)) {
881
+ if (view.error) { skipped.push({ campaign_id: view.id, reason: `cannot be read: ${view.error}` }); continue; }
882
+ const move = decide(view);
883
+ if (move.action === 'none') { skipped.push({ campaign_id: view.id, reason: move.reason }); continue; }
884
+ const saved = view.next_move;
885
+ if (saved && saved.action === 'wait' && now() < saved.wake_at) {
886
+ skipped.push({ campaign_id: view.id, reason: `next check at ${new Date(saved.wake_at).toISOString()}` });
887
+ continue;
888
+ }
889
+ let step = null;
890
+ let code;
891
+ try {
892
+ code = await cmdLoop(['step', view.id, ...flags], {
893
+ ...ctx,
894
+ out: (text, data) => { step = data; },
895
+ fail: (error, failCode = 2) => { step = { ok: false, error }; return failCode; },
896
+ }, 'step');
897
+ } catch (err) {
898
+ code = 1;
899
+ step = { ok: false, error: err.message };
900
+ }
901
+ const { campaign, next, ...result } = step || {};
902
+ woke.push({ campaign_id: view.id, ...result, ok: code === 0 && result.ok !== false });
903
+ }
904
+ const lines = [
905
+ ...woke.map((w) => `${w.campaign_id} ${w.ok ? (w.stop ? `stopped: ${w.reason}` : `did: ${w.did}`) : `failed: ${w.error || w.reason}`}`),
906
+ ...skipped.map((s) => `${s.campaign_id} skipped: ${s.reason}`),
907
+ ];
908
+ const head = `woke ${woke.length} campaign${woke.length === 1 ? '' : 's'}, skipped ${skipped.length}`;
909
+ ctx.out([head, ...lines].join('\n'), { ok: true, action: 'campaign_wake', at: new Date(now()).toISOString(), woke, skipped });
910
+ return 0;
911
+ }
912
+
913
+ // A one-minute timer that runs `atris campaign wake` for this checkout.
914
+ function cmdKeep(args, ctx) {
915
+ const { installKeep, planKeep, removeKeep } = require('../lib/campaign-keep');
916
+ const install = args.includes('--install');
917
+ if (install === args.includes('--remove')) return ctx.fail('say which: atris campaign keep --install|--remove [--dry-run]');
918
+ const keepDeps = ctx.keepDeps || {};
919
+ const plan = planKeep({ root: process.cwd(), home: ctx.campaignHome, atrisBin: path.resolve(__dirname, '..', 'bin', 'atris.js'), ...(keepDeps.plan || {}) });
920
+ const action = install ? 'keep_install' : 'keep_remove';
921
+ const where = plan.kind === 'launchd' ? plan.file : 'your crontab';
922
+ 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}`;
923
+ if (args.includes('--dry-run')) {
924
+ ctx.out(`would ${install ? 'install' : 'remove'} ${describe}`, { ok: true, action, dry_run: true, plan });
925
+ return 0;
926
+ }
927
+ const result = (install ? installKeep : removeKeep)(plan, keepDeps);
928
+ if (!result.ok) return ctx.fail(result.error, 1);
929
+ const done = install ? `installed ${describe}` : (result.removed ? `removed the timer at ${where}` : 'no timer was installed for this checkout');
930
+ ctx.out(done, { ok: true, action, ...result, plan });
931
+ return 0;
932
+ }
933
+
758
934
  const VERBS = {
935
+ wake: cmdWake, keep: cmdKeep,
759
936
  start: cmdStart, show: cmdShow, judge: cmdJudge, guard: cmdGuard, seat: cmdSeat, bet: cmdBet, budget: cmdBudget,
760
937
  pause: cmdPause, resume: cmdResume, stop: cmdStop, event: cmdEvent,
761
938
  ask: cmdAsk, answer: cmdAnswer, 'set-judge': cmdSetJudge, sync: cmdSync, 'cloud-run': cmdCloudRun,
939
+ rsi: cmdRsi, learn: cmdRsi,
762
940
  run: (args, ctx) => cmdLoop(args, ctx, 'run'),
941
+ tick: cmdTick,
763
942
  step: (args, ctx) => cmdLoop(args, ctx, 'step'),
764
943
  propose: (args, ctx) => cmdLoop(args, ctx, 'propose'),
765
944
  // atris campaign new "<goal>": the owner's engine drafts a judge that fails today (lib/campaign-new.js).
@@ -528,7 +528,7 @@ function collectImproveVitals(options = {}, deps = {}) {
528
528
  };
529
529
 
530
530
  // The guarantee gauge: agent landings vs human fixes over the last 14 days.
531
- // Collection is bounded by --since so a huge repo only walks a fortnight.
531
+ // The lookback filters results while traversal can visit older ancestors.
532
532
  // No git history (not a repo, git missing) means the line is omitted silently.
533
533
  let guarantee = null;
534
534
  try {
@@ -1313,7 +1313,8 @@ function collectRevisionSignals(root, options = {}) {
1313
1313
 
1314
1314
  let raw = '';
1315
1315
  try {
1316
- raw = gitLines(root, ['log', `--since=${sinceIso}`, '--date=iso-strict', '--pretty=format:%H%x1f%P%x1f%aI%x1f%s%x1f%B%x1e']);
1316
+ // Filter dates without pruning ancestors behind an older-dated descendant.
1317
+ raw = gitLines(root, ['log', `--since-as-filter=${sinceIso}`, '--date=iso-strict', '--pretty=format:%H%x1f%P%x1f%aI%x1f%s%x1f%B%x1e']);
1317
1318
  } catch (e) {
1318
1319
  // a repo with no commits yet exits non-zero on `git log`; that is the
1319
1320
  // empty-history case, not an error. anything else (not a repo) rethrows.
@@ -64,6 +64,7 @@ const {
64
64
  renderMorningCardRow,
65
65
  } = require('../lib/receipt-block');
66
66
  const { findCachedMissionStepReceipt } = require('../lib/receipt-evidence');
67
+ const { captureCodeBaseline, collectCodeProvenance } = require('../lib/mission-code-provenance');
67
68
  const {
68
69
  pruneRuns,
69
70
  runsPruneLines,
@@ -4402,6 +4403,7 @@ function seedMissionRunContinuation(parent, root = process.cwd(), proof = '') {
4402
4403
  nextMission.next_action = `decide next mission, then run: ${nextPlan.command}`;
4403
4404
  nextMission.next_action_preview = nextPlan.preview;
4404
4405
 
4406
+ const codeBaseline = captureCodeBaseline(root);
4405
4407
  ensureMemberMissionFile(nextMission.owner, root, nextMission.objective);
4406
4408
  const { mission: saved } = saveMission(assignMissionNumber(nextMission, root), root, 'mission_continuation_started', {
4407
4409
  parent_mission_id: parent.id,
@@ -4409,7 +4411,7 @@ function seedMissionRunContinuation(parent, root = process.cwd(), proof = '') {
4409
4411
  proof: proof || null,
4410
4412
  });
4411
4413
  if (saved.campaign_id) campaignMissions().recordCampaignMissionStarted(root, saved, 0);
4412
- const worktreeBaseline = captureMissionWorktreeBaseline(saved, root);
4414
+ const worktreeBaseline = captureMissionWorktreeBaseline(saved, root, codeBaseline);
4413
4415
  const seededAt = stampIso();
4414
4416
  const { mission: updatedParent } = saveMission({
4415
4417
  ...parent,
@@ -4656,6 +4658,7 @@ function startMission(args, options = {}) {
4656
4658
  if (nodeModulesSource) mission.node_modules_source = nodeModulesSource;
4657
4659
  process.chdir(created.path);
4658
4660
  }
4661
+ const codeBaseline = captureCodeBaseline(process.cwd());
4659
4662
  if (mission.xp_task_enabled) {
4660
4663
  const xpTask = createMissionXpTask(mission, process.cwd(), asJson);
4661
4664
  mission.xp_task = xpTask;
@@ -4696,7 +4699,7 @@ function startMission(args, options = {}) {
4696
4699
  lane: saved.lane,
4697
4700
  verifier: saved.verifier,
4698
4701
  });
4699
- const worktreeBaseline = captureMissionWorktreeBaseline(saved, process.cwd());
4702
+ const worktreeBaseline = captureMissionWorktreeBaseline(saved, process.cwd(), codeBaseline);
4700
4703
  const nextTickCommand = `atris mission tick ${saved.id}${saved.verifier ? ' --verify' : ''}`;
4701
4704
  const payload = {
4702
4705
  ok: true,
@@ -4738,6 +4741,7 @@ function startMission(args, options = {}) {
4738
4741
 
4739
4742
  async function startMissionFromRunObjective(objective, args) {
4740
4743
  const asJson = wantsJson(args);
4744
+ const codeBaseline = captureCodeBaseline(process.cwd());
4741
4745
  const rawObjective = String(objective || '').trim();
4742
4746
  const landRun = hasFlag(args, '--land');
4743
4747
  const dryRun = hasFlag(args, '--dry-run');
@@ -4939,7 +4943,7 @@ async function startMissionFromRunObjective(objective, args) {
4939
4943
  lane: saved.lane,
4940
4944
  verifier: saved.verifier,
4941
4945
  });
4942
- const worktreeBaseline = captureMissionWorktreeBaseline(saved, process.cwd());
4946
+ const worktreeBaseline = captureMissionWorktreeBaseline(saved, process.cwd(), codeBaseline);
4943
4947
  const completedContinuationGoal = completeActiveContinuationForStartedMission(saved, process.cwd());
4944
4948
  const nativeGoalOptions = codexNativeGoalOptionsFromArgs(args);
4945
4949
  const goalSlotHandoff = hasFlag(args, '--take-goal-slot') && isCodexGoalMission(saved)
@@ -6629,6 +6633,7 @@ function writeReceipt(mission, result, root = process.cwd()) {
6629
6633
  tree_hash: treeHashFor(root),
6630
6634
  clean: codeState.clean,
6631
6635
  git_tree: codeState.clean ? codeState.tree : null,
6636
+ code_provenance: collectCodeProvenance(root, loadMissionWorktreeBaseline(mission.id, root)?.code),
6632
6637
  mission_id: mission.id,
6633
6638
  objective: mission.objective,
6634
6639
  owner: mission.owner,
@@ -6864,7 +6869,7 @@ function missionBaselinePath(missionId, root = process.cwd()) {
6864
6869
  // Stored outside missions.jsonl because that log re-appends the full mission
6865
6870
  // record on every save. Captured after start bookkeeping so the mission's own
6866
6871
  // state writes land inside the baseline, not in new_since_baseline.
6867
- function captureMissionWorktreeBaseline(mission, root = process.cwd()) {
6872
+ function captureMissionWorktreeBaseline(mission, root = process.cwd(), code = captureCodeBaseline(root)) {
6868
6873
  const snapshot = gitWorktreeSnapshot(root);
6869
6874
  if (!snapshot.available) return null;
6870
6875
  const baselineFile = missionBaselinePath(mission.id, root);
@@ -6880,6 +6885,7 @@ function captureMissionWorktreeBaseline(mission, root = process.cwd()) {
6880
6885
  dirty_count: snapshot.dirty_count,
6881
6886
  dirty_hash: snapshot.dirty_hash,
6882
6887
  paths: Array.from(paths).sort(),
6888
+ code,
6883
6889
  };
6884
6890
  writeJson(baselineFile, baseline);
6885
6891
  return baseline;
package/commands/pulse.js CHANGED
@@ -53,6 +53,9 @@ Options:
53
53
  --cadence "<cron>" Cron cadence for install
54
54
  --days <n> Auto-expire installed heartbeat after n days
55
55
  --hours <n> Auto-expire installed heartbeat after n hours
56
+ --no-keep-awake Install without holding the Mac awake for the window (macOS)
57
+ --no-autopilot install/tick: never fall back to autopilot (only campaigns and due missions)
58
+ --no-campaigns tick: skip pushing campaigns (atris campaign tick)
56
59
  --model <id> Runner model alias/id for installed heartbeat
57
60
  --runner-profile <n> Runner profile for installed heartbeat (e.g. atris-fast)
58
61
  --runner-bin <path> Runner binary for installed heartbeat
@@ -128,9 +131,43 @@ function runAutopilotTick(root, { timeoutMs = 600000 } = {}) {
128
131
  };
129
132
  }
130
133
 
131
- // The full heartbeat composition: continue a due mission, else author+pursue a
132
- // new goal via autopilot. This is the /loop skill's logic, encoded for OS cron.
133
- function runEngine(root, { noClaude = false, autopilotFallback = true, timeoutMs = 600000 } = {}) {
134
+ // Campaigns first: one bounded push on the next campaign here that has work
135
+ // (atris campaign tick). A campaign mission can outlast a mission tick, so it gets
136
+ // up to 50 minutes; the campaign run lock keeps two drivers off one campaign.
137
+ const CAMPAIGN_TICK_MS = 50 * 60 * 1000;
138
+ function runCampaignEngine(root, { timeoutMs = CAMPAIGN_TICK_MS } = {}) {
139
+ const cliPath = path.join(__dirname, '..', 'bin', 'atris.js');
140
+ const result = spawnSync(process.execPath, [cliPath, 'campaign', 'tick', '--json'], {
141
+ cwd: root,
142
+ encoding: 'utf8',
143
+ timeout: timeoutMs,
144
+ env: { ...process.env, ATRIS_SKIP_UPDATE_CHECK: '1' },
145
+ });
146
+ let payload = null;
147
+ try {
148
+ payload = JSON.parse(result.stdout || '{}');
149
+ } catch {}
150
+ const idle = Boolean(payload && payload.picked === null);
151
+ return {
152
+ actor: 'campaign_tick',
153
+ ok: result.status === 0,
154
+ reason: idle ? 'no_campaign_work' : (payload && payload.reason) || (result.status === 0 ? 'advanced' : 'error'),
155
+ idle,
156
+ status: result.status,
157
+ payload,
158
+ stdout: String(result.stdout || '').slice(-2000),
159
+ stderr: String(result.stderr || '').slice(-2000),
160
+ };
161
+ }
162
+
163
+ // The full heartbeat composition: push a campaign that has work, else continue a
164
+ // due mission, else author+pursue a new goal via autopilot. This is the /loop
165
+ // skill's logic, encoded for OS cron.
166
+ function runEngine(root, { noClaude = false, autopilotFallback = true, campaigns = true, timeoutMs = 600000 } = {}) {
167
+ if (campaigns && !noClaude) {
168
+ const campaign = runCampaignEngine(root);
169
+ if (!campaign.idle) return campaign;
170
+ }
134
171
  const mission = runMissionEngine(root, { noClaude, timeoutMs });
135
172
  if (pulse.shouldFallbackToAutopilot({ missionReason: mission.reason, autopilotFallback, noClaude })) {
136
173
  const ap = runAutopilotTick(root, { timeoutMs });
@@ -284,7 +321,7 @@ function tickCommand(args, root = process.cwd()) {
284
321
  // filesystem snapshot when git is absent.
285
322
  const isRepo = fs.existsSync(path.join(root, '.git'));
286
323
  const before = isRepo ? gitSnapshot(root) : pulse.fsSnapshot(root);
287
- engine = runEngine(root, { noClaude, autopilotFallback: !hasFlag(args, '--no-autopilot') });
324
+ engine = runEngine(root, { noClaude, autopilotFallback: !hasFlag(args, '--no-autopilot'), campaigns: !hasFlag(args, '--no-campaigns') });
288
325
  const after = isRepo ? gitSnapshot(root) : pulse.fsSnapshot(root);
289
326
  // This tick's ACTUAL contribution: files it newly dirtied, or a new commit.
290
327
  // Pre-existing dirt is excluded so reward isn't re-credited every tick.
@@ -315,7 +352,9 @@ function tickCommand(args, root = process.cwd()) {
315
352
  const changedTail = committed
316
353
  ? ' - committed'
317
354
  : (changedFiles.length ? ` - ${changedFiles.length} file(s) changed` : '');
318
- const what = engine.actor === 'autopilot'
355
+ const what = engine.actor === 'campaign_tick'
356
+ ? `campaign ${(engine.payload && engine.payload.campaign && engine.payload.campaign.id) || ''} ${engine.reason}${changedTail}`.replace(/\s+/g, ' ')
357
+ : engine.actor === 'autopilot'
319
358
  ? `autopilot authored/advanced a goal${changedTail}`
320
359
  : engine.reason === 'no_due_mission'
321
360
  ? 'no due mission; heartbeat alive (no-op)'
@@ -677,6 +716,8 @@ function installCommand(args, root = process.cwd(), options = {}) {
677
716
  pathDirs,
678
717
  launchLabel: label,
679
718
  launchPlistPath: plistPath,
719
+ // Baked into every tick: a night of campaigns only, no invented goals.
720
+ tickFlags: ['--no-autopilot', '--no-campaigns'].filter((flag) => hasFlag(args, flag)),
680
721
  });
681
722
  fs.writeFileSync(scriptPath, script, { mode: 0o755 });
682
723
 
@@ -695,6 +736,10 @@ function installCommand(args, root = process.cwd(), options = {}) {
695
736
  const apply = commandSpawn('launchctl', ['bootstrap', `gui/${uid}`, plistPath], { encoding: 'utf8', timeout: 10000 });
696
737
  const expiryBreadcrumbCleared = apply.status === 0 && clearPulseExpiryBreadcrumb(root);
697
738
  const cadenceMapping = pulse.launchIntervalFromCadence({ cron });
739
+ // Tests pass their own spawnSync and never get a real caffeinate unless they ask.
740
+ const wantsKeepAwake = apply.status === 0 && process.platform === 'darwin' && !hasFlag(args, '--no-keep-awake')
741
+ && (!options.spawnSync || options.holdKeepAwake);
742
+ const keepAwake = wantsKeepAwake ? (options.holdKeepAwake || holdKeepAwake)(slot.stateHome, expiry.seconds) : null;
698
743
 
699
744
  const out = {
700
745
  ok: apply.status === 0,
@@ -718,6 +763,7 @@ function installCommand(args, root = process.cwd(), options = {}) {
718
763
  runner_bin: runnerBin || null,
719
764
  runner_template_configured: Boolean(runnerCommandTemplate),
720
765
  expiry_breadcrumb_cleared: expiryBreadcrumbCleared,
766
+ keep_awake: keepAwake,
721
767
  };
722
768
  if (!asJson) {
723
769
  if (apply.status === 0) {
@@ -728,6 +774,7 @@ function installCommand(args, root = process.cwd(), options = {}) {
728
774
  `script: ${scriptPath}`,
729
775
  cadenceMapping.fallback ? `cadence '${cron}' is not directly mappable; using 3600 seconds.` : '',
730
776
  `auto-expires in ${expiry.hours ? `${expiry.hours} hours` : `${expiry.days} days`}. stop early: atris pulse uninstall`,
777
+ ...(keepAwake ? [`keeps this Mac awake until then (lid open); atris pulse uninstall lets it sleep.`] : []),
731
778
  ].filter(Boolean).join('\n') + '\n');
732
779
  } else {
733
780
  writeOutput(`pulse install failed to bootstrap ${label}: ${apply.stderr || apply.status}\nplist: ${plistPath}\n`);
@@ -736,6 +783,40 @@ function installCommand(args, root = process.cwd(), options = {}) {
736
783
  return emit(out, asJson);
737
784
  }
738
785
 
786
+ // The heartbeat only ticks while the Mac is awake. On macOS an overnight install
787
+ // holds a no-idle-sleep assertion (caffeinate -i) for exactly the heartbeat's
788
+ // window, so a Mac on its own power settings does not sleep through the night.
789
+ // Closing the lid still sleeps. Uninstall lets it go. --no-keep-awake skips it.
790
+ function keepAwakeFile(stateHome) {
791
+ return path.join(stateHome, 'keep-awake.pid');
792
+ }
793
+
794
+ function releaseKeepAwake(stateHome) {
795
+ const file = keepAwakeFile(stateHome);
796
+ let pid = 0;
797
+ try { pid = Number(fs.readFileSync(file, 'utf8').trim()); } catch { return false; }
798
+ fs.rmSync(file, { force: true });
799
+ if (!Number.isInteger(pid) || pid <= 0) return false;
800
+ try {
801
+ const name = spawnSync('ps', ['-p', String(pid), '-o', 'comm='], { encoding: 'utf8', timeout: 5000 }).stdout.trim();
802
+ if (!/caffeinate$/.test(name)) return false;
803
+ process.kill(pid, 'SIGTERM');
804
+ return true;
805
+ } catch {
806
+ return false;
807
+ }
808
+ }
809
+
810
+ function holdKeepAwake(stateHome, seconds) {
811
+ releaseKeepAwake(stateHome);
812
+ const { spawn } = require('child_process');
813
+ const child = spawn('caffeinate', ['-i', '-t', String(Math.max(60, Math.floor(seconds)))], { detached: true, stdio: 'ignore' });
814
+ child.unref();
815
+ fs.mkdirSync(stateHome, { recursive: true });
816
+ fs.writeFileSync(keepAwakeFile(stateHome), `${child.pid}\n`);
817
+ return { pid: child.pid, seconds: Math.floor(seconds) };
818
+ }
819
+
739
820
  function uninstallCommand(args, root = process.cwd(), options = {}) {
740
821
  const asJson = wantsJson(args);
741
822
  const slot = pulse.resolvePulseSlot(root, options.homeDir ? { homeDir: options.homeDir } : {});
@@ -745,6 +826,7 @@ function uninstallCommand(args, root = process.cwd(), options = {}) {
745
826
  const plistPath = path.join(homeDir, 'Library', 'LaunchAgents', `${label}.plist`);
746
827
  const uid = options.uid != null ? options.uid : process.getuid();
747
828
  commandSpawn('launchctl', ['bootout', `gui/${uid}/${label}`], { encoding: 'utf8', timeout: 10000 });
829
+ const keepAwakeReleased = (options.releaseKeepAwake || releaseKeepAwake)(slot.stateHome || slot.activeStateHome);
748
830
  const plistHad = fs.existsSync(plistPath);
749
831
  fs.rmSync(plistPath, { force: true });
750
832
  const existing = commandSpawn('crontab', ['-l'], { encoding: 'utf8', timeout: 10000 });
@@ -757,6 +839,7 @@ function uninstallCommand(args, root = process.cwd(), options = {}) {
757
839
  ok: apply.status === 0,
758
840
  action: 'pulse_uninstall',
759
841
  removed: plistHad || had,
842
+ keep_awake_released: keepAwakeReleased,
760
843
  label,
761
844
  plist_path: plistPath,
762
845
  removed_legacy_crontab: had,
@@ -818,5 +901,6 @@ module.exports = {
818
901
  pulseExpiryBreadcrumbPath,
819
902
  installCommand,
820
903
  runCommand,
904
+ uninstallCommand,
821
905
  refreshOrbPolicyLessons,
822
906
  };
@@ -16,6 +16,9 @@ const close = require('./close');
16
16
  // parallel landings that each carry a copy always clash.
17
17
  const REGEN_ADAPTER_FILES = ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md', '.atris/state/tasks.projection.json'];
18
18
  const COMMAND_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
19
+ // Push also runs local hooks. Backend startup and regression checks alone can
20
+ // exceed the short network-only allowance used by fetch and discovery.
21
+ const GIT_PUSH_TIMEOUT_MS = 5 * 60 * 1000;
19
22
  const GIT_OID_PATTERN = /^[0-9a-f]{40,64}$/;
20
23
  const ONE_LAP_PROOF_REF_PATTERN = /^refs\/atris\/one-lap\/([0-9a-f]{40,64})$/;
21
24
  const DAY_MS = 24 * 60 * 60 * 1000;
@@ -37,13 +40,15 @@ function gitNetworkEnvironment(source = process.env, cwd = process.cwd()) {
37
40
  // A killed network call leaves stderr empty; name the timeout instead.
38
41
  function gitFailureDetail(result) {
39
42
  if (result && result.error && result.error.code === 'ETIMEDOUT') return 'timed out';
40
- return String((result && (result.stderr || result.stdout)) || '').trim();
43
+ return [result && result.stdout, result && result.stderr]
44
+ .map(output => String(output || '').trim()).filter(Boolean).join('\n');
41
45
  }
42
46
 
43
47
  function runGit(args, { cwd = process.cwd(), check = true, timeout } = {}) {
44
48
  const network = ['fetch', 'push', 'ls-remote'].includes(args[0]);
45
49
  // Only network calls need the SSH probe; local calls skip its extra git spawn.
46
- const options = { cwd, encoding: 'utf8', timeout: timeout ?? (network ? 30000 : undefined),
50
+ const defaultTimeout = args[0] === 'push' ? GIT_PUSH_TIMEOUT_MS : network ? 30000 : undefined;
51
+ const options = { cwd, encoding: 'utf8', timeout: timeout ?? defaultTimeout,
47
52
  killSignal: 'SIGKILL', env: network ? gitNetworkEnvironment(process.env, cwd) : undefined };
48
53
  let result = spawnSync('git', args, options);
49
54
  // Linked checkouts share config. A concurrent start must not silently lose
@@ -1118,6 +1123,23 @@ function shipWorktree(args) {
1118
1123
  if (/already merged/i.test(`${merged.stdout}${merged.stderr}`)) {
1119
1124
  throw new Error(`gh pr merge reported the PR was already merged (stale PR reused): ${(merged.stdout || merged.stderr).trim()}`);
1120
1125
  }
1126
+ // A merge queue accepts the request before merging. Deleting its head
1127
+ // here closes the PR and cancels that request, so confirm the outcome.
1128
+ const confirmation = spawnSync('gh', ['pr', 'view', mergeRef, '--json', 'state,mergedAt'], { cwd: root, encoding: 'utf8' });
1129
+ if (confirmation.status !== 0) {
1130
+ throw new Error('could not confirm the merge; the source branch was kept');
1131
+ }
1132
+ let state;
1133
+ try { state = JSON.parse(confirmation.stdout); } catch {
1134
+ throw new Error('could not read the merge result; the source branch was kept');
1135
+ }
1136
+ if (state.state === 'OPEN' && !state.mergedAt) {
1137
+ console.log('merge: pending; kept the source branch while required checks finish');
1138
+ return 0;
1139
+ }
1140
+ if (state.state !== 'MERGED' || !state.mergedAt) {
1141
+ throw new Error('the change was not merged; the source branch was kept');
1142
+ }
1121
1143
  console.log('merge: merged');
1122
1144
  const deleted = runGit(['push', 'origin', '--delete', branch], { cwd: root, check: false });
1123
1145
  if (deleted.status === 0) {