atris 3.71.0 → 3.71.1

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
@@ -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;
@@ -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 = [];
@@ -639,6 +639,25 @@ const cmdEvent = (args, ctx) => {
639
639
  async function cmdLoop(args, ctx, verb) {
640
640
  const { view, error } = requireCampaign(ctx, positionals(args)[1]);
641
641
  if (error !== undefined) return error;
642
+ // One driver per campaign: a second loop would plan and start missions
643
+ // against the same budget and files (lib/campaign-run-lock.js).
644
+ let lock = null;
645
+ if (ctx.store.path && ctx.campaignHome) {
646
+ lock = require('../lib/campaign-run-lock').acquireRunLock(ctx.campaignHome, view.id);
647
+ if (!lock.ok) {
648
+ const holder = lock.holder || {};
649
+ 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);
650
+ }
651
+ process.once('exit', lock.release);
652
+ }
653
+ try {
654
+ return await driveLoop(args, ctx, verb, view);
655
+ } finally {
656
+ if (lock) lock.release();
657
+ }
658
+ }
659
+
660
+ async function driveLoop(args, ctx, verb, view) {
642
661
  const { makeLoop } = require('../lib/campaign-loop');
643
662
  // A synced campaign trades events around every step. Failures warn and the
644
663
  // loop runs on offline; a refusal (403, 409) stops syncing for this run
@@ -666,6 +685,7 @@ async function cmdLoop(args, ctx, verb) {
666
685
  const loop = makeLoop(process.cwd(), {
667
686
  parallel: readFlag(args, '--parallel', '') || readFlag(args, '--count', ''),
668
687
  maxTicks: readFlag(args, '--max-ticks', ''),
688
+ missionMinutes: readFlag(args, '--mission-minutes', ''),
669
689
  runner: readFlag(args, '--runner', ''),
670
690
  actor: ctx.actor,
671
691
  }, {
@@ -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');
@@ -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
  }
@@ -105,6 +105,9 @@ function makeLoop(root, options = {}, deps = {}) {
105
105
  const log = deps.log || ((line) => console.log(line));
106
106
  const parallel = Math.max(1, Number(options.parallel) || 3);
107
107
  const maxTicks = Math.max(1, Number(options.maxTicks) || 3);
108
+ // Every mission gets an explicit time limit. Without one, a number in the
109
+ // objective ("437 tests, 475s") was read as the mission's time budget.
110
+ const missionMinutes = Math.max(1, Number(options.missionMinutes) || 40);
108
111
  const runner = options.runner || 'auto';
109
112
  const actor = options.actor || 'mission-lead';
110
113
  const ask = deps.ask || askOwner;
@@ -155,7 +158,8 @@ function makeLoop(root, options = {}, deps = {}) {
155
158
  const reserve = work.estimate_usd || defaultReserve(current);
156
159
  const started = await cli([
157
160
  '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',
161
+ '--runner', runner, '--verify', work.verify || current.judge, '--campaign', id, '--reserve', String(reserve),
162
+ '--max-wall', String(missionMinutes * 60), '--json',
159
163
  ]);
160
164
  if (started.code !== 0) {
161
165
  const why = (started.out + started.err).trim().split('\n').pop();
@@ -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.1",
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": {