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.
- package/commands/integrations.js +168 -18
- package/commands/mission.js +9 -0
- package/commands/worktree.js +155 -11
- package/lib/conductor-artifacts.js +3 -1
- package/package.json +2 -1
- package/scripts/member-operate.mjs +133 -0
package/commands/integrations.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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)
|
|
92
|
+
if (!account || row.account === account) matches.push(row);
|
|
80
93
|
} catch {}
|
|
81
|
-
return
|
|
82
|
-
}, []).reverse()
|
|
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 = [
|
|
93
|
-
|
|
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 (
|
|
282
|
-
|
|
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
|
|
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
|
|
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,
|
package/commands/mission.js
CHANGED
|
@@ -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
|
package/commands/worktree.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
931
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
+
});
|