atris 3.56.3 → 3.57.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.
@@ -5,7 +5,8 @@
5
5
  * atris gmail inbox [--account <id>] - List recent emails for a mailbox
6
6
  * atris gmail read <id> [--account <id>] - Read specific email
7
7
  * atris gmail archive <id> [...] [--account <id>] - Archive messages
8
- * atris gmail verdicts [--account <id>] [--limit N] - List recent archive verdicts
8
+ * atris gmail triage [--account <id>] [--limit N] - Record keep or archive verdicts
9
+ * atris gmail verdicts [--summary] [--account <id>] [--limit N] - List recent Gmail verdicts
9
10
  * atris gmail send <to> <subject> <body...> [--body-file <path>] [--account <id>] - Send an email
10
11
  * atris gmail voice [account] [--clear] - Edit an account's writing voice
11
12
  * atris gmail connect [name] - Connect or reconnect a Gmail account
@@ -40,7 +41,18 @@ const GMAIL_CONNECT_POLL_MS = 3000;
40
41
  const GMAIL_CONNECT_TIMEOUT_MS = 3 * 60 * 1000;
41
42
  const GMAIL_SEND_USAGE = 'usage: atris gmail send <to> <subject> <body...> [--body-file <path>] [--account <id>]';
42
43
  const GMAIL_VOICE_USAGE = 'usage: atris gmail voice [account] [--clear]';
43
- const GMAIL_VERDICTS_USAGE = 'usage: atris gmail verdicts [--account <id>] [--limit N]';
44
+ const GMAIL_TRIAGE_USAGE = 'usage: atris gmail triage [--account <id>] [--limit N]';
45
+ const GMAIL_VERDICTS_USAGE = 'usage: atris gmail verdicts [--summary] [--account <id>] [--limit N]';
46
+ const GMAIL_BULK_DOMAINS = [
47
+ 'campaign-archive.com',
48
+ 'constantcontact.com',
49
+ 'hubspotemail.net',
50
+ 'mailchimp.com',
51
+ 'mailchimpapp.net',
52
+ 'mailgun.org',
53
+ 'sendgrid.net',
54
+ 'substack.com',
55
+ ];
44
56
 
45
57
  function gmailVerdictsPath(root = process.cwd()) {
46
58
  return path.join(root, '.atris', 'state', 'gmail-verdicts.jsonl');
@@ -54,10 +66,11 @@ function appendGmailVerdicts(verdicts, options = {}) {
54
66
  const lines = rows.map((row) => JSON.stringify({
55
67
  ts: row.ts || new Date().toISOString(),
56
68
  account: String(row.account || ''),
57
- verdict: 'archive',
69
+ verdict: row.verdict === 'keep' ? 'keep' : 'archive',
58
70
  message_id: String(row.message_id || ''),
59
71
  ...(row.from ? { from: row.from } : {}),
60
72
  ...(row.subject ? { subject: row.subject } : {}),
73
+ ...(typeof row.reason === 'string' && row.reason ? { reason: row.reason } : {}),
61
74
  }));
62
75
  fs.appendFileSync(filePath, `${lines.join('\n')}\n`, 'utf8');
63
76
  }
@@ -72,35 +85,89 @@ function readGmailVerdicts(options = {}) {
72
85
  throw error;
73
86
  }
74
87
  const account = String(options.account || '').trim();
75
- const limit = Number.isInteger(options.limit) ? options.limit : 20;
76
- return lines.reduce((rows, line) => {
88
+ const limit = options.limit === null ? null : (Number.isInteger(options.limit) ? options.limit : 20);
89
+ const rows = lines.reduce((matches, line) => {
77
90
  try {
78
91
  const row = JSON.parse(line);
79
- if (!account || row.account === account) rows.push(row);
92
+ if (!account || row.account === account) matches.push(row);
80
93
  } catch {}
81
- return rows;
82
- }, []).reverse().slice(0, limit);
94
+ return matches;
95
+ }, []).reverse();
96
+ return limit === null ? rows : rows.slice(0, limit);
97
+ }
98
+
99
+ function summarizeGmailVerdicts(rows = []) {
100
+ const groups = new Map();
101
+ for (const row of rows) {
102
+ const date = String(row.ts || '').slice(0, 10);
103
+ const account = String(row.account || '');
104
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) continue;
105
+ const key = `${date}\n${account}`;
106
+ const summary = groups.get(key) || { date, account, keep: 0, archive: 0 };
107
+ summary[row.verdict === 'keep' ? 'keep' : 'archive'] += 1;
108
+ groups.set(key, summary);
109
+ }
110
+ return [...groups.values()].sort((left, right) => (
111
+ right.date.localeCompare(left.date) || left.account.localeCompare(right.account)
112
+ ));
83
113
  }
84
114
 
