atris 3.71.1 → 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.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: campaigns
3
- description: "Run an Atris campaign: a long goal with a judge that fails today, a budget, and members in seats, worked by missions until the judge passes. Use when the goal takes many runs, needs several members, or must spend under a cap. Triggers on: run a campaign, long goal, big goal, keep going until it passes, campaign, judge, budget, atris campaign."
3
+ description: "Run an Atris campaign: a long goal (hours to days) with a judge that fails today and members in seats, worked by missions until the judge passes. Use only when the path is unknown and takes many tries; a quick fix or a single mission is not a campaign. Triggers on: run a campaign, long goal, big goal, keep going until it passes, campaign, judge, budget, atris campaign."
4
4
  version: 1.0.0
5
5
  tags:
6
6
  - campaign
@@ -12,14 +12,18 @@ 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 under a budget until the judge passes, the money 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.
16
16
 
17
- ## Campaign or mission
17
+ ## Size it first
18
18
 
19
- - **Mission**: one member, one objective, one check. Done in a run or a few ticks.
20
- - **Campaign**: the goal needs many missions, more than one member, or a spending cap. A campaign decides what to do next; its missions do the work.
19
+ Ask one question: do you already know the steps?
21
20
 
22
- If one `atris mission start` would finish it, use a mission.
21
+ - **Quick fix** (minutes): you know the file and the fix. Edit it, run the tests that cover it, ship. No mission, no judge.
22
+ - **Mission** (an hour or a few): you know what done looks like and roughly how. One `atris mission start`, or a few across members.
23
+ - **Campaign** (hours to days): done is clear and measurable, the path is not, and it takes many tries. A campaign decides what to do next; its missions do the work.
24
+ - **Purpose** (months to years): a standing campaign (`--standing`). Campaigns run under it (`--parent`), and each one's lessons feed the next.
25
+
26
+ A campaign is a vision, not a fix. If one mission would finish it, use a mission; if you could write the diff in ten minutes, write it. Measured 2026-10-16: a 50-line fix run as a campaign took six jobs and 29 minutes.
23
27
 
24
28
  ## The five commands
25
29
 
package/atris.md CHANGED
@@ -131,6 +131,7 @@ Six primitives, one sentence each:
131
131
  - **The Mission** (MISSION.md): the enduring purpose. Never ends. Sits at the top and helps members pick goals.
132
132
  - **Goals** (endgame, member goals): direction derived from the Mission. A goal points; it does not commit.
133
133
  - **A mission run** (`atris mission`): a goal that signed a contract: objective + owner + verifier + stop condition. Ends in hours or days. Any engine can tick it.
134
+ - **A campaign** (`atris campaign`): a measurable goal with a judge that fails today, worked by many mission runs until it passes. Hours to days.
134
135
  - **Tasks** (`atris task`): the smallest provable units. Receipts, then human accept.
135
136
  - **Members** (`atris member`): the WHO. A role with memory and judgment doing one bounded, proven slice at a time.
136
137
  - **Loops** (`atris loops`): the WHEN. Clocks that never finish, only fire. Health is exit 0 on cadence.
@@ -236,6 +237,14 @@ Before picking up work, decide scope:
236
237
  - single project → route to that project's `atris/team/` and `atris task` queue
237
238
  - crosses projects → route to `atris/team/cross-project-architect/` and plan the dependency order first
238
239
 
240
+ Then size it, before any tool runs. Ask one question: do I already know the steps?
241
+ - **Quick fix** (minutes): you know the file and the fix. Do it, run the tests that cover it, ship. No mission, no judge.
242
+ - **Mission** (an hour or a few): you know what done looks like and roughly how. One mission run, or a few across members (builder builds, validator checks).
243
+ - **Campaign** (hours to days): done is clear and measurable, the path is not, and it takes many tries. `atris campaign` with a judge that fails today; its missions do the work.
244
+ - **Purpose** (months to years): a standing campaign under the Mission. Its judge runs on a schedule, campaigns run under it, and each one's lessons and scored record feed the next. This is the self-improving loop.
245
+
246
+ Never size up for show. A fix run as a campaign wastes time and inference and leaves bad data (2026-10-16: a 50-line fix run as a campaign took six jobs and 29 minutes). Sizing is judgment, not a command: `atris wish` is for imagining, not for sizing.
247
+
239
248
  The human is the constructor. You multiply. Handoff fidelity lives in the files, not in context.
