atris 3.71.0 → 3.71.2

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
@@ -3003,7 +3003,12 @@ if (command === 'guide') {
3003
3003
  const mailbox = extractGmailMailboxAccount(raw);
3004
3004
  // Mailbox --account <id> is a selector, not the workspace opt-in.
3005
3005
  // Scratch folders refuse every gmail verb the same way as slack/inbox.
3006
- const gate = requireAccountBound(mailbox.args.filter((arg) => arg !== '--account'));
3006
+ // Read verbs also run from a project folder (atris/, no business.json).
3007
+ const GMAIL_READ_VERBS = new Set(['inbox', 'list', 'read', 'triage', 'verdicts', 'accounts']);
3008
+ const gate = requireAccountBound(
3009
+ mailbox.args.filter((arg) => arg !== '--account'),
3010
+ { read: GMAIL_READ_VERBS.has(mailbox.args[0]) },
3011
+ );
3007
3012
  if (!gate.ok) {
3008
3013
  process.exit(refuseAccountGlobal());
3009
3014
  return;
@@ -3042,7 +3047,12 @@ if (command === 'guide') {
3042
3047
  const account = extractGmailMailboxAccount(raw);
3043
3048
  // Account --account <id> is a selector, not the workspace opt-in.
3044
3049
  // Scratch folders refuse every slack verb the same way as gmail/inbox.
3045
- const gate = requireAccountBound(account.args.filter((arg) => arg !== '--account'));
3050
+ // Read verbs also run from a project folder (atris/, no business.json).
3051
+ const SLACK_READ_VERBS = new Set(['channels', 'list', 'dms', 'messages', 'read', 'search']);
3052
+ const gate = requireAccountBound(
3053
+ account.args.filter((arg) => arg !== '--account'),
3054
+ { read: SLACK_READ_VERBS.has(account.args[0]) },
3055
+ );
3046
3056
  if (!gate.ok) {
3047
3057
  process.exit(refuseAccountGlobal());
3048
3058
  return;
@@ -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).
@@ -46,7 +46,7 @@ function printHelp() {
46
46
  console.log(' atris campaign answer <id> <ask-id> "<answer>" a person answers; agents cannot');
47
47
  console.log(' atris campaign pause|resume|stop <id>');
48
48
  console.log(' atris campaign event <id> <type> [--data \'<json>\'] append any event by hand');
49
- console.log(' atris campaign run <id> [--parallel 3] [--max-steps 20] [--max-ticks 3] [--runner auto]');
49
+ console.log(' atris campaign run <id> [--parallel 3] [--max-steps 20] [--max-ticks 3] [--mission-minutes 40] [--runner auto]');
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');
@@ -79,7 +79,7 @@ const NO_LIMIT_BUDGET = 1e9;
79
79
  const noLimit = (view) => view.budget.total >= NO_LIMIT_BUDGET;
80
80
 
81
81
  // The flags this command understands, so positional words are what is left.
82
- const VALUE_FLAGS = ['--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'];
82
+ const VALUE_FLAGS = ['--mission-minutes', '--basis', '--work', '--break', '--rounds', '--mission', '--parallel', '--max-steps', '--max-ticks', '--runner', '--count', '--judge', '--budget', '--owner', '--seat', '--bet', '--wrong-if', '--parent', '--id', '--role', '--add', '--data', '--timeout', '--as', '--holdout', '--protect'];
83
83
 
84
84
  function positionals(args) {
85
85
  const out = [];
@@ -211,6 +211,13 @@ function keepJudgeScript(judge, folder) {
211
211
  return { from: file, judge: shellQuote(copy) };
212
212
  }
213
213
 
214
+ // The commit saved on a campaign's start, or null when it started outside git.
215
+ function savedStartCommit(store, campaignId) {
216
+ if (!campaignId) return null;
217
+ const start = store.read(campaignId).find((event) => event.type === 'campaign_started');
218
+ return (start && start.data && start.data.start_commit) || null;
219
+ }
220
+
214
221
  function makeContext(args, deps) {
215
222
  const asJson = args.includes('--json');
216
223
  const store = deps.store || openCampaignStore(process.cwd());
@@ -220,10 +227,15 @@ function makeContext(args, deps) {
220
227
  // Held-out files live beside the campaign store, outside every checkout.
221
228
  const holdoutRoot = deps.holdoutRoot || path.dirname(store.path || campaignDbPath());
222
229
  // 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) => {
230
+ // the judge prints from it reaches the store. A campaign started in a git
231
+ // checkout hands the judge the commit it started from. Start passes its
232
+ // commit in, since nothing is saved yet.
233
+ const judge = async (command, timeout, campaignId, startCommit = savedStartCommit(store, campaignId)) => {
225
234
  const holdout = campaignId ? findHoldout(holdoutRoot, campaignId) : null;
226
- const reading = await runJudge(command, timeout, holdout ? { ...process.env, ATRIS_CAMPAIGN_HOLDOUT: holdout } : process.env);
235
+ const env = { ...process.env };
236
+ if (holdout) env.ATRIS_CAMPAIGN_HOLDOUT = holdout;
237
+ if (startCommit) env.ATRIS_CAMPAIGN_START_COMMIT = startCommit;
238
+ const reading = await runJudge(command, timeout, env);
227
239
  if (!holdout) return reading;
228
240
  return { ...reading, summary: scrub(reading.summary, holdout), output_tail: scrub(reading.output_tail, holdout) };
229
241
  };
@@ -250,6 +262,14 @@ function requireCampaign(ctx, id) {
250
262
  return { view };
251
263
  }
252
264
 
265
+ // On a subscription the budget is still a cap, measured at API prices, so the
266
+ // person sees how much of it is used without a dollar figure they never paid.
267
+ function spentText(view, paid) {
268
+ if (paid) return `${money(view.budget.spent)} spent of ${money(view.budget.total)}`;
269
+ const used = view.budget.total > 0 ? Math.round((view.budget.spent / view.budget.total) * 100) : 0;
270
+ return `on your subscription, nothing spent · ${used}% of the budget used`;
271
+ }
272
+
253
273
  function renderList(views) {
254
274
  if (!views.length) {
255
275
  return 'No campaigns yet. Start one:\n atris campaign "<goal as a sentence>" --judge "<command that fails today>"';
@@ -259,7 +279,7 @@ function renderList(views) {
259
279
  for (const view of sorted) {
260
280
  const running = view.missions.filter((m) => m.status === 'running').length;
261
281
  lines.push(`${clip(view.title)} (${view.id})`);
262
- const spend = noLimit(view) ? '' : ` · ${money(view.budget.spent)} of ${money(view.budget.total)}`;
282
+ const spend = noLimit(view) ? '' : ` · ${spentText(view, paysDollars(view))}`;
263
283
  lines.push(` ${view.state.replace(/_/g, ' ')}${spend} · ${running} mission${running === 1 ? '' : 's'} running · answer ${view.answer.confidence}`);
264
284
  lines.push(` next: ${nextMove(view).text}`);
265
285
  }
@@ -274,14 +294,17 @@ function renderShow(view) {
274
294
  const last = view.last_judge;
275
295
  row('judge', `${view.judge}${last ? ` · last run ${last.passed ? 'passed' : 'failed'} at ${last.at}` : ' · never run'}${view.judge_due && last ? ' · due again' : ''}`);
276
296
  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`);
297
+ if (!noLimit(view)) {
298
+ const paid = paysDollars(view);
299
+ row('budget', paid ? `${spentText(view, paid)} · ${money(view.budget.reserved)} reserved · ${money(view.budget.available)} left` : spentText(view, paid));
300
+ }
278
301
  row('owner', view.owner + (view.standing ? ' · standing (a passing judge means healthy, not done)' : ''));
279
302
  if (view.bet) row('bet', `${view.bet.text}${view.bet.wrong_if ? ` · wrong if ${view.bet.wrong_if}` : ''}`);
280
303
  if (view.parent) row('parent', view.parent);
281
304
  row('seats', view.seats.length ? view.seats.map((s) => (s.role ? `${s.member} (${s.role})` : s.member)).join(', ') : 'none');
282
305
  row('missions', view.missions.length ? '' : 'none');
283
306
  for (const m of view.missions) {
284
- const cost = m.cost_usd === null ? '' : ` · ${money(m.cost_usd)}`;
307
+ const cost = m.cost_usd === null || !paysDollars(view) ? '' : ` · ${money(m.cost_usd)}`;
285
308
  lines.push(` ${m.id.padEnd(22)} ${m.status.padEnd(8)} ${m.member || '-'}${m.outcome ? ` · ${m.outcome}` : ''}${cost}`);
286
309
  }
287
310
  row('asks', view.asks.length ? '' : 'none');
@@ -352,7 +375,8 @@ async function cmdStart(args, ctx) {
352
375
 
353
376
  const timeout = Number(readFlag(args, '--timeout', '')) || JUDGE_TIMEOUT_SECONDS;
354
377
  if (!ctx.asJson) console.error(`running the judge first: ${judge}`);
355
- const reading = await ctx.judge(judge, timeout, id);
378
+ const startCommit = headCommit(process.cwd());
379
+ const reading = await ctx.judge(judge, timeout, id, startCommit);
356
380
  if (reading.passed && !standing) {
357
381
  moveHoldoutBack(held);
358
382
  return ctx.fail(`already done: the judge passes before any work (${reading.summary || 'exit 0'}). Write a judge that fails first.`, 1);
@@ -371,6 +395,7 @@ async function cmdStart(args, ctx) {
371
395
  if (bet && !wrongIf) startData.bet = bet;
372
396
  if (standing) startData.standing = true;
373
397
  if (basis) startData.basis = basis;
398
+ if (startCommit) startData.start_commit = startCommit;
374
399
  ctx.append(id, 'campaign_started', startData);
375
400
  if (bet && wrongIf) ctx.append(id, 'bet_set', { bet, wrong_if: wrongIf });
376
401
  if (parent) ctx.append(id, 'attached', { parent_id: parent });
@@ -639,6 +664,25 @@ const cmdEvent = (args, ctx) => {
639
664
  async function cmdLoop(args, ctx, verb) {
640
665
  const { view, error } = requireCampaign(ctx, positionals(args)[1]);
641
666
  if (error !== undefined) return error;
667
+ // One driver per campaign: a second loop would plan and start missions
668
+ // against the same budget and files (lib/campaign-run-lock.js).
669
+ let lock = null;
670
+ if (ctx.store.path && ctx.campaignHome) {
671
+ lock = require('../lib/campaign-run-lock').acquireRunLock(ctx.campaignHome, view.id);
672
+ if (!lock.ok) {
673
+ const holder = lock.holder || {};
674
+ return ctx.fail(`another run is driving ${view.id} (pid ${holder.pid || '?'} in ${holder.cwd || '?'} since ${holder.started_at || '?'}); wait for it to stop or stop that process`, 1);
675
+ }
676
+ process.once('exit', lock.release);
677
+ }
678
+ try {
679
+ return await driveLoop(args, ctx, verb, view);
680
+ } finally {
681
+ if (lock) lock.release();
682
+ }
683
+ }
684
+
685
+ async function driveLoop(args, ctx, verb, view) {
642
686
  const { makeLoop } = require('../lib/campaign-loop');
643
687
  // A synced campaign trades events around every step. Failures warn and the
644
688
  // loop runs on offline; a refusal (403, 409) stops syncing for this run
@@ -666,6 +710,7 @@ async function cmdLoop(args, ctx, verb) {
666
710
  const loop = makeLoop(process.cwd(), {
667
711
  parallel: readFlag(args, '--parallel', '') || readFlag(args, '--count', ''),
668
712
  maxTicks: readFlag(args, '--max-ticks', ''),
713
+ missionMinutes: readFlag(args, '--mission-minutes', ''),
669
714
  runner: readFlag(args, '--runner', ''),
670
715
  actor: ctx.actor,
671
716
  }, {
@@ -7,7 +7,7 @@
7
7
  * atris gmail archive <id> [...] [--account <id>] - Archive messages
8
8
  * atris gmail triage [--account <id>] [--limit N] - Record keep or archive verdicts
9
9
  * atris gmail verdicts [--summary] [--account <id>] [--limit N] - List recent Gmail verdicts
10
- * atris gmail send <to> <subject> <body...> [--body-file <path>] [--account <id>] - Send an email
10
+ * atris gmail send <to> <subject> <body...> --approved [--body-file <path>] [--account <id>] - Send an email
11
11
  * atris gmail voice [account] [--clear] - Edit an account's writing voice
12
12
  * atris gmail connect [name] - Connect or reconnect a Gmail account
13
13
  * atris gmail accounts - List connected Gmail accounts and the active account
@@ -41,7 +41,7 @@ const { sleepSync } = require('../lib/sleep-sync');
41
41
  const CALENDAR_CACHE_PATH = path.join(stateHome(), '.atris', 'calendar-events-cache.json');
42
42
  const GMAIL_CONNECT_POLL_MS = 3000;
43
43
  const GMAIL_CONNECT_TIMEOUT_MS = 3 * 60 * 1000;
44
- const GMAIL_SEND_USAGE = 'usage: atris gmail send <to> <subject> <body...> [--body-file <path>] [--account <id>]';
44
+ const GMAIL_SEND_USAGE = 'usage: atris gmail send <to> <subject> <body...> --approved [--body-file <path>] [--account <id>]';
45
45
  const GMAIL_VOICE_USAGE = 'usage: atris gmail voice [account] [--clear]';
46
46
  const GMAIL_TRIAGE_USAGE = 'usage: atris gmail triage [--account <id>] [--limit N]';
47
47
  const GMAIL_VERDICTS_USAGE = 'usage: atris gmail verdicts [--summary] [--account <id>] [--limit N]';
@@ -560,10 +560,15 @@ async function gmailArchive(messageIds, options = {}) {
560
560
  function parseGmailSendArgs(args = []) {
561
561
  let accountId = null;
562
562
  let bodyFile = null;
563
+ let approved = false;
563
564
  const positional = [];
564
565
 
565
566
  for (let i = 0; i < args.length; i += 1) {
566
567
  const arg = args[i];
568
+ if (arg === '--approved' || arg === '--confirm-approved') {
569
+ approved = true;
570
+ continue;
571
+ }
567
572
  if (arg === '--account' || arg === '--body-file') {
568
573
  const value = String(args[i + 1] || '').trim();
569
574
  if (!value || value.startsWith('--')) {
@@ -596,10 +601,17 @@ function parseGmailSendArgs(args = []) {
596
601
  }
597
602
  }
598
603
 
599
- return { to, subject, body, accountId };
604
+ return { to, subject, body, accountId, approved };
600
605
  }
601
606
 
602
607
  async function gmailSend(to, subject, body, options = {}) {
608
+ // Approval is checked before any token lookup or network call so an
609
+ // unapproved send can never reach the server.
610
+ if (!options.approved) {
611
+ console.error('Refusing to send without --approved after the exact recipient, subject, and body are confirmed.');
612
+ console.error(GMAIL_SEND_USAGE);
613
+ process.exit(2);
614
+ }
603
615
  const token = await getAuthToken();
604
616
  const accountId = resolveGmailAccountId(options.accountId);
605
617
  const accounts = await fetchGmailAccounts(token);
@@ -864,7 +876,7 @@ async function gmailCommand(subcommand, ...args) {
864
876
  }
865
877
  case 'send': {
866
878
  const parsed = parseGmailSendArgs(args);
867
- await gmailSend(parsed.to, parsed.subject, parsed.body, { accountId: parsed.accountId || undefined });
879
+ await gmailSend(parsed.to, parsed.subject, parsed.body, { accountId: parsed.accountId || undefined, approved: parsed.approved });
868
880
  break;
869
881
  }
870
882
  case 'voice': {
@@ -888,7 +900,7 @@ async function gmailCommand(subcommand, ...args) {
888
900
  console.log(' atris gmail archive <id> [...] [--account <id>] - archive messages (reversible, all mail keeps them)');
889
901
  console.log(' atris gmail triage [--account <id>] [--limit N] - record keep or archive verdicts without changing mail');
890
902
  console.log(' atris gmail verdicts [--summary] [--account <id>] [--limit N] - list recent gmail verdicts');
891
- console.log(' atris gmail send <to> <subject> <body...> [--body-file <path>] [--account <id>] - send an email');
903
+ console.log(' atris gmail send <to> <subject> <body...> --approved [--body-file <path>] [--account <id>] - send an email');
892
904
  console.log(' atris gmail voice [account] [--clear] - edit or clear an account writing voice');
893
905
  console.log(' atris gmail connect [name] - connect or reconnect a gmail account');
894
906
  console.log(' atris gmail accounts - list connected gmail accounts and the active account');
@@ -6989,18 +6989,34 @@ const CLAUDE_SESSION_CONTEXT_ROTATE_TICKS = Math.max(
6989
6989
  // before the tick fires. Same tag-plus-text routing the task lane uses
6990
6990
  // (CLI-1189). An operator override is explicit: protected_lane_ack in the
6991
6991
  // mission metadata, set by a human, or ATRIS_ALLOW_PROTECTED_MISSION=1.
6992
- function missionProtectedLaneHold(mission) {
6992
+ function missionProtectedLaneHold(mission, root = process.cwd()) {
6993
6993
  if (process.env.ATRIS_ALLOW_PROTECTED_MISSION === '1') return null;
6994
6994
  if (mission?.metadata?.protected_lane_ack) return null;
6995
6995
  const { DENIED_TAGS } = require('../lib/fleet');
6996
6996
  const { declaredProtectedLane, sniffedProtectedLane } = require('../lib/auto-accept-certified');
6997
6997
  const lane = String(mission?.lane || '').toLowerCase();
6998
- if (DENIED_TAGS.includes(lane)) return { pause_reason: `protected-lane-${lane}` };
6999
6998
  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;
6999
+ let hold = null;
7000
+ if (DENIED_TAGS.includes(lane)) hold = { pause_reason: `protected-lane-${lane}`, lane };
7001
+ else if (declaredProtectedLane(probe)) hold = { pause_reason: 'protected-lane-declared', lane: 'declared' };
7002
+ else {
7003
+ const sniffed = sniffedProtectedLane(probe);
7004
+ if (sniffed) hold = { pause_reason: `protected-lane-${sniffed.lane}`, lane: sniffed.lane };
7005
+ }
7006
+ // Inside a campaign, a person may have allowed this lane by answering the
7007
+ // campaign's ask. Only a person's answer closes an ask, so it counts as the ack.
7008
+ if (hold && mission?.campaign_id && campaignApprovedLane(root, mission.campaign_id, hold.lane)) return null;
7009
+ return hold ? { pause_reason: hold.pause_reason } : null;
7010
+ }
7011
+
7012
+ function campaignApprovedLane(root, campaignId, lane) {
7013
+ try {
7014
+ const { openCampaignStore } = require('../lib/campaign-store');
7015
+ const { approvedLanes } = require('../lib/campaign-lanes');
7016
+ return approvedLanes(openCampaignStore(root).read(campaignId)).has(lane);
7017
+ } catch {
7018
+ return false;
7019
+ }
7004
7020
  }
7005
7021
 
7006
7022
  function runnerUsesCallerSession(runner) {
@@ -10342,7 +10358,7 @@ async function executeMissionRunTicksPhase(context) {
10342
10358
  if (['complete', 'stopped', 'paused'].includes(mission.status)) { pauseReason = mission.status; break; }
10343
10359
  if (effectiveMissionVerifier(mission) !== storedVerifier) { pauseReason = 'verifier-mutated'; break; }
10344
10360
  if ((mission.lane || 'workspace') !== frozen.lane) { pauseReason = 'lane-mutated'; break; }
10345
- const protectedHold = missionProtectedLaneHold(mission);
10361
+ const protectedHold = missionProtectedLaneHold(mission, cwd);
10346
10362
  if (protectedHold) { pauseReason = protectedHold.pause_reason; break; }
10347
10363
 
10348
10364
  const tickIdx = Number(mission.last_tick_index || 0) + 1;
@@ -20,6 +20,14 @@ function isProvisionedAgentWorkspace(cwd = process.cwd()) {
20
20
  return fs.existsSync(path.join(root, '.atris', 'agent-token.json'));
21
21
  }
22
22
 
23
+ // A Desktop project folder carries an atris/ directory but no business.json.
24
+ // It is a real workspace, not a scratch folder, so account reads may run
25
+ // there. Writes (send, dm, archive, connect) still need the business binding.
26
+ function isProjectWorkspace(cwd = process.cwd()) {
27
+ const root = path.resolve(cwd);
28
+ return fs.existsSync(path.join(root, 'atris'));
29
+ }
30
+
23
31
  function isHelpArg(arg) {
24
32
  return arg === '--help' || arg === '-h' || arg === 'help';
25
33
  }
@@ -33,6 +41,8 @@ function looksLikeBusinessSlug(token) {
33
41
  /**
34
42
  * Account-scoped verbs dump live CRM/cloud state. Outside a bound business
35
43
  * workspace they must opt in with --account. Help always passes.
44
+ * Pass { read: true } for read-only verbs: a project folder with atris/ (no
45
+ * business.json) then counts as bound. Writes never get that relaxation.
36
46
  * Returns { ok, args } where args has --account stripped when continuing.
37
47
  */
38
48
  function requireAccountBound(args = [], options = {}) {
@@ -44,6 +54,9 @@ function requireAccountBound(args = [], options = {}) {
44
54
  if (isBoundBusinessWorkspace(cwd) || isProvisionedAgentWorkspace(cwd)) {
45
55
  return { ok: true, args: list.filter((arg) => arg !== '--account'), bound: true };
46
56
  }
57
+ if (options.read === true && isProjectWorkspace(cwd)) {
58
+ return { ok: true, args: list.filter((arg) => arg !== '--account'), bound: true, project: true };
59
+ }
47
60
  if (list.includes('--account')) {
48
61
  return { ok: true, args: list.filter((arg) => arg !== '--account'), account: true };
49
62
  }
@@ -9,6 +9,7 @@
9
9
 
10
10
  const fs = require('fs');
11
11
  const path = require('path');
12
+ const { paysDollars } = require('./campaign-core');
12
13
  const { getCampaign, openCampaignStore } = require('./campaign-store');
13
14
 
14
15
  const LESSON_LINES_FOR_PLANNING = 30;
@@ -44,8 +45,14 @@ function roundSummary(before, after) {
44
45
  return { kept, failed, vetoed, spent, fresh };
45
46
  }
46
47
 
48
+ // On a subscription the round is told without dollars, since none were paid.
49
+ function roundTally(view, summary) {
50
+ return `${summary.kept} kept, ${summary.failed} failed${paysDollars(view) ? `, $${summary.spent} spent` : ''}`;
51
+ }
52
+
47
53
  function reviewPrompt(view, summary, lessons) {
48
- const missions = summary.fresh.map((m) => `- ${m.id}: ${m.status}${m.outcome ? ` (${m.outcome})` : ''}${m.cost_usd !== null ? `, $${m.cost_usd}` : ''}`).join('\n') || '- none ended this round';
54
+ const paid = paysDollars(view);
55
+ const missions = summary.fresh.map((m) => `- ${m.id}: ${m.status}${m.outcome ? ` (${m.outcome})` : ''}${paid && m.cost_usd !== null ? `, $${m.cost_usd}` : ''}`).join('\n') || '- none ended this round';
49
56
  return [
50
57
  'You review one round of an Atris campaign. Reply with 2 to 6 short lessons, one per line, each starting with "- ".',
51
58
  'A lesson says what to do differently next round, in plain words. Do not repeat a lesson already listed.',
@@ -53,7 +60,7 @@ function reviewPrompt(view, summary, lessons) {
53
60
  `Campaign: ${view.title}`,
54
61
  `Judge: ${view.judge}`,
55
62
  `Judge now: ${view.last_judge ? `${view.last_judge.passed ? 'passed' : 'failed'}${view.last_judge.reading !== null ? ` (reading ${view.last_judge.reading})` : ''}: ${view.answer.text || ''}` : 'never run'}`,
56
- `This round: ${summary.kept} kept, ${summary.failed} failed, ${summary.vetoed} could not start, $${summary.spent} spent`,
63
+ `This round: ${summary.kept} kept, ${summary.failed} failed, ${summary.vetoed} could not start${paid ? `, $${summary.spent} spent` : ''}`,
57
64
  'Missions this round:',
58
65
  missions,
59
66
  '',
@@ -88,9 +95,9 @@ function makeCadence(root, loop, options = {}, deps = {}) {
88
95
  fs.mkdirSync(path.dirname(file), { recursive: true });
89
96
  if (!fs.existsSync(file)) fs.writeFileSync(file, `# Lessons: ${after.title}\n\nWritten by the owner at each review; the next round plans from them.\n`);
90
97
  const stamp = new Date(now()).toISOString().slice(0, 16).replace('T', ' ');
91
- const header = `\n## ${stamp}: ${summary.kept} kept, ${summary.failed} failed, $${summary.spent} spent\n`;
98
+ const header = `\n## ${stamp}: ${roundTally(after, summary)}\n`;
92
99
  fs.appendFileSync(file, `${header}${(written.length ? written : ['- (no review written this round)']).join('\n')}\n`);
93
- return { summary, lessons: written };
100
+ return { summary, lessons: written, view: after };
94
101
  }
95
102
 
96
103
  async function run(id) {
@@ -111,8 +118,8 @@ function makeCadence(root, loop, options = {}, deps = {}) {
111
118
  }
112
119
  const breakStarted = now();
113
120
  log(`round ${round}: review`);
114
- const { summary, lessons } = await review(id, before);
115
- log(` ${summary.kept} kept, ${summary.failed} failed, $${summary.spent} spent; ${lessons.length} lesson${lessons.length === 1 ? '' : 's'} written`);
121
+ const { summary, lessons, view: reviewed } = await review(id, before);
122
+ log(` ${roundTally(reviewed, summary)}; ${lessons.length} lesson${lessons.length === 1 ? '' : 's'} written`);
116
123
  const view = getCampaign(store, id);
117
124
  last = { reason: stopped || 'work window ended', round };
118
125
  // Finished, out of money, or waiting on a person: no point in another round.
@@ -282,6 +282,9 @@ function fold(events) {
282
282
  bet: s.bet,
283
283
  parent: s.parent,
284
284
  home: s.home,
285
+ // Only when the start event names one, so the shared golden views stay as
286
+ // pinned; no basis means the person's own engines.
287
+ ...(d.basis === 'atris_cloud' || d.basis === 'own_engines' ? { basis: d.basis } : {}),
285
288
  created_from: d.created_from === undefined ? null : d.created_from,
286
289
  seats: s.seats,
287
290
  state,
@@ -327,4 +330,12 @@ function decide(view) {
327
330
  return { action: 'propose', reason: 'no missions queued' };
328
331
  }
329
332
 
330
- module.exports = { cents, compareEvents, decide, fold };
333
+ // Spend is real dollars only when billing is API or Atris runs the campaign on
334
+ // its own engines (basis atris_cloud). Otherwise the work ran on the person's own
335
+ // subscriptions: they paid nothing, and cost_usd stays as the API-price budget meter.
336
+ function paysDollars(view) {
337
+ if (process.env.ATRIS_BILLING === 'api') return true;
338
+ return Boolean(view && view.basis === 'atris_cloud');
339
+ }
340
+
341
+ module.exports = { cents, compareEvents, decide, fold, paysDollars };
@@ -0,0 +1,52 @@
1
+ 'use strict';
2
+
3
+ // Protected lanes inside a campaign. A mission that touches billing, security,
4
+ // deploys, or customers pauses before its first tick (missionProtectedLaneHold).
5
+ // Inside a campaign that pause becomes an ask; a person answering yes approves
6
+ // that one lane for that one campaign. The approval lives in the campaign's own
7
+ // event log, where only a person's answer closes an ask.
8
+
9
+ const { isHumanActor } = require('./campaign-schema');
10
+
11
+ const LANE_ASK_KIND = 'protected_lane';
12
+ const YES = /^\s*(y|yes|ok|okay|approve|approved|allow|go|sure)\b/i;
13
+ const PAUSE_LINE = /paused: protected-lane-([a-z]+)/i;
14
+
15
+ // The lane a paused `atris mission run` reported, or null.
16
+ function pausedLane(output) {
17
+ const match = String(output || '').match(PAUSE_LINE);
18
+ return match ? match[1].toLowerCase() : null;
19
+ }
20
+
21
+ // Lanes a person approved, from a campaign's events.
22
+ function approvedLanes(events) {
23
+ const asked = new Map();
24
+ const lanes = new Set();
25
+ for (const event of events || []) {
26
+ const data = event && event.data ? event.data : {};
27
+ if (event.type === 'ask_opened' && data.kind === LANE_ASK_KIND && data.lane) asked.set(data.ask_id, data.lane);
28
+ if (event.type === 'ask_answered' && asked.has(data.ask_id) && isHumanActor(event.actor) && YES.test(String(data.answer || ''))) {
29
+ lanes.add(asked.get(data.ask_id));
30
+ }
31
+ }
32
+ return lanes;
33
+ }
34
+
35
+ // True when this campaign already has an open ask for the lane, so the loop asks once.
36
+ function laneAskOpen(events, lane) {
37
+ const open = new Set();
38
+ for (const event of events || []) {
39
+ const data = event && event.data ? event.data : {};
40
+ if (event.type === 'ask_opened' && data.kind === LANE_ASK_KIND && data.lane === lane) open.add(data.ask_id);
41
+ if (event.type === 'ask_answered') open.delete(data.ask_id);
42
+ }
43
+ return open.size > 0;
44
+ }
45
+
46
+ function laneQuestion(lane, member, objective) {
47
+ const what = String(objective || '').replace(/\s+/g, ' ').trim();
48
+ const short = what.length > 140 ? `${what.slice(0, 139)}…` : what;
49
+ return `${member}'s mission touches ${lane}: "${short}". Missions that touch ${lane} wait for you. Let this campaign work on ${lane}? Answer yes to allow it.`;
50
+ }
51
+
52
+ module.exports = { LANE_ASK_KIND, pausedLane, approvedLanes, laneAskOpen, laneQuestion };
@@ -12,6 +12,7 @@ const { decide } = require('./campaign-core');
12
12
  const { getCampaign, openCampaignStore } = require('./campaign-store');
13
13
  const { defaultReserve, reconcileCampaign } = require('./campaign-missions');
14
14
  const { judgeRanData, latestJudgeOutput } = require('./campaign-reading');
15
+ const { LANE_ASK_KIND, pausedLane, laneAskOpen, laneQuestion } = require('./campaign-lanes');
15
16
 
16
17
  const TERMINAL = new Set(['stopped', 'complete']);
17
18
 
@@ -86,17 +87,37 @@ async function askOwner(root, view, prompt) {
86
87
  }
87
88
 
88
89
  // Run one atris command and resolve with its exit code and output.
90
+ // A live step an engine prints while a mission runs: "<member> <hh:mm> <what it is doing>".
91
+ const ACTIVITY_LINE = /^\s*[a-z0-9][a-z0-9-]*\s+\d{1,2}:\d{2}\s{2,}\S/i;
92
+
89
93
  function runCli(args, { cwd, env = process.env, onLine = null } = {}) {
90
94
  return new Promise((resolve) => {
91
95
  const child = spawn(process.execPath, [CLI, ...args], { cwd, env: { ...env, ATRIS_SKIP_UPDATE_CHECK: '1' }, stdio: ['ignore', 'pipe', 'pipe'] });
92
96
  let out = '';
93
97
  let err = '';
94
- child.stdout.on('data', (chunk) => {
95
- out += chunk;
96
- if (onLine) String(chunk).split('\n').filter(Boolean).forEach(onLine);
98
+ // Whole lines only: a chunk can end mid-line. Live engine steps arrive on
99
+ // stderr (member-progress reportProgress), so both streams are read.
100
+ const reader = () => {
101
+ let partial = '';
102
+ return {
103
+ push(chunk) {
104
+ if (!onLine) return;
105
+ const lines = (partial + String(chunk)).split('\n');
106
+ partial = lines.pop();
107
+ lines.filter(Boolean).forEach(onLine);
108
+ },
109
+ end() { if (onLine && partial) onLine(partial); },
110
+ };
111
+ };
112
+ const stdout = reader();
113
+ const stderr = reader();
114
+ child.stdout.on('data', (chunk) => { out += chunk; stdout.push(chunk); });
115
+ child.stderr.on('data', (chunk) => { err += chunk; stderr.push(chunk); });
116
+ child.on('close', (code) => {
117
+ stdout.end();
118
+ stderr.end();
119
+ resolve({ code, out, err });
97
120
  });
98
- child.stderr.on('data', (chunk) => { err += chunk; });
99
- child.on('close', (code) => resolve({ code, out, err }));
100
121
  });
101
122
  }
102
123
 
@@ -105,10 +126,13 @@ function makeLoop(root, options = {}, deps = {}) {
105
126
  const log = deps.log || ((line) => console.log(line));
106
127
  const parallel = Math.max(1, Number(options.parallel) || 3);
107
128
  const maxTicks = Math.max(1, Number(options.maxTicks) || 3);
129
+ // Every mission gets an explicit time limit. Without one, a number in the
130
+ // objective ("437 tests, 475s") was read as the mission's time budget.
131
+ const missionMinutes = Math.max(1, Number(options.missionMinutes) || 40);
108
132
  const runner = options.runner || 'auto';
109
133
  const actor = options.actor || 'mission-lead';
110
134
  const ask = deps.ask || askOwner;
111
- const cli = deps.cli || ((args) => runCli(args, { cwd: root }));
135
+ const cli = deps.cli || ((args, extra = {}) => runCli(args, { cwd: root, ...extra }));
112
136
  const judge = deps.judge;
113
137
 
114
138
  const view = (id) => getCampaign(store, id);
@@ -155,7 +179,8 @@ function makeLoop(root, options = {}, deps = {}) {
155
179
  const reserve = work.estimate_usd || defaultReserve(current);
156
180
  const started = await cli([
157
181
  'mission', 'start', work.objective, '--mission-id', work.mission_id, '--owner', work.member,
158
- '--runner', runner, '--verify', work.verify || current.judge, '--campaign', id, '--reserve', String(reserve), '--json',
182
+ '--runner', runner, '--verify', work.verify || current.judge, '--campaign', id, '--reserve', String(reserve),
183
+ '--max-wall', String(missionMinutes * 60), '--json',
159
184
  ]);
160
185
  if (started.code !== 0) {
161
186
  const why = (started.out + started.err).trim().split('\n').pop();
@@ -164,8 +189,22 @@ function makeLoop(root, options = {}, deps = {}) {
164
189
  return { mission_id: work.mission_id, started: false };
165
190
  }
166
191
  log(` ${work.mission_id} started for ${work.member}: ${work.objective}`);
167
- const ran = await cli(['mission', 'run', work.mission_id, '--max-ticks', String(maxTicks), '--complete-on-pass']);
192
+ // The engine's live steps ("researcher 06:47 editing stable.mjs") pass through to
193
+ // the campaign's own output, so a screen watching the run can show the work.
194
+ const ran = await cli(['mission', 'run', work.mission_id, '--max-ticks', String(maxTicks), '--complete-on-pass'], { onLine: (line) => { if (ACTIVITY_LINE.test(line)) log(` ${line.trim()}`); } });
168
195
  const after = view(id).missions.find((m) => m.id === work.mission_id);
196
+ // A mission in a protected lane (billing, security, deploy, customer) pauses
197
+ // before its first tick. Ask a person once per lane instead of failing quietly.
198
+ const lane = after && after.status !== 'ended' ? pausedLane(`${ran.out || ''}\n${ran.err || ''}`) : null;
199
+ if (lane) {
200
+ if (!laneAskOpen(store.read(id), lane)) {
201
+ const askId = `a-${crypto.randomBytes(3).toString('hex')}`;
202
+ append(id, 'ask_opened', { ask_id: askId, question: laneQuestion(lane, work.member, work.objective), mission_id: work.mission_id, kind: LANE_ASK_KIND, lane }, current.owner);
203
+ }
204
+ await cli(['mission', 'stop', work.mission_id, '--reason', `campaign loop: waiting for a person to allow the ${lane} lane`]);
205
+ log(` ${work.mission_id} needs you: it touches ${lane}`);
206
+ return { mission_id: work.mission_id, started: true, outcome: null, needs: lane };
207
+ }
169
208
  if (after && after.status !== 'ended') {
170
209
  // The run stopped without finishing (tick cap, error): close it so its
171
210
  // reserve comes back and its cost is counted.
@@ -239,4 +278,4 @@ function makeLoop(root, options = {}, deps = {}) {
239
278
  return { step, run, propose };
240
279
  }
241
280
 
242
- module.exports = { askOwner, makeLoop, parseProposals, proposalPrompt };
281
+ module.exports = { ACTIVITY_LINE, askOwner, makeLoop, parseProposals, proposalPrompt, runCli };
@@ -29,6 +29,12 @@ function protectFile(home, campaignId) {
29
29
  return path.join(home, 'campaigns', campaignId, 'protect.json');
30
30
  }
31
31
 
32
+ // The commit a checkout sits on, or null outside git or before the first commit.
33
+ function headCommit(root) {
34
+ const head = git(root, ['rev-parse', 'HEAD']);
35
+ return head.ok ? head.out : null;
36
+ }
37
+
32
38
  // The commit and checkout-relative paths to save, or an error in plain words.
33
39
  function planProtect(root, paths) {
34
40
  const topDir = checkoutTop(root);
@@ -72,4 +78,4 @@ function changedProtected(root, saved) {
72
78
  return { changed: [...new Set(changed)].sort() };
73
79
  }
74
80
 
75
- module.exports = { campaignHome, changedProtected, checkoutTop, planProtect, protectFile, readProtect, saveProtect };
81
+ module.exports = { campaignHome, changedProtected, checkoutTop, headCommit, planProtect, protectFile, readProtect, saveProtect };
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+
3
+ // One driver per campaign on a computer. Two `campaign run` loops on the same
4
+ // campaign each planned and started missions, so they raced for the same
5
+ // budget and edited the same files (2026-10-15, cmp-suite-3min). The lock is
6
+ // a file beside the store naming the process that holds it; a lock whose
7
+ // process is gone is stale and is taken over.
8
+
9
+ const fs = require('fs');
10
+ const os = require('os');
11
+ const path = require('path');
12
+
13
+ function lockPath(home, campaignId) {
14
+ return path.join(home, 'campaigns', campaignId, 'run.lock');
15
+ }
16
+
17
+ function alive(pid) {
18
+ if (!Number.isInteger(pid) || pid <= 0) return false;
19
+ try {
20
+ process.kill(pid, 0);
21
+ return true;
22
+ } catch (error) {
23
+ return error.code === 'EPERM';
24
+ }
25
+ }
26
+
27
+ function readHolder(file) {
28
+ try {
29
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ // Returns { ok: true, release } or { ok: false, holder } when a live run holds it.
36
+ function acquireRunLock(home, campaignId, { pid = process.pid, host = os.hostname(), cwd = process.cwd(), isAlive = alive } = {}) {
37
+ const file = lockPath(home, campaignId);
38
+ fs.mkdirSync(path.dirname(file), { recursive: true });
39
+ const mine = { pid, host, cwd, started_at: new Date().toISOString() };
40
+ for (let attempt = 0; attempt < 2; attempt += 1) {
41
+ try {
42
+ fs.writeFileSync(file, `${JSON.stringify(mine)}\n`, { flag: 'wx' });
43
+ const release = () => {
44
+ const holder = readHolder(file);
45
+ if (holder && holder.pid === pid && holder.host === host) fs.rmSync(file, { force: true });
46
+ };
47
+ return { ok: true, release };
48
+ } catch (error) {
49
+ if (error.code !== 'EEXIST') throw error;
50
+ }
51
+ const holder = readHolder(file);
52
+ if (holder && holder.host === host && isAlive(holder.pid)) return { ok: false, holder };
53
+ if (holder && holder.host !== host) return { ok: false, holder };
54
+ fs.rmSync(file, { force: true });
55
+ }
56
+ return { ok: false, holder: readHolder(file) };
57
+ }
58
+
59
+ module.exports = { acquireRunLock, lockPath };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.71.0",
3
+ "version": "3.71.2",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof.",
5
5
  "main": "bin/atris.js",
6
6
  "bin": {