85
115
  function printGmailVerdicts(options = {}) {
86
- const rows = readGmailVerdicts(options);
116
+ const rows = readGmailVerdicts(options.summary ? { ...options, limit: null } : options);
117
+ if (options.summary) {
118
+ const summaries = summarizeGmailVerdicts(rows);
119
+ if (!summaries.length) {
120
+ console.log('no gmail verdicts found.');
121
+ return summaries;
122
+ }
123
+ for (const row of summaries) {
124
+ console.log(`${row.date} account ${row.account}, keep ${row.keep}, archive ${row.archive}`);
125
+ }
126
+ return summaries;
127
+ }
87
128
  if (!rows.length) {
88
129
  console.log('no gmail verdicts found.');
89
130
  return rows;
90
131
  }
91
132
  for (const row of rows) {
92
- const details = [row.from && `from ${row.from}`, row.subject && `subject ${row.subject}`].filter(Boolean);
93
- console.log(`${row.ts} archive ${row.message_id} account ${row.account}${details.length ? `, ${details.join(', ')}` : ''}`);
133
+ const details = [
134
+ row.reason && `reason ${row.reason}`,
135
+ row.from && `from ${row.from}`,
136
+ row.subject && `subject ${row.subject}`,
137
+ ].filter(Boolean);
138
+ console.log(`${row.ts} ${row.verdict || 'archive'} ${row.message_id} account ${row.account}${details.length ? `, ${details.join(', ')}` : ''}`);
94
139
  }
95
140
  return rows;
96
141
  }
97
142
 