240
249
 
241
250
  ## next
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)))
@@ -11,12 +11,12 @@ const path = require('path');
11
11
  const { spawn } = require('child_process');
12
12
  const { readFlag } = require('../lib/arg-parser');
13
13
  const { CAMPAIGN_ID, isHumanActor, validateCampaign } = require('../lib/campaign-schema');
14
- const { decide } = require('../lib/campaign-core');
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
17
  const { judgeRanData, judgeReading, outputTail } = require('../lib/campaign-reading');
18
18
  const { CLOUD_FEATURES, cloudLink, cloudRunStatus, runInCloud, sendPersonAction, syncCampaign, takeCampaignToken } = require('../lib/campaign-sync');
19
- const { campaignHome, changedProtected, checkoutTop, planProtect, readProtect, saveProtect } = require('../lib/campaign-protect');
19
+ const { campaignHome, changedProtected, checkoutTop, headCommit, planProtect, readProtect, saveProtect } = require('../lib/campaign-protect');
20
20
 
21
21
  const JUDGE_TIMEOUT_SECONDS = 15 * 60;
22
22
  // Who pays for a campaign's engines (atris/features/campaign/api.md, Credits).
@@ -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');
@@ -211,6 +217,13 @@ function keepJudgeScript(judge, folder) {
211
217
  return { from: file, judge: shellQuote(copy) };
212
218
  }
213
219
 
