atris 3.56.3 → 3.57.0

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 [--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]';
44
+ const GMAIL_TRIAGE_USAGE = 'usage: atris gmail triage [--account <id>] [--limit N]';
43
45
  const GMAIL_VERDICTS_USAGE = 'usage: atris gmail verdicts [--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,7 +66,7 @@ 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 } : {}),
@@ -90,11 +102,28 @@ function printGmailVerdicts(options = {}) {
90
102
  }
91
103
  for (const row of rows) {
92
104
  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(', ')}` : ''}`);
105
+ console.log(`${row.ts} ${row.verdict || 'archive'} ${row.message_id} account ${row.account}${details.length ? `, ${details.join(', ')}` : ''}`);
94
106
  }
95
107
  return rows;
96
108
  }
97
109
 
110
+ function parseGmailTriageArgs(args = []) {
111
+ let accountId = null;
112
+ let limit = 25;
113
+ for (let i = 0; i < args.length; i += 1) {
114
+ const arg = args[i];
115
+ const value = String(args[i + 1] || '').trim();
116
+ if (arg === '--account' && value && !value.startsWith('--')) accountId = value;
117
+ else if (arg === '--limit' && /^\d+$/.test(value) && Number(value) > 0) limit = Number(value);
118
+ else {
119
+ console.error(GMAIL_TRIAGE_USAGE);
120
+ process.exit(1);
121
+ }
122
+ i += 1;
123
+ }
124
+ return { accountId, limit };
125
+ }
126
+
98
127
  function parseGmailVerdictsArgs(args = []) {
99
128
  let account = null;
100
129
  let limit = 20;
@@ -278,8 +307,10 @@ async function gmailInbox(options = {}) {
278
307
  const account = findGmailAccount(accounts, accountId);
279
308
  const mailboxEmail = account ? gmailAccountIdentity(account).email : '';
280
309
 
281
- if (mailboxEmail) console.log(`inbox for ${mailboxEmail} (${accountId.toLowerCase()})`);
282
- console.log('📬 Fetching inbox...\n');
310
+ if (!options.quiet) {
311
+ if (mailboxEmail) console.log(`inbox for ${mailboxEmail} (${accountId.toLowerCase()})`);
312
+ console.log('📬 Fetching inbox...\n');
313
+ }
283
314
 
284
315
  const path = `/integrations/gmail/messages?max_results=${limit}&account_id=${encodeURIComponent(accountId)}`;
285
316
 
@@ -299,13 +330,16 @@ async function gmailInbox(options = {}) {
299
330
  process.exit(1);
300
331
  }
301
332
 
302
- const messages = result.data?.messages || result.data || [];
333
+ const payload = result.data?.messages || result.data || [];
334
+ const messages = Array.isArray(payload) ? payload : [];
303
335
 
304
336
  if (messages.length === 0) {
305
- console.log('No messages found.');
306
- return;
337
+ if (!options.quiet) console.log('No messages found.');
338
+ return messages;
307
339
  }
308
340
 
341
+ if (options.quiet) return messages;
342
+
309
343
  console.log(`Found ${messages.length} messages:\n`);
310
344
  console.log('─'.repeat(60));
311
345
 
@@ -321,6 +355,69 @@ async function gmailInbox(options = {}) {
321
355
  console.log(`ID: ${id}`);
322
356
  console.log('─'.repeat(60));
323
357
  }
358
+
359
+ return messages;
360
+ }
361
+
362
+ function gmailMessageHeaderText(message = {}) {
363
+ const lines = [];
364
+ const headers = message.headers;
365
+ if (Array.isArray(headers)) {
366
+ for (const header of headers) {
367
+ lines.push(`${String(header?.name || '')}: ${String(header?.value || '')}`);
368
+ }
369
+ } else if (headers && typeof headers === 'object') {
370
+ for (const [name, value] of Object.entries(headers)) lines.push(`${name}: ${String(value || '')}`);
371
+ } else if (headers) {
372
+ lines.push(String(headers));
373
+ }
374
+
375
+ for (const name of ['list_id', 'list_unsubscribe', 'mailing_list', 'unsubscribe']) {
376
+ if (message[name]) lines.push(`${name}: ${String(message[name])}`);
377
+ }
378
+ return lines.join('\n').toLowerCase();
379
+ }
380
+
381
+ function gmailTriageVerdict(message = {}) {
382
+ const from = String(message.from || message.sender || '').toLowerCase();
383
+ if (/(?:^|[^a-z0-9])(?:no[-_.]?reply|do[-_.]?not[-_.]?reply)(?:[^a-z0-9]|$)/i.test(from)) {
384
+ return 'archive';
385
+ }
386
+
387
+ const domain = from.match(/@([a-z0-9.-]+)/i)?.[1] || '';
388
+ if (GMAIL_BULK_DOMAINS.some((bulkDomain) => domain === bulkDomain || domain.endsWith(`.${bulkDomain}`))) {
389
+ return 'archive';
390
+ }
391
+
392
+ const headers = gmailMessageHeaderText(message);
393
+ if (/(?:^|\n)(?:list-id|list-unsubscribe|mailing-list|x-campaign-id|x-mailing-list)\s*:/i.test(headers)
394
+ || /(?:^|\n)precedence\s*:\s*(?:bulk|junk|list)/i.test(headers)
395
+ || /\bunsubscribe\b/i.test(headers)) {
396
+ return 'archive';
397
+ }
398
+
399
+ return 'keep';
400
+ }
401
+
402
+ async function gmailTriage(options = {}) {
403
+ const accountId = resolveGmailAccountId(options.accountId);
404
+ const limit = Number.isInteger(options.limit) && options.limit > 0 ? options.limit : 25;
405
+ const messages = await gmailInbox({ accountId, limit, quiet: true });
406
+ const ts = new Date().toISOString();
407
+ const rows = messages.map((message) => ({
408
+ ts,
409
+ account: accountId,
410
+ verdict: gmailTriageVerdict(message),
411
+ message_id: String(message.id || message.message_id || ''),
412
+ from: String(message.from || message.sender || 'Unknown'),
413
+ subject: String(message.subject || '(no subject)'),
414
+ }));
415
+ appendGmailVerdicts(rows, { root: options.root, filePath: options.filePath });
416
+
417
+ const keepCount = rows.filter((row) => row.verdict === 'keep').length;
418
+ const archiveCount = rows.length - keepCount;
419
+ console.log(`gmail triage recorded ${keepCount} keep and ${archiveCount} archive verdict${rows.length === 1 ? '' : 's'}.`);
420
+ return rows;
324
421
  }
325
422
 
326
423
  async function gmailRead(messageId, options = {}) {
@@ -700,6 +797,11 @@ async function gmailCommand(subcommand, ...args) {
700
797
  await gmailArchive(parsed.positional, { accountId: parsed.accountId || undefined });
701
798
  break;
702
799
  }
800
+ case 'triage': {
801
+ const parsed = parseGmailTriageArgs(args);
802
+ await gmailTriage(parsed);
803
+ break;
804
+ }
703
805
  case 'verdicts': {
704
806
  const parsed = parseGmailVerdictsArgs(args);
705
807
  printGmailVerdicts(parsed);
@@ -729,7 +831,8 @@ async function gmailCommand(subcommand, ...args) {
729
831
  console.log(' atris gmail inbox [--account <id>] - list recent emails for a mailbox');
730
832
  console.log(' atris gmail read <id> [--account <id>] - read specific email');
731
833
  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');
834
+ console.log(' atris gmail triage [--account <id>] [--limit N] - record keep or archive verdicts without changing mail');
835
+ console.log(' atris gmail verdicts [--account <id>] [--limit N] - list recent gmail verdicts');
733
836
  console.log(' atris gmail send <to> <subject> <body...> [--body-file <path>] [--account <id>] - send an email');
734
837
  console.log(' atris gmail voice [account] [--clear] - edit or clear an account writing voice');
735
838
  console.log(' atris gmail connect [name] - connect or reconnect a gmail account');
@@ -2253,6 +2356,9 @@ module.exports = {
2253
2356
  appendGmailVerdicts,
2254
2357
  readGmailVerdicts,
2255
2358
  printGmailVerdicts,
2359
+ parseGmailTriageArgs,
2360
+ gmailTriageVerdict,
2361
+ gmailTriage,
2256
2362
  calendarCommand,
2257
2363
  twitterCommand,
2258
2364
  slackCommand,
@@ -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.0",
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": {