143
+ function parseGmailTriageArgs(args = []) {
144
+ let accountId = null;
145
+ let limit = 25;
146
+ for (let i = 0; i < args.length; i += 1) {
147
+ const arg = args[i];
148
+ const value = String(args[i + 1] || '').trim();
149
+ if (arg === '--account' && value && !value.startsWith('--')) accountId = value;
150
+ else if (arg === '--limit' && /^\d+$/.test(value) && Number(value) > 0) limit = Number(value);
151
+ else {
152
+ console.error(GMAIL_TRIAGE_USAGE);
153
+ process.exit(1);
154
+ }
155
+ i += 1;
156
+ }
157
+ return { accountId, limit };
158
+ }
159
+
98
160
  function parseGmailVerdictsArgs(args = []) {
99
161
  let account = null;
100
162
  let limit = 20;
163
+ let summary = false;
101
164
  for (let i = 0; i < args.length; i += 1) {
102
165
  const arg = args[i];
103
166
  const value = String(args[i + 1] || '').trim();
167
+ if (arg === '--summary') {
168
+ summary = true;
169
+ continue;
170
+ }
104
171
  if (arg === '--account' && value && !value.startsWith('--')) account = value;
105
172
  else if (arg === '--limit' && /^\d+$/.test(value) && Number(value) > 0) limit = Number(value);
106
173
  else {
@@ -109,7 +176,7 @@ function parseGmailVerdictsArgs(args = []) {
109
176
  }
110
177
  i += 1;
111
178
  }
112
- return { account, limit };
179
+ return { account, limit, summary };
113
180
  }
114
181
 
115
182
  function gmailAccountStatePath() {
@@ -278,8 +345,10 @@ async function gmailInbox(options = {}) {
278
345
  const account = findGmailAccount(accounts, accountId);
279
346
  const mailboxEmail = account ? gmailAccountIdentity(account).email : '';
280
347
 
281
- if (mailboxEmail) console.log(`inbox for ${mailboxEmail} (${accountId.toLowerCase()})`);
282
- console.log('📬 Fetching inbox...\n');
348
+ if (!options.quiet) {
349
+ if (mailboxEmail) console.log(`inbox for ${mailboxEmail} (${accountId.toLowerCase()})`);
350
+ console.log('📬 Fetching inbox...\n');
351
+ }
283
352
 
284
353
  const path = `/integrations/gmail/messages?max_results=${limit}&account_id=${encodeURIComponent(accountId)}`;
285
354
 
@@ -299,13 +368,16 @@ async function gmailInbox(options = {}) {
299
368
  process.exit(1);
300
369
  }
301
370
 
302
- const messages = result.data?.messages || result.data || [];
371
+ const payload = result.data?.messages || result.data || [];
372
+ const messages = Array.isArray(payload) ? payload : [];
303
373
 
304
374
  if (messages.length === 0) {
305
- console.log('No messages found.');
306
- return;
375
+ if (!options.quiet) console.log('No messages found.');
376
+ return messages;
307
377
  }
308
378
 
379
+ if (options.quiet) return messages;
380
+
309
381
  console.log(`Found ${messages.length} messages:\n`);
310
382
  console.log('─'.repeat(60));
311
383
 
@@ -321,6 +393,75 @@ async function gmailInbox(options = {}) {
321
393
  console.log(`ID: ${id}`);
322
394
  console.log('─'.repeat(60));
323
395
  }
396
+
397
+ return messages;
398
+ }
399
+
400
+ function gmailMessageHeaderText(message = {}) {
401
+ const lines = [];
402
+ const headers = message.headers;
403
+ if (Array.isArray(headers)) {
404
+ for (const header of headers) {
405
+ lines.push(`${String(header?.name || '')}: ${String(header?.value || '')}`);
406
+ }
407
+ } else if (headers && typeof headers === 'object') {
408
+ for (const [name, value] of Object.entries(headers)) lines.push(`${name}: ${String(value || '')}`);
409
+ } else if (headers) {
410
+ lines.push(String(headers));
411
+ }
412
+
413
+ for (const name of ['list_id', 'list_unsubscribe', 'mailing_list', 'unsubscribe']) {
414
+ if (message[name]) lines.push(`${name}: ${String(message[name])}`);
415
+ }
416
+ return lines.join('\n').toLowerCase();
417
+ }
418
+
419
+ function gmailTriageVerdict(message = {}, options = {}) {
420
+ const from = String(message.from || message.sender || '').toLowerCase();
421
+ let result;
422
+ if (/(?:^|[^a-z0-9])(?:no[-_.]?reply|do[-_.]?not[-_.]?reply)(?:[^a-z0-9]|$)/i.test(from)) {
423
+ result = { verdict: 'archive', reason: 'noreply sender' };
424
+ }
425
+
426
+ const domain = from.match(/@([a-z0-9.-]+)/i)?.[1] || '';
427
+ if (!result && GMAIL_BULK_DOMAINS.some((bulkDomain) => domain === bulkDomain || domain.endsWith(`.${bulkDomain}`))) {
428
+ result = { verdict: 'archive', reason: 'bulk mail domain' };
429
+ }
430
+
431
+ const headers = gmailMessageHeaderText(message);
432
+ if (!result && (/(?:^|\n)(?:list-id|list-unsubscribe|mailing-list|x-campaign-id|x-mailing-list)\s*:/i.test(headers)
433
+ || /(?:^|\n)precedence\s*:\s*(?:bulk|junk|list)/i.test(headers)
434
+ || /\bunsubscribe\b/i.test(headers))) {
435
+ result = { verdict: 'archive', reason: 'list or unsubscribe headers' };
436
+ }
437
+
438
+ if (!result) result = { verdict: 'keep', reason: 'personal sender' };
439
+ return options.details ? result : result.verdict;
440
+ }
441
+
442
+ async function gmailTriage(options = {}) {
443
+ const accountId = resolveGmailAccountId(options.accountId);
444
+ const limit = Number.isInteger(options.limit) && options.limit > 0 ? options.limit : 25;
445
+ const messages = await gmailInbox({ accountId, limit, quiet: true });
446
+ const ts = new Date().toISOString();
447
+ const rows = messages.map((message) => {
448
+ const { verdict, reason } = gmailTriageVerdict(message, { details: true });
449
+ return {
450
+ ts,
451
+ account: accountId,
452
+ verdict,
453
+ reason,
454
+ message_id: String(message.id || message.message_id || ''),
455
+ from: String(message.from || message.sender || 'Unknown'),
456
+ subject: String(message.subject || '(no subject)'),
457
+ };
458
+ });
459
+ appendGmailVerdicts(rows, { root: options.root, filePath: options.filePath });
460
+
461
+ const keepCount = rows.filter((row) => row.verdict === 'keep').length;
462
+ const archiveCount = rows.length - keepCount;
463
+ console.log(`gmail triage recorded ${keepCount} keep and ${archiveCount} archive verdict${rows.length === 1 ? '' : 's'}.`);
464
+ return rows;
324
465
  }
325
466
 
326
467
  async function gmailRead(messageId, options = {}) {
@@ -700,6 +841,11 @@ async function gmailCommand(subcommand, ...args) {
700
841
  await gmailArchive(parsed.positional, { accountId: parsed.accountId || undefined });
701
842
  break;
702
843
  }
844
+ case 'triage': {
845
+ const parsed = parseGmailTriageArgs(args);
846
+ await gmailTriage(parsed);
847
+ break;
848
+ }
703
849
  case 'verdicts': {
704
850
  const parsed = parseGmailVerdictsArgs(args);
705
851
  printGmailVerdicts(parsed);
@@ -729,7 +875,8 @@ async function gmailCommand(subcommand, ...args) {
729
875
  console.log(' atris gmail inbox [--account <id>] - list recent emails for a mailbox');
730
876
  console.log(' atris gmail read <id> [--account <id>] - read specific email');
731
877
  console.log(' atris gmail archive <id> [...] [--account <id>] - archive messages (reversible, all mail keeps them)');
732
- console.log(' atris gmail verdicts [--account <id>] [--limit N] - list recent archive verdicts');
878
+ console.log(' atris gmail triage [--account <id>] [--limit N] - record keep or archive verdicts without changing mail');
879
+ console.log(' atris gmail verdicts [--summary] [--account <id>] [--limit N] - list recent gmail verdicts');
733
880
  console.log(' atris gmail send <to> <subject> <body...> [--body-file <path>] [--account <id>] - send an email');
734
881
  console.log(' atris gmail voice [account] [--clear] - edit or clear an account writing voice');
735
882
  console.log(' atris gmail connect [name] - connect or reconnect a gmail account');
@@ -2253,6 +2400,9 @@ module.exports = {
2253
2400
  appendGmailVerdicts,
2254
2401
  readGmailVerdicts,
2255
2402
  printGmailVerdicts,
2403
+ parseGmailTriageArgs,
2404
+ gmailTriageVerdict,
2405
+ gmailTriage,
2256
2406
  calendarCommand,
2257
2407
  twitterCommand,
2258
2408
  slackCommand,
@@ -9934,6 +9934,15 @@ async function executeMissionRunTicksPhase(context) {
9934
9934
  mission = resolveMission(mission.id, cwd) || mission;
9935
9935
  const remainingBudgetSeconds = missionFullBudgetRemainingSeconds(mission);
9936
9936
  const explicitExit = ['complete', 'stopped', 'paused'].includes(String(mission.status || ''));
9937
+ if (!pauseReason
9938
+ && detachedDriverLifecycle
9939
+ && mission.always_on
9940
+ && missionSpendsFullBudget(mission)
9941
+ && remainingBudgetSeconds <= 0
9942
+ && !explicitExit
9943
+ && !controller.signal.aborted) {
9944
+ pauseReason = 'budget-exhausted';
9945
+ }
9937
9946
  const healthyCycleBoundary = !pauseReason || pauseReason === 'max-ticks-reached';
9938
9947
  const keepDetachedFullBudgetDriverAlive = Boolean(
9939
9948
  detachedDriverLifecycle
@@ -358,11 +358,14 @@ function statusCounts(root, {
358
358
  ignoredUnstagedFiles = new Set(),
359
359
  ignoredUntrackedFiles = new Set(),
360
360
  ignoreUntracked = null,
361
+ ignoreAny = null,
361
362
  } = {}) {
362
363
  if (!fs.existsSync(root)) return null;
363
364
  // -uall expands untracked directories to individual files so ignoredUntrackedFiles
364
365
  // and ignoreUntracked can match exact paths (plain porcelain collapses them to "?? dir/").
365
- const expandUntracked = ignoredUntrackedFiles.size || typeof ignoreUntracked === 'function';
366
+ const expandUntracked = ignoredUntrackedFiles.size
367
+ || typeof ignoreUntracked === 'function'
368
+ || typeof ignoreAny === 'function';
366
369
  const statusArgs = expandUntracked ? ['status', '--porcelain', '-uall'] : ['status', '--porcelain'];
367
370
  const result = runGit(statusArgs, { cwd: root, check: false });
368
371
  if (result.status !== 0) return null;
@@ -371,6 +374,7 @@ function statusCounts(root, {
371
374
  let untracked = 0;
372
375
  for (const line of result.stdout.split(/\r?\n/).filter(Boolean)) {
373
376
  const file = line.slice(3);
377
+ if (typeof ignoreAny === 'function' && ignoreAny(file)) continue;
374
378
  if (ignoredUnstagedFiles.has(file) && line[0] === ' ' && line[1] !== ' ') continue;
375
379
  if (line.startsWith('??')) {
376
380
  if (ignoredUntrackedFiles.has(file)) continue;
@@ -457,6 +461,16 @@ function printStatus() {
457
461
  function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task, branch: branchOverride, path: pathOverride, base: baseOverride, now = new Date() } = {}) {
458
462
  const owner = member || agent;
459
463
  if (!owner || !task) throw new Error('createAgentWorktree: owner (member/agent) and task required');
464
+ // Every new flight pays down completed checkout debt before adding another
465
+ // full repo copy. Branches and commits survive worktree removal, while real
466
+ // uncommitted changes remain protected by cleanupWorktrees.
467
+ let reapedBeforeStart = [];
468
+ let cachePrunedBeforeStart = [];
469
+ try {
470
+ const cleaned = cleanupWorktrees({ root, base: defaultMainlineBase(root), apply: true });
471
+ reapedBeforeStart = cleaned.removed;
472
+ cachePrunedBeforeStart = cleaned.cachePruned;
473
+ } catch {}
460
474
  const branch = branchOverride || branchName(owner, task, now);
461
475
  const target = path.resolve(pathOverride || defaultWorktreePath(root, owner, task, now));
462
476
  const explicitBase = Boolean(baseOverride);
@@ -489,7 +503,7 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
489
503
  }, null, 2) + '\n',
490
504
  'utf8'
491
505
  );
492
- return { path: target, branch, base: shipBase, checkoutBase, owner };
506
+ return { path: target, branch, base: shipBase, checkoutBase, owner, reapedBeforeStart, cachePrunedBeforeStart };
493
507
  }
494
508
 
495
509
  function startWorktree(args) {
@@ -543,6 +557,13 @@ function startWorktree(args) {
543
557
  }
544
558
  const { path: target, branch, base } = created;
545
559
 
560
+ if (created.reapedBeforeStart.length) {
561
+ console.log(`cleanup: reaped ${created.reapedBeforeStart.length} completed ${created.reapedBeforeStart.length === 1 ? 'worktree' : 'worktrees'}`);
562
+ }
563
+ if (created.cachePrunedBeforeStart.length) {
564
+ console.log(`cleanup: pruned ${created.cachePrunedBeforeStart.length} generated ${created.cachePrunedBeforeStart.length === 1 ? 'cache' : 'caches'}`);
565
+ }
566
+
546
567
  const counts = statusCounts(root);
547
568
  if (counts && (counts.staged || counts.unstaged || counts.untracked)) {
548
569
  console.log(`note: primary checkout is dirty staged=${counts.staged} unstaged=${counts.unstaged} untracked=${counts.untracked}`);
@@ -889,6 +910,14 @@ const PROTECTED_BRANCHES = new Set(['main', 'master']);
889
910
  // (zero commits yet), so without a grace window the janitor reaps it while
890
911
  // the engine that requested it is still booting inside.
891
912
  const WORKTREE_REAP_GRACE_MS = 60 * 60 * 1000;
913
+ const COMPLETED_UNMERGED_REAP_MS = WORKTREE_REAP_GRACE_MS;
914
+ const GENERATED_WORKTREE_CACHE_DIRS = [
915
+ '.derivedData',
916
+ '.next',
917
+ '.swiftpm',
918
+ '.cache/swiftpm',
919
+ 'node_modules/.cache',
920
+ ];
892
921
 
893
922
  function worktreeWithinReapGrace(worktreePath, now = Date.now()) {
894
923
  try {
@@ -898,15 +927,84 @@ function worktreeWithinReapGrace(worktreePath, now = Date.now()) {
898
927
  }
899
928
  }
900
929
 
901
- function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply = false } = {}) {
930
+ function completedAgentOutputAgeMs(worktreePath, now = Date.now()) {
931
+ try {
932
+ const output = path.join(worktreePath, '.codex-last-message.txt');
933
+ return now - fs.statSync(output).mtimeMs;
934
+ } catch {
935
+ return null;
936
+ }
937
+ }
938
+
939
+ function canonicalPath(value) {
940
+ try {
941
+ return fs.realpathSync(value);
942
+ } catch {
943
+ return path.resolve(value);
944
+ }
945
+ }
946
+
947
+ function listActiveProcessCwds() {
948
+ const result = spawnSync('lsof', ['-n', '-a', '-d', 'cwd', '-F', 'n'], {
949
+ encoding: 'utf8',
950
+ maxBuffer: COMMAND_MAX_BUFFER_BYTES,
951
+ });
952
+ if (result.status !== 0) return [];
953
+ return String(result.stdout || '')
954
+ .split(/\r?\n/)
955
+ .filter((line) => line.startsWith('n'))
956
+ .map((line) => canonicalPath(line.slice(1)))
957
+ .filter(Boolean);
958
+ }
959
+
960
+ function worktreeHasActiveProcess(worktreePath, activeCwds) {
961
+ const resolved = canonicalPath(worktreePath);
962
+ const prefix = `${resolved}${path.sep}`;
963
+ return activeCwds.some((cwd) => {
964
+ const active = canonicalPath(cwd);
965
+ return active === resolved || active.startsWith(prefix);
966
+ });
967
+ }
968
+
969
+ function pruneGeneratedWorktreeCaches(worktreePath, { apply = false } = {}) {
970
+ const candidates = [];
971
+ const pruned = [];
972
+ for (const relative of GENERATED_WORKTREE_CACHE_DIRS) {
973
+ const target = path.join(worktreePath, relative);
974
+ if (!fs.existsSync(target)) continue;
975
+ const ignored = runGit(['check-ignore', '-q', '--', relative], { cwd: worktreePath, check: false });
976
+ if (ignored.status !== 0) continue;
977
+ const item = { worktree: worktreePath, path: target, relative };
978
+ candidates.push(item);
979
+ if (!apply) continue;
980
+ try {
981
+ fs.rmSync(target, { recursive: true, force: true });
982
+ pruned.push(item);
983
+ } catch (error) {
984
+ item.error = String((error && error.message) || error);
985
+ }
986
+ }
987
+ return { candidates, pruned };
988
+ }
989
+
990
+ function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply = false, activeCwds: activeCwdsOverride } = {}) {
902
991
  const worktrees = listWorktrees(root);
903
992
  const primary = worktrees[0]?.path ? path.resolve(worktrees[0].path) : '';
904
993
  const current = path.resolve(root);
994
+ const activeCwds = Array.isArray(activeCwdsOverride) ? activeCwdsOverride.map(canonicalPath) : listActiveProcessCwds();
905
995
  const base = normalizeTargetRef(root, baseOverride || defaultShipTarget(root));
906
996
  refreshRemoteRef(root, base);
907
997
  const candidates = [];
908
998
  const kept = [];
909
999
  const removed = [];
1000
+ const cacheCandidates = [];
1001
+ const cachePruned = [];
1002
+ const pruneCachesForKept = (wtPath) => {
1003
+ if (worktreeWithinReapGrace(wtPath)) return;
1004
+ const caches = pruneGeneratedWorktreeCaches(wtPath, { apply });
1005
+ cacheCandidates.push(...caches.candidates);
1006
+ cachePruned.push(...caches.pruned);
1007
+ };
910
1008
 
911
1009
  for (const wt of worktrees) {
912
1010
  const wtPath = path.resolve(wt.path);
@@ -927,32 +1025,72 @@ function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply =
927
1025
  kept.push({ ...item, reason: 'protected_branch' });
928
1026
  continue;
929
1027
  }
930
- const counts = statusCounts(wt.path);
931
- if (!counts) {
1028
+ if (worktreeHasActiveProcess(wtPath, activeCwds)) {
1029
+ kept.push({ ...item, reason: 'active_process' });
1030
+ continue;
1031
+ }
1032
+ const rawCounts = statusCounts(wt.path);
1033
+ const counts = statusCounts(wt.path, { ignoreAny: isConductorArtifact });
1034
+ if (!rawCounts || !counts) {
932
1035
  kept.push({ ...item, reason: 'missing_or_unreadable' });
933
1036
  continue;
934
1037
  }
935
1038
  if (counts.staged || counts.unstaged || counts.untracked) {
1039
+ pruneCachesForKept(wtPath);
936
1040
  kept.push({ ...item, reason: 'dirty', staged: counts.staged, unstaged: counts.unstaged, untracked: counts.untracked });
937
1041
  continue;
938
1042
  }
939
1043
  if (!wt.head) {
1044
+ pruneCachesForKept(wtPath);
940
1045
  kept.push({ ...item, reason: 'missing_head' });
941
1046
  continue;
942
1047
  }
1048
+ const artifactOnlyDirty = Boolean(
1049
+ (rawCounts.staged || rawCounts.unstaged || rawCounts.untracked)
1050
+ && !(counts.staged || counts.unstaged || counts.untracked)
1051
+ );
943
1052
  const merged = runGit(['merge-base', '--is-ancestor', wt.head, base], { cwd: root, check: false }).status === 0;
944
1053
  if (!merged) {
945
- kept.push({ ...item, reason: 'unmerged' });
1054
+ const completedAgeMs = completedAgentOutputAgeMs(wtPath);
1055
+ if (completedAgeMs === null || completedAgeMs < COMPLETED_UNMERGED_REAP_MS) {
1056
+ pruneCachesForKept(wtPath);
1057
+ kept.push({
1058
+ ...item,
1059
+ reason: completedAgeMs === null ? 'unmerged' : 'completed_unmerged_retention',
1060
+ ...(completedAgeMs === null ? {} : { retention_hours: 1 }),
1061
+ });
1062
+ continue;
1063
+ }
1064
+ const candidate = {
1065
+ ...item,
1066
+ reason: 'completed_unmerged_checkout_expired',
1067
+ branch_preserved: true,
1068
+ artifact_only_dirty: artifactOnlyDirty,
1069
+ };
1070
+ candidates.push(candidate);
1071
+ if (!apply) continue;
1072
+ const removeArgs = ['worktree', 'remove'];
1073
+ if (artifactOnlyDirty) removeArgs.push('--force');
1074
+ removeArgs.push(wt.path);
1075
+ const removedResult = runGit(removeArgs, { cwd: root, check: false });
1076
+ if (removedResult.status === 0) {
1077
+ removed.push(candidate);
1078
+ } else {
1079
+ kept.push({ ...item, reason: 'remove_failed', error: (removedResult.stderr || removedResult.stdout || '').trim() });
1080
+ }
946
1081
  continue;
947
1082
  }
948
1083
  if (worktreeWithinReapGrace(wtPath)) {
949
1084
  kept.push({ ...item, reason: 'fresh_worktree_grace' });
950
1085
  continue;
951
1086
  }
952
- const candidate = { ...item, reason: 'merged_into_base' };
1087
+ const candidate = { ...item, reason: 'merged_into_base', artifact_only_dirty: artifactOnlyDirty };
953
1088
  candidates.push(candidate);
954
1089
  if (!apply) continue;
955
- const removedResult = runGit(['worktree', 'remove', wt.path], { cwd: root, check: false });
1090
+ const removeArgs = ['worktree', 'remove'];
1091
+ if (artifactOnlyDirty) removeArgs.push('--force');
1092
+ removeArgs.push(wt.path);
1093
+ const removedResult = runGit(removeArgs, { cwd: root, check: false });
956
1094
  if (removedResult.status === 0) {
957
1095
  removed.push(candidate);
958
1096
  } else {
@@ -960,7 +1098,7 @@ function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply =
960
1098
  }
961
1099
  }
962
1100
 
963
- return { apply, base, candidates, removed, kept };
1101
+ return { apply, base, candidates, removed, kept, cacheCandidates, cachePruned };
964
1102
  }
965
1103
 
966
1104
  function cleanup(args) {
@@ -980,6 +1118,11 @@ function cleanup(args) {
980
1118
  for (const item of result.apply ? result.removed : result.candidates) {
981
1119
  console.log(`${action}: ${item.path} branch=${item.branch} reason=${item.reason}`);
982
1120
  }
1121
+ const cacheAction = result.apply ? 'cache_pruned' : 'cache_candidate';
1122
+ console.log(`${cacheAction}s: ${result.apply ? result.cachePruned.length : result.cacheCandidates.length}`);
1123
+ for (const item of result.apply ? result.cachePruned : result.cacheCandidates) {
1124
+ console.log(`${cacheAction}: ${item.path}`);
1125
+ }
983
1126
  console.log(`kept: ${result.kept.length}`);
984
1127
  if (!result.apply && result.candidates.length) console.log('next: atris worktree cleanup --apply');
985
1128
  return 0;
@@ -1010,11 +1153,12 @@ function guide() {
1010
1153
  console.log(' --target <ref> overrides the default landing target (default: branch atris-base, else origin default branch)');
1011
1154
  console.log(' recommended verify: npm run test:fast && node --test <focused files>');
1012
1155
  console.log('');
1013
- console.log('5. Clean merged worktrees:');
1156
+ console.log('5. Cleanup is part of creation and the janitor; run it directly any time:');
1014
1157
  console.log(' atris worktree cleanup');
1015
1158
  console.log(' atris worktree cleanup --apply');
1016
1159
  console.log('');
1017
1160
  console.log('Notes: start uses the current upstream/default remote base, not dirty local HEAD.');
1161
+ console.log('Start reaps completed checkouts first. Cleanup preserves branches and source changes, skips live work, and prunes only ignored build caches.');
1018
1162
  console.log('Use `atris worktree status` to see all worktrees and dirty counts.');
1019
1163
  return 0;
1020
1164
  }
@@ -1032,7 +1176,7 @@ function help() {
1032
1176
  console.log(' atris worktree status');
1033
1177
  console.log(' atris worktree guard [--allow-primary] [--allow-dirty]');
1034
1178
  console.log(' atris worktree prune [--apply]');
1035
- console.log(' atris worktree cleanup [--apply] [--json] [--base origin/master]');
1179
+ console.log(' atris worktree cleanup [--apply] [--json] [--base origin/master] reap completed checkouts and ignored build caches');
1036
1180
  }
1037
1181
 
1038
1182
  function worktreeCommand(args = []) {
@@ -7,9 +7,11 @@
7
7
  // by its own prompt file.
8
8
  const CONDUCTOR_UNTRACKED_PATTERN =
9
9
  /^\.atris\/(?:agent-worktree\.json|fleet-prompt-[^/]+\.md|codex-watchdog\.js|codex-watchdog-[^/]+\.json|runtime-tmp(?:\/.*)?|state\/briefs\.jsonl)$/;
10
+ const CONDUCTOR_ROOT_OUTPUT_PATTERN = /^\.codex-last-message\.txt$/;
10
11
 
11
12
  function isConductorArtifact(file) {
12
- return CONDUCTOR_UNTRACKED_PATTERN.test(String(file || ''));
13
+ const value = String(file || '');
14
+ return CONDUCTOR_UNTRACKED_PATTERN.test(value) || CONDUCTOR_ROOT_OUTPUT_PATTERN.test(value);
13
15
  }
14
16
 
15
17
  // Accepts a `git status --porcelain` line ("?? .atris/fleet-prompt-BCK-1.md").
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.56.3",
3
+ "version": "3.57.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": {
@@ -14,6 +14,7 @@
14
14
  "commands/",
15
15
  "decks/",
16
16
  "scripts/outbound-artifact-gate.js",
17
+ "scripts/member-operate.mjs",
17
18
  "scripts/agent_worktree.py",
18
19
  "scripts/det/",
19
20
  "utils/",
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env node
2
+ // member-operate: the one executed slice of a member alive tick.
3
+ //
4
+ // Contract (lib/member-alive.js runMemberOperateScript):
5
+ // node scripts/member-operate.mjs <member> --json --max-wall <60..1800>
6
+ // --execute --confirm-autonomy-policy [--agent claude] [--model X] [--no-prime]
7
+ // stdout: final JSON only (last line wins). stderr: live progress, prefixed
8
+ // with ATRIS_MEMBER_OPERATE_PROGRESS\t so the alive loop can stream phases.
9
+ //
10
+ // This script is a dispatcher, not an engine. It delegates to
11
+ // `atris member run <member>`, which resumes the member's active mission or
12
+ // chooses one bounded useful task, and the mission itself carries its runner
13
+ // (codex_goal, claude, atris2, ...). Swapping engines is mission/member
14
+ // config, never an edit here.
15
+
16
+ import { spawn } from 'node:child_process';
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import process from 'node:process';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ const PROGRESS_PREFIX = 'ATRIS_MEMBER_OPERATE_PROGRESS\t';
23
+
24
+ function progress(payload) {
25
+ process.stderr.write(`${PROGRESS_PREFIX}${JSON.stringify(payload)}\n`);
26
+ }
27
+
28
+ function readFlag(args, name, fallback = '') {
29
+ const index = args.indexOf(name);
30
+ if (index === -1 || index === args.length - 1) return fallback;
31
+ return args[index + 1];
32
+ }
33
+
34
+ function finish(result) {
35
+ process.stdout.write(`${JSON.stringify(result)}\n`);
36
+ process.exit(result.ok === false ? 1 : 0);
37
+ }
38
+
39
+ function collectJsonSignals(text) {
40
+ // member run / mission run print JSON payloads along the way; harvest the
41
+ // fields the alive loop cares about without depending on exact shapes.
42
+ const signals = { receipt_path: null, needs_user: false, summary: null };
43
+ for (const line of String(text || '').split(/\r?\n/)) {
44
+ const trimmed = line.trim();
45
+ if (!trimmed.startsWith('{')) continue;
46
+ try {
47
+ const parsed = JSON.parse(trimmed);
48
+ if (parsed.receipt_path) signals.receipt_path = parsed.receipt_path;
49
+ if (parsed.mission?.receipt_path) signals.receipt_path = parsed.mission.receipt_path;
50
+ if (parsed.needs_user === true) signals.needs_user = true;
51
+ const landing = parsed.result?.landing || parsed.landing || null;
52
+ if (landing?.changed) signals.summary = landing.changed;
53
+ else if (parsed.summary) signals.summary = parsed.summary;
54
+ } catch {
55
+ // Partial or non-JSON line; keep streaming.
56
+ }
57
+ }
58
+ return signals;
59
+ }
60
+
61
+ const argv = process.argv.slice(2);
62
+ const member = argv[0] && !argv[0].startsWith('--') ? argv[0] : '';
63
+ const args = argv.slice(1);
64
+
65
+ if (!member) {
66
+ finish({ ok: false, reason: 'member_required', executed: false });
67
+ }
68
+
69
+ const execute = args.includes('--execute');
70
+ const confirmed = args.includes('--confirm-autonomy-policy');
71
+ if (!execute || !confirmed) {
72
+ finish({ ok: true, reason: 'operate_dry_run', member, executed: false });
73
+ }
74
+
75
+ const maxWall = Math.max(60, Math.min(1800, Number(readFlag(args, '--max-wall', '900')) || 900));
76
+ const agent = String(readFlag(args, '--agent', '')).trim().toLowerCase();
77
+ const model = String(readFlag(args, '--model', '')).trim();
78
+
79
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
80
+ const atrisBin = process.env.ATRIS_BIN || path.join(scriptDir, '..', 'bin', 'atris.js');
81
+ if (!fs.existsSync(atrisBin)) {
82
+ finish({ ok: false, reason: 'atris_bin_not_found', member, executed: false });
83
+ }
84
+
85
+ const runArgs = [atrisBin, 'member', 'run', member, '--json', '--max-wall', String(maxWall)];
86
+ if (agent === 'claude') runArgs.push('--runner', 'claude');
87
+ if (model) runArgs.push('--model', model);
88
+
89
+ progress({ kind: 'phase', text: `Dispatching one bounded ${member} slice (wall cap ${maxWall}s).` });
90
+
91
+ const child = spawn(process.execPath, runArgs, {
92
+ cwd: process.cwd(),
93
+ env: process.env,
94
+ stdio: ['ignore', 'pipe', 'pipe'],
95
+ });
96
+
97
+ let output = '';
98
+ const forward = (chunk) => {
99
+ const text = chunk.toString();
100
+ output += text;
101
+ // Keep stdout clean for the final JSON; the human-visible stream is stderr.
102
+ process.stderr.write(text);
103
+ };
104
+ child.stdout.on('data', forward);
105
+ child.stderr.on('data', forward);
106
+
107
+ const wallTimer = setTimeout(() => {
108
+ progress({ kind: 'phase', text: `Wall cap ${maxWall}s reached; stopping the slice.` });
109
+ child.kill('SIGTERM');
110
+ setTimeout(() => child.kill('SIGKILL'), 10000).unref();
111
+ }, maxWall * 1000);
112
+
113
+ child.on('close', (code) => {
114
+ clearTimeout(wallTimer);
115
+ const signals = collectJsonSignals(output);
116
+ const ok = code === 0;
117
+ finish({
118
+ ok,
119
+ reason: ok ? 'operate_complete' : 'operate_failed',
120
+ member,
121
+ executed: true,
122
+ exit_code: code,
123
+ max_wall_seconds: maxWall,
124
+ needs_user: signals.needs_user,
125
+ receipt_path: signals.receipt_path,
126
+ summary: signals.summary,
127
+ });
128
+ });
129
+
130
+ child.on('error', (error) => {
131
+ clearTimeout(wallTimer);
132
+ finish({ ok: false, reason: `operate_spawn_failed: ${error.message}`, member, executed: true });
133
+ });