220
+ // The commit saved on a campaign's start, or null when it started outside git.
221
+ function savedStartCommit(store, campaignId) {
222
+ if (!campaignId) return null;
223
+ const start = store.read(campaignId).find((event) => event.type === 'campaign_started');
224
+ return (start && start.data && start.data.start_commit) || null;
225
+ }
226
+
214
227
  function makeContext(args, deps) {
215
228
  const asJson = args.includes('--json');
216
229
  const store = deps.store || openCampaignStore(process.cwd());
@@ -220,10 +233,15 @@ function makeContext(args, deps) {
220
233
  // Held-out files live beside the campaign store, outside every checkout.
221
234
  const holdoutRoot = deps.holdoutRoot || path.dirname(store.path || campaignDbPath());
222
235
  // A campaign with a held-out file hands its path to the judge, and nothing
223
- // the judge prints from it reaches the store.
224
- const judge = async (command, timeout, campaignId) => {
236
+ // the judge prints from it reaches the store. A campaign started in a git
237
+ // checkout hands the judge the commit it started from. Start passes its
238
+ // commit in, since nothing is saved yet.
239
+ const judge = async (command, timeout, campaignId, startCommit = savedStartCommit(store, campaignId)) => {
225
240
  const holdout = campaignId ? findHoldout(holdoutRoot, campaignId) : null;
226
- const reading = await runJudge(command, timeout, holdout ? { ...process.env, ATRIS_CAMPAIGN_HOLDOUT: holdout } : process.env);
241
+ const env = { ...process.env };
242
+ if (holdout) env.ATRIS_CAMPAIGN_HOLDOUT = holdout;
243
+ if (startCommit) env.ATRIS_CAMPAIGN_START_COMMIT = startCommit;
244
+ const reading = await runJudge(command, timeout, env);
227
245
  if (!holdout) return reading;
228
246
  return { ...reading, summary: scrub(reading.summary, holdout), output_tail: scrub(reading.output_tail, holdout) };
229
247
  };
@@ -239,7 +257,7 @@ function makeContext(args, deps) {
239
257
  const append = (campaignId, type, data, as = actor) => store.append({ campaign_id: campaignId, type, actor: as, data });
240
258
  // A campaign's own files (its judge copy, held-out data) live beside the store, outside the repo.
241
259
  const folder = deps.campaignFolder || ((id) => path.join(holdoutRoot, 'campaigns', id));
242
- 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 || {} };
243
261
  }
244
262
 
245
263
  function requireCampaign(ctx, id) {
@@ -250,6 +268,14 @@ function requireCampaign(ctx, id) {
250
268
  return { view };
251
269
  }
252
270
 
271
+ // On a subscription the budget is still a cap, measured at API prices, so the
272
+ // person sees how much of it is used without a dollar figure they never paid.
273
+ function spentText(view, paid) {
274
+ if (paid) return `${money(view.budget.spent)} spent of ${money(view.budget.total)}`;
275
+ const used = view.budget.total > 0 ? Math.round((view.budget.spent / view.budget.total) * 100) : 0;
276
+ return `on your subscription, nothing spent · ${used}% of the budget used`;
277
+ }
278
+
253
279
  function renderList(views) {
254
280
  if (!views.length) {
255
281
  return 'No campaigns yet. Start one:\n atris campaign "<goal as a sentence>" --judge "<command that fails today>"';
@@ -259,7 +285,7 @@ function renderList(views) {
259
285
  for (const view of sorted) {
260
286
  const running = view.missions.filter((m) => m.status === 'running').length;
261
287
  lines.push(`${clip(view.title)} (${view.id})`);
262
- const spend = noLimit(view) ? '' : ` · ${money(view.budget.spent)} of ${money(view.budget.total)}`;
288
+ const spend = noLimit(view) ? '' : ` · ${spentText(view, paysDollars(view))}`;
263
289
  lines.push(` ${view.state.replace(/_/g, ' ')}${spend} · ${running} mission${running === 1 ? '' : 's'} running · answer ${view.answer.confidence}`);
264
290
  lines.push(` next: ${nextMove(view).text}`);
265
291
  }
@@ -274,14 +300,31 @@ function renderShow(view) {
274
300
  const last = view.last_judge;
275
301
  row('judge', `${view.judge}${last ? ` · last run ${last.passed ? 'passed' : 'failed'} at ${last.at}` : ' · never run'}${view.judge_due && last ? ' · due again' : ''}`);
276
302
  row('answer', `${view.answer.confidence}${view.answer.text ? `: ${view.answer.text}` : ''}`);
277
- if (!noLimit(view)) row('budget', `${money(view.budget.spent)} spent of ${money(view.budget.total)} · ${money(view.budget.reserved)} reserved · ${money(view.budget.available)} left`);
303
+ if (!noLimit(view)) {
304
+ const paid = paysDollars(view);
305
+ row('budget', paid ? `${spentText(view, paid)} · ${money(view.budget.reserved)} reserved · ${money(view.budget.available)} left` : spentText(view, paid));
306
+ }
278
307
  row('owner', view.owner + (view.standing ? ' · standing (a passing judge means healthy, not done)' : ''));
279
308
  if (view.bet) row('bet', `${view.bet.text}${view.bet.wrong_if ? ` · wrong if ${view.bet.wrong_if}` : ''}`);
280
309
  if (view.parent) row('parent', view.parent);
281
- 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');
282
325
  row('missions', view.missions.length ? '' : 'none');
283
326
  for (const m of view.missions) {
284
- const cost = m.cost_usd === null ? '' : ` · ${money(m.cost_usd)}`;
327
+ const cost = m.cost_usd === null || !paysDollars(view) ? '' : ` · ${money(m.cost_usd)}`;
285
328
  lines.push(` ${m.id.padEnd(22)} ${m.status.padEnd(8)} ${m.member || '-'}${m.outcome ? ` · ${m.outcome}` : ''}${cost}`);
286
329
  }
287
330
  row('asks', view.asks.length ? '' : 'none');
@@ -352,7 +395,8 @@ async function cmdStart(args, ctx) {
352
395
 
353
396
  const timeout = Number(readFlag(args, '--timeout', '')) || JUDGE_TIMEOUT_SECONDS;
354
397
  if (!ctx.asJson) console.error(`running the judge first: ${judge}`);
355
- const reading = await ctx.judge(judge, timeout, id);
398
+ const startCommit = headCommit(process.cwd());
399
+ const reading = await ctx.judge(judge, timeout, id, startCommit);
356
400
  if (reading.passed && !standing) {
357
401
  moveHoldoutBack(held);
358
402
  return ctx.fail(`already done: the judge passes before any work (${reading.summary || 'exit 0'}). Write a judge that fails first.`, 1);
@@ -371,6 +415,7 @@ async function cmdStart(args, ctx) {
371
415
  if (bet && !wrongIf) startData.bet = bet;
372
416
  if (standing) startData.standing = true;
373
417
  if (basis) startData.basis = basis;
418
+ if (startCommit) startData.start_commit = startCommit;
374
419
  ctx.append(id, 'campaign_started', startData);
375
420
  if (bet && wrongIf) ctx.append(id, 'bet_set', { bet, wrong_if: wrongIf });
376
421
  if (parent) ctx.append(id, 'attached', { parent_id: parent });
@@ -499,7 +544,7 @@ async function cmdSetJudge(args, ctx) {
499
544
  return ctx.fail(`the new judge already passes (${reading.summary || 'exit 0'}). Write a judge that fails first.`, 1);
500
545
  }
501
546
  ctx.append(view.id, 'judge_set', { judge, from: view.judge });
502
- 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');
503
548
  const after = getCampaign(ctx.store, view.id);
504
549
  ctx.out(`judge changed to: ${judge}\n${renderShow(after)}`, { ok: true, action: 'judge_set', campaign: after, next: decide(after) });
505
550
  return 0;
@@ -519,6 +564,56 @@ async function cmdJudge(args, ctx) {
519
564
  return reading.passed ? 0 : 1;
520
565
  }
521
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
+
522
617
  // Exit 1 when a protected path changed since start, naming each one.
523
618
  function cmdGuard(args, ctx) {
524
619
  const { view, error } = requireCampaign(ctx, positionals(args)[1]);
@@ -636,7 +731,7 @@ const cmdEvent = (args, ctx) => {
636
731
  return 0;
637
732
  };
638
733
 
639
- async function cmdLoop(args, ctx, verb) {
734
+ async function cmdLoop(args, ctx, verb, prepare = null) {
640
735
  const { view, error } = requireCampaign(ctx, positionals(args)[1]);
641
736
  if (error !== undefined) return error;
642
737
  // One driver per campaign: a second loop would plan and start missions
@@ -651,9 +746,13 @@ async function cmdLoop(args, ctx, verb) {
651
746
  process.once('exit', lock.release);
652
747
  }
653
748
  try {
749
+ if (prepare) prepare();
654
750
  return await driveLoop(args, ctx, verb, view);
655
751
  } finally {
656
- if (lock) lock.release();
752
+ if (lock) {
753
+ process.removeListener('exit', lock.release);
754
+ lock.release();
755
+ }
657
756
  }
658
757
  }
659
758
 
@@ -706,7 +805,7 @@ async function driveLoop(args, ctx, verb, view) {
706
805
  const result = await loop.step(view.id);
707
806
  const after = getCampaign(ctx.store, view.id);
708
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 });
709
- return 0;
808
+ return result.ok === false ? 1 : 0;
710
809
  }
711
810
  const work = readFlag(args, '--work', '');
712
811
  const pause = readFlag(args, '--break', '');
@@ -730,11 +829,116 @@ async function driveLoop(args, ctx, verb, view) {
730
829
  return 0;
731
830
  }
732
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
+
733
934
  const VERBS = {
935
+ wake: cmdWake, keep: cmdKeep,
734
936
  start: cmdStart, show: cmdShow, judge: cmdJudge, guard: cmdGuard, seat: cmdSeat, bet: cmdBet, budget: cmdBudget,
735
937
  pause: cmdPause, resume: cmdResume, stop: cmdStop, event: cmdEvent,
736
938
  ask: cmdAsk, answer: cmdAnswer, 'set-judge': cmdSetJudge, sync: cmdSync, 'cloud-run': cmdCloudRun,
939
+ rsi: cmdRsi, learn: cmdRsi,
737
940
  run: (args, ctx) => cmdLoop(args, ctx, 'run'),
941
+ tick: cmdTick,
738
942
  step: (args, ctx) => cmdLoop(args, ctx, 'step'),
739
943
  propose: (args, ctx) => cmdLoop(args, ctx, 'propose'),
740
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;
@@ -6989,18 +6995,34 @@ const CLAUDE_SESSION_CONTEXT_ROTATE_TICKS = Math.max(
6989
6995
  // before the tick fires. Same tag-plus-text routing the task lane uses
6990
6996
  // (CLI-1189). An operator override is explicit: protected_lane_ack in the
6991
6997
  // mission metadata, set by a human, or ATRIS_ALLOW_PROTECTED_MISSION=1.
6992
- function missionProtectedLaneHold(mission) {
6998
+ function missionProtectedLaneHold(mission, root = process.cwd()) {
6993
6999
  if (process.env.ATRIS_ALLOW_PROTECTED_MISSION === '1') return null;
6994
7000
  if (mission?.metadata?.protected_lane_ack) return null;
6995
7001
  const { DENIED_TAGS } = require('../lib/fleet');
6996
7002
  const { declaredProtectedLane, sniffedProtectedLane } = require('../lib/auto-accept-certified');
6997
7003
  const lane = String(mission?.lane || '').toLowerCase();
6998
- if (DENIED_TAGS.includes(lane)) return { pause_reason: `protected-lane-${lane}` };
6999
7004
  const probe = { title: mission?.objective, objective: mission?.stop_condition, metadata: mission?.metadata || {} };
7000
- if (declaredProtectedLane(probe)) return { pause_reason: 'protected-lane-declared' };
7001
- const sniffed = sniffedProtectedLane(probe);
7002
- if (sniffed) return { pause_reason: `protected-lane-${sniffed.lane}` };
7003
- return null;
7005
+ let hold = null;
7006
+ if (DENIED_TAGS.includes(lane)) hold = { pause_reason: `protected-lane-${lane}`, lane };
7007
+ else if (declaredProtectedLane(probe)) hold = { pause_reason: 'protected-lane-declared', lane: 'declared' };
7008
+ else {
7009
+ const sniffed = sniffedProtectedLane(probe);
7010
+ if (sniffed) hold = { pause_reason: `protected-lane-${sniffed.lane}`, lane: sniffed.lane };
7011
+ }
7012
+ // Inside a campaign, a person may have allowed this lane by answering the
7013
+ // campaign's ask. Only a person's answer closes an ask, so it counts as the ack.
7014
+ if (hold && mission?.campaign_id && campaignApprovedLane(root, mission.campaign_id, hold.lane)) return null;
7015
+ return hold ? { pause_reason: hold.pause_reason } : null;
7016
+ }
7017
+
7018
+ function campaignApprovedLane(root, campaignId, lane) {
7019
+ try {
7020
+ const { openCampaignStore } = require('../lib/campaign-store');
7021
+ const { approvedLanes } = require('../lib/campaign-lanes');
7022
+ return approvedLanes(openCampaignStore(root).read(campaignId)).has(lane);
7023
+ } catch {
7024
+ return false;
7025
+ }
7004
7026
  }
7005
7027
 
7006
7028
  function runnerUsesCallerSession(runner) {
@@ -10342,7 +10364,7 @@ async function executeMissionRunTicksPhase(context) {
10342
10364
  if (['complete', 'stopped', 'paused'].includes(mission.status)) { pauseReason = mission.status; break; }
10343
10365
  if (effectiveMissionVerifier(mission) !== storedVerifier) { pauseReason = 'verifier-mutated'; break; }
10344
10366
  if ((mission.lane || 'workspace') !== frozen.lane) { pauseReason = 'lane-mutated'; break; }
10345
- const protectedHold = missionProtectedLaneHold(mission);
10367
+ const protectedHold = missionProtectedLaneHold(mission, cwd);
10346
10368
  if (protectedHold) { pauseReason = protectedHold.pause_reason; break; }
10347
10369
 
10348
10370
  const tickIdx = Number(mission.last_tick_index || 0) + 1;