atris 3.80.1 → 3.82.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +11 -1
  3. package/atris/atris.md +14 -0
  4. package/atris/features/README.md +15 -0
  5. package/atris/policies/README.md +24 -0
  6. package/atris/skills/README.md +61 -45
  7. package/atris/skills/design/SKILL.md +6 -5
  8. package/atris/skills/taste/SKILL.md +39 -0
  9. package/atris/skills/taste/notes.md +29 -0
  10. package/atris/skills/taste/principles.md +23 -0
  11. package/atris/skills/wiki/SKILL.md +14 -2
  12. package/atris/skills/wiki/index-lines.md +30 -0
  13. package/atris/skills/wiki/measure.md +20 -0
  14. package/atris/skills/wiki/note-shape.md +16 -0
  15. package/atris/skills/wiki/upkeep.md +16 -0
  16. package/atris/team/README.md +38 -0
  17. package/atris.md +14 -0
  18. package/bin/atris.js +1 -1
  19. package/commands/activate.js +1 -1
  20. package/commands/autoland.js +25 -26
  21. package/commands/autopilot-front.js +46 -1
  22. package/commands/autopilot.js +55 -25
  23. package/commands/clean.js +22 -11
  24. package/commands/computer.js +1 -1
  25. package/commands/doc-health.js +48 -1
  26. package/commands/improve.js +16 -3
  27. package/commands/init.js +4 -0
  28. package/commands/integrations.js +69 -1616
  29. package/commands/mission.js +112 -14
  30. package/commands/release.js +41 -10
  31. package/commands/status.js +1 -1
  32. package/commands/sync.js +12 -8
  33. package/commands/task.js +150 -137
  34. package/commands/unknowns.js +9 -5
  35. package/commands/visualize.js +1 -12
  36. package/commands/workflow.js +4 -3
  37. package/commands/worktree.js +107 -1610
  38. package/commands/xp.js +23 -2113
  39. package/commands/youtube.js +192 -3110
  40. package/lib/business-core.js +3 -4
  41. package/lib/campaign-run-lock.js +12 -1
  42. package/lib/engine-ask.js +1 -1
  43. package/lib/engine-receipt-sweep.js +64 -0
  44. package/lib/file-lock.js +175 -0
  45. package/lib/first-minute.js +37 -4
  46. package/lib/fleet.js +1 -1
  47. package/lib/integrations-core.js +1695 -0
  48. package/lib/intents.js +7 -0
  49. package/lib/json-file.js +22 -1
  50. package/lib/loop-doctor.js +36 -2
  51. package/lib/map-refs.js +68 -8
  52. package/lib/member-context.js +20 -1
  53. package/lib/member-engine.js +1 -1
  54. package/lib/member-map-fold.js +49 -11
  55. package/lib/member-map-inbox.js +2 -1
  56. package/lib/mission-core.js +9 -2
  57. package/lib/next-work-context.js +190 -0
  58. package/lib/recall-exam.js +171 -0
  59. package/lib/roster-markdown.js +2 -2
  60. package/lib/runner-command.js +5 -5
  61. package/lib/task-db.js +107 -38
  62. package/lib/taste-notes.js +35 -0
  63. package/lib/team-lineup.js +3 -3
  64. package/lib/todo.js +2 -0
  65. package/lib/wiki.js +11 -3
  66. package/lib/worktree-core.js +2032 -0
  67. package/lib/xp-core.js +2172 -0
  68. package/lib/youtube-core.js +3178 -0
  69. package/lib/zen-apply.js +3 -1
  70. package/package.json +2 -2
@@ -1,548 +1,80 @@
1
1
  'use strict';
2
2
 
3
3
  const fs = require('fs');
4
- const os = require('os');
5
4
  const path = require('path');
6
- const { spawn, spawnSync } = require('child_process');
5
+ const { spawnSync } = require('child_process');
7
6
  const { hasFlag, readFlag } = require('../lib/arg-parser');
8
7
  const { stampLatestOpenBriefForWorktree } = require('../lib/brief-ledger');
9
- const { installKeep, planKeep, removeKeep } = require('../lib/campaign-keep');
10
- const { isConductorArtifact, isConductorUntracked } = require('../lib/conductor-artifacts');
11
- const { fixMapDocs } = require('../lib/map-refs');
12
- const { sleepSync } = require('../lib/sleep-sync');
8
+ const { installKeep, removeKeep } = require('../lib/campaign-keep');
9
+ const { isConductorUntracked } = require('../lib/conductor-artifacts');
13
10
  const space = require('../lib/worktree-space');
14
- const close = require('./close');
15
-
16
- const ATRIS_BIN = path.resolve(__dirname, '..', 'bin', 'atris.js');
17
-
18
- // Files atris regenerates on its own. Unstaged churn in them is restored
19
- // before ship commits, unless the commit message names the file. The task
20
- // projection is rewritten by any task command run in a work folder, and two
21
- // parallel landings that each carry a copy always clash.
22
- const REGEN_ADAPTER_FILES = ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md', '.atris/state/tasks.projection.json'];
23
- const COMMAND_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
24
- // Push also runs local hooks. Backend startup and regression checks alone can
25
- // exceed the short network-only allowance used by fetch and discovery.
26
- const GIT_PUSH_TIMEOUT_MS = 5 * 60 * 1000;
27
- const GIT_OID_PATTERN = /^[0-9a-f]{40,64}$/;
28
- const ONE_LAP_PROOF_REF_PATTERN = /^refs\/atris\/one-lap\/([0-9a-f]{40,64})$/;
29
- const DAY_MS = 24 * 60 * 60 * 1000;
30
- const SHIP_HEALTH_SOURCE = 'ship-health:experiments-daily';
31
-
32
- function gitNetworkEnvironment(source = process.env, cwd = process.cwd()) {
33
- const env = { ...source, GIT_TERMINAL_PROMPT: '0' };
34
- // Custom commands may be wrappers that do not accept OpenSSH flags.
35
- // Leave Git's own command/config/program precedence intact.
36
- if (source.GIT_SSH_COMMAND || source.GIT_SSH) return env;
37
- const configured = spawnSync('git', ['config', '--get', 'core.sshCommand'], {
38
- cwd, encoding: 'utf8', env: source, timeout: 5000, killSignal: 'SIGKILL',
39
- });
40
- if (configured.status === 0 && String(configured.stdout || '').trim()) return env;
41
- env.GIT_SSH_COMMAND = 'ssh -o BatchMode=yes -o ConnectTimeout=10 -o ServerAliveInterval=15';
42
- return env;
43
- }
44
-
45
- // A killed network call leaves stderr empty; name the timeout instead.
46
- function gitFailureDetail(result) {
47
- if (result && result.error && result.error.code === 'ETIMEDOUT') return 'timed out';
48
- return [result && result.stdout, result && result.stderr]
49
- .map(output => String(output || '').trim()).filter(Boolean).join('\n');
50
- }
51
-
52
- function runGit(args, { cwd = process.cwd(), check = true, timeout } = {}) {
53
- const network = ['fetch', 'push', 'ls-remote'].includes(args[0]);
54
- // Only network calls need the SSH probe; local calls skip its extra git spawn.
55
- const defaultTimeout = args[0] === 'push' ? GIT_PUSH_TIMEOUT_MS : network ? 30000 : undefined;
56
- const options = { cwd, encoding: 'utf8', timeout: timeout ?? defaultTimeout,
57
- killSignal: 'SIGKILL', env: network ? gitNetworkEnvironment(process.env, cwd) : undefined };
58
- let result = spawnSync('git', args, options);
59
- // Linked checkouts share config. A concurrent start must not silently lose
60
- // its owner/base metadata, but real config errors still stop immediately.
61
- for (let retry = 1; args[0] === 'config' && retry < 3 && configLocked(result); retry += 1) {
62
- sleepSync(100 * retry);
63
- result = spawnSync('git', args, options);
64
- }
65
- if (check && result.status !== 0) {
66
- const detail = gitFailureDetail(result);
67
- throw new Error(detail === 'timed out' ? `git ${args[0]} timed out` : (detail || `git ${args.join(' ')} failed`));
68
- }
69
- return result;
70
- }
71
-
72
- function configLocked(result) {
73
- return result.status !== 0 && /could not lock config file/i.test(`${result.stderr || ''}${result.stdout || ''}`);
74
- }
75
-
76
- function runCommand(command, { cwd = process.cwd(), check = true } = {}) {
77
- const result = spawnSync(command, {
78
- cwd,
79
- encoding: 'utf8',
80
- shell: true,
81
- stdio: 'pipe',
82
- maxBuffer: COMMAND_MAX_BUFFER_BYTES,
83
- });
84
- if (result.error) throw result.error;
85
- if (check && result.status !== 0) {
86
- const msg = (result.stderr || result.stdout || `${command} failed`).trim();
87
- throw new Error(msg);
88
- }
89
- return result;
90
- }
91
-
92
- function statusSnapshot(root) {
93
- const result = runGit(['status', '--porcelain=v1', '--untracked-files=all'], { cwd: root, check: false });
94
- if (result.status !== 0) return [];
95
- return result.stdout
96
- .split(/\r?\n/)
97
- .map((line) => line.trimEnd())
98
- .filter(Boolean);
99
- }
100
-
101
- function newStatusRows(before, after) {
102
- const seen = new Set(before);
103
- return after.filter((row) => !seen.has(row));
104
- }
105
-
106
- function readOneLapProofBinding(root, branch) {
107
- const sidecarPath = path.join(root, '.atris', 'agent-worktree.json');
108
- let sidecar = null;
109
- try {
110
- sidecar = JSON.parse(fs.readFileSync(sidecarPath, 'utf8'));
111
- } catch (error) {
112
- if (error && error.code !== 'ENOENT') throw new Error('one-lap proof metadata is unreadable');
113
- }
114
- const metadata = sidecar && sidecar.one_lap_proof;
115
- const configured = runGit(['config', '--get', `branch.${branch}.atris-proof-ref`], { cwd: root, check: false });
116
- const configuredRef = configured.status === 0 ? String(configured.stdout || '').trim() : '';
117
- if (!metadata && !configuredRef) return null;
118
- if (!metadata || !configuredRef) throw new Error('one-lap proof binding is incomplete');
119
-
120
- const proofRef = String(metadata.ref || '');
121
- const sourceCommit = String(metadata.commit || '');
122
- const sourceTree = String(metadata.tree || '');
123
- const refMatch = proofRef.match(ONE_LAP_PROOF_REF_PATTERN);
124
- if (!refMatch || proofRef !== configuredRef || !GIT_OID_PATTERN.test(sourceCommit) || !GIT_OID_PATTERN.test(sourceTree)) {
125
- throw new Error('one-lap proof binding is invalid');
126
- }
127
- const resolvedCommit = runGit(['rev-parse', '--verify', `${proofRef}^{commit}`], { cwd: root, check: false });
128
- const resolvedTree = runGit(['rev-parse', '--verify', `${proofRef}^{tree}`], { cwd: root, check: false });
129
- if (resolvedCommit.status !== 0 || String(resolvedCommit.stdout || '').trim() !== sourceCommit || refMatch[1] !== sourceCommit) {
130
- throw new Error('one-lap proof commit no longer matches its retained ref');
131
- }
132
- if (resolvedTree.status !== 0 || String(resolvedTree.stdout || '').trim() !== sourceTree) {
133
- throw new Error('one-lap proof tree no longer matches its retained ref');
134
- }
135
- return { ref: proofRef, commit: sourceCommit, tree: sourceTree };
136
- }
137
-
138
- function worktreeTree(root, source) {
139
- const result = runGit(source === 'index'
140
- ? ['write-tree']
141
- : ['rev-parse', '--verify', 'HEAD^{tree}'], { cwd: root, check: false });
142
- if (result.status !== 0) throw new Error(`could not resolve the ${source} tree for one-lap proof`);
143
- return String(result.stdout || '').trim();
144
- }
145
-
146
- function assertOneLapProofTree(root, binding, source) {
147
- if (!binding) return;
148
- if (worktreeTree(root, source) !== binding.tree) {
149
- throw new Error(`one-lap ${source} tree differs from the independently verified proof tree`);
150
- }
151
- }
152
-
153
- function repoRoot(cwd = process.cwd()) {
154
- return runGit(['rev-parse', '--show-toplevel'], { cwd }).stdout.trim();
155
- }
156
-
157
- function slugify(value, fallback = 'task', limit = 48) {
158
- const slug = String(value || '')
159
- .trim()
160
- .toLowerCase()
161
- .replace(/[^a-z0-9]+/g, '-')
162
- .replace(/^-+|-+$/g, '')
163
- .slice(0, limit)
164
- .replace(/-+$/g, '');
165
- return slug || fallback;
166
- }
167
-
168
- function stamp(now = new Date()) {
169
- return now.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, '').replace('T', '-');
170
- }
171
-
172
- function branchName(member, task, now = new Date()) {
173
- return `codex/${slugify(member, 'member', 24)}-${slugify(task, 'task', 36)}-${stamp(now)}`;
174
- }
175
-
176
- // The newest earlier run of this same member+job, if its commits never reached
177
- // the base, so a retry continues that work instead of redoing it. Only the
178
- // newest run counts: once it landed or did nothing, anything older is stale.
179
- function lastRunBranch(root, owner, task, base, skip) {
180
- const prefix = `codex/${slugify(owner, 'member', 24)}-${slugify(task, 'task', 36)}-`;
181
- const listed = runGit(['for-each-ref', '--format=%(refname:short)', `refs/heads/${prefix}*`], { cwd: root, check: false });
182
- if (listed.status !== 0) return null;
183
- // Only exact runs of this job (stamp plus optional same-second number), so
184
- // "fix-login" never matches "fix-login-page".
185
- const runs = listed.stdout.split('\n').map((name) => name.trim())
186
- .map((name) => ({ name, run: name.slice(prefix.length).match(/^(\d{8}-\d{6})(?:-(\d+))?$/) }))
187
- .filter(({ name, run }) => run && name !== skip)
188
- .sort((a, b) => b.run[1].localeCompare(a.run[1]) || Number(b.run[2] || 1) - Number(a.run[2] || 1));
189
- if (!runs.length) return null;
190
- const newest = runs[0].name;
191
- const cherry = runGit(['cherry', base, newest], { cwd: root, check: false });
192
- if (cherry.status !== 0) return null;
193
- return cherry.stdout.split('\n').some((line) => line.startsWith('+')) ? newest : null;
194
- }
195
-
196
- // True only when git proves the branch no longer merges cleanly with the base.
197
- // merge-tree --write-tree exits 1 on conflict; an error or old git keeps the
198
- // resume as before.
199
- function conflictsWithBase(root, base, branch) {
200
- const result = runGit(['merge-tree', '--write-tree', '--name-only', base, branch], { cwd: root, check: false });
201
- return result.status === 1;
202
- }
203
-
204
- function defaultWorktreePath(root, member, task, now = new Date()) {
205
- const name = `${slugify(member, 'member', 24)}-${slugify(task, 'task', 36)}-${stamp(now)}`;
206
- return path.join(path.dirname(root), '.agent-worktrees', path.basename(root), name);
207
- }
208
-
209
- function parseWorktrees(text) {
210
- const out = [];
211
- let current = {};
212
- for (const raw of `${text}\n`.split(/\r?\n/)) {
213
- const line = raw.trim();
214
- if (!line) {
215
- if (current.worktree) {
216
- let branch = current.branch || 'detached';
217
- branch = branch.replace(/^refs\/heads\//, '');
218
- const item = { path: current.worktree, branch, head: current.HEAD || '' };
219
- if (current.locked) item.locked = true;
220
- if (current.prunable) item.prunable = true;
221
- out.push(item);
222
- }
223
- current = {};
224
- continue;
225
- }
226
- const idx = line.indexOf(' ');
227
- if (idx === -1) {
228
- current[line] = true;
229
- } else {
230
- current[line.slice(0, idx)] = line.slice(idx + 1);
231
- }
232
- }
233
- return out;
234
- }
235
-
236
- function listWorktrees(root = repoRoot()) {
237
- return parseWorktrees(runGit(['worktree', 'list', '--porcelain'], { cwd: root }).stdout);
238
- }
239
-
240
- // Duplicate flight guard. On 2026-08-07 two agents were dispatched onto the
241
- // same map rewrite 44 seconds apart: fleet dispatch claims a task first, but a
242
- // direct `worktree start` had no pre-check beyond the target path existing.
243
- // Refuse only the same task slug. A shared word on an unrelated flight is not
244
- // a collision (dispatch-cli-900 must not block dispatch-cli-901).
245
- const AGENT_FLIGHT_NAME_PATTERN = /^codex\/(.+)-(\d{8}-\d{6})$/;
246
- const FLIGHT_WINDOW_MS = 24 * 60 * 60 * 1000;
247
- const FLIGHT_STOPWORDS = new Set([
248
- 'fix', 'add', 'update', 'make', 'change', 'new', 'task', 'work', 'repo',
249
- 'cli', 'atris', 'backend', 'the', 'a', 'an', 'and', 'for', 'to', 'of', 'in', 'on',
250
- ]);
251
-
252
- function flightStampMs(stampText) {
253
- const m = /^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$/.exec(String(stampText || ''));
254
- if (!m) return null;
255
- const [, year, month, day, hour, minute, second] = m.map(Number);
256
- if (month < 1 || month > 12 || day < 1 || day > 31 || hour > 23 || minute > 59 || second > 59) return null;
257
- return Date.UTC(year, month - 1, day, hour, minute, second);
258
- }
259
-
260
- function taskTokens(value) {
261
- return String(value || '')
262
- .toLowerCase()
263
- .split(/[^a-z0-9]+/)
264
- .filter(Boolean)
265
- .filter((token) => !FLIGHT_STOPWORDS.has(token));
266
- }
267
-
268
- // codex/<owner>-<task-slug>-<YYYYMMDD-HHMMSS>: drop the owner segment and the
269
- // stamp, and what is left is the task the flight is working on.
270
- function parseAgentFlightName(name) {
271
- const text = String(name || '').trim().replace(/^refs\/heads\//, '');
272
- const match = AGENT_FLIGHT_NAME_PATTERN.exec(text);
273
- if (!match) return null;
274
- const stampMs = flightStampMs(match[2]);
275
- if (stampMs === null) return null;
276
- const segments = match[1].split('-').filter(Boolean);
277
- if (segments.length < 2) return null;
278
- return {
279
- name: text,
280
- owner: segments[0],
281
- taskSlug: segments.slice(1).join('-'),
282
- stamp: match[2],
283
- stampMs,
284
- tokens: taskTokens(segments.slice(1).join('-')),
285
- };
286
- }
287
-
288
- function remoteAgentBranchNames(root) {
289
- // One ls-remote, best effort: offline dispatchers still get the local view.
290
- const result = runGit(['ls-remote', '--heads', 'origin'], { cwd: root, check: false, timeout: 10000 });
291
- if (result.status !== 0) return [];
292
- return String(result.stdout || '')
293
- .split(/\r?\n/)
294
- .map((line) => (line.split(/\s+/)[1] || '').replace(/^refs\/heads\//, '').trim())
295
- .filter(Boolean);
296
- }
297
-
298
- function inFlightAgentFlights({ root = repoRoot(), now = new Date(), windowMs = FLIGHT_WINDOW_MS } = {}) {
299
- const nowMs = now.getTime();
300
- const flights = new Map();
301
- const add = (name, kind) => {
302
- const flight = parseAgentFlightName(name);
303
- if (!flight) return;
304
- // Branch age comes from the stamp in the name, never a git log per branch.
305
- if (kind !== 'worktree' && nowMs - flight.stampMs > windowMs) return;
306
- const existing = flights.get(flight.name);
307
- if (existing && existing.kind === 'worktree') return;
308
- flights.set(flight.name, { ...flight, kind });
309
- };
310
- for (const wt of listWorktrees(root)) add(wt.branch, 'worktree');
311
- const local = runGit(['branch', '--format=%(refname:short)'], { cwd: root, check: false });
312
- if (local.status === 0) {
313
- for (const name of String(local.stdout || '').split(/\r?\n/)) add(name, 'branch');
314
- }
315
- for (const name of remoteAgentBranchNames(root)) add(name, 'branch');
316
- return [...flights.values()].sort((a, b) => b.stampMs - a.stampMs);
317
- }
318
-
319
- function collidingFlights(flights, taskSlug) {
320
- const wanted = String(taskSlug || '').trim().toLowerCase();
321
- if (!wanted) return [];
322
- return flights.filter((flight) => String(flight.taskSlug || '').toLowerCase() === wanted);
323
- }
324
-
325
- function describeFlightAge(flight, nowMs = Date.now()) {
326
- const minutes = Math.max(0, Math.round((nowMs - flight.stampMs) / 60000));
327
- if (minutes < 60) return `${minutes}m old`;
328
- return `${Math.floor(minutes / 60)}h${minutes % 60}m old`;
329
- }
330
-
331
- function refExists(root, ref) {
332
- return runGit(['rev-parse', '--verify', `${ref}^{commit}`], { cwd: root, check: false }).status === 0;
333
- }
334
-
335
- function currentBranch(root = repoRoot()) {
336
- return runGit(['branch', '--show-current'], { cwd: root, check: false }).stdout.trim();
337
- }
338
-
339
- function detachedShipBranch(root) {
340
- return `codex/${path.basename(root)}`;
341
- }
342
-
343
- function createDetachedShipBranch(root, branch) {
344
- if (refExists(root, `refs/heads/${branch}`) || refExists(root, `refs/remotes/origin/${branch}`)) {
345
- console.error(`blocked: detached head branch already exists: ${branch}`);
346
- return false;
347
- }
348
- const created = runGit(['switch', '-c', branch], { cwd: root, check: false });
349
- if (created.status !== 0) {
350
- console.error(`blocked: detached head branch create failed: ${branch}`);
351
- console.error((created.stderr || created.stdout || '').trim());
352
- return false;
353
- }
354
- console.log(`ship: detached head, created branch ${branch}`);
355
- return true;
356
- }
357
-
358
- function currentUpstream(root) {
359
- const result = runGit(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], { cwd: root, check: false });
360
- return result.status === 0 ? result.stdout.trim() : '';
361
- }
362
-
363
- function remoteHead(root) {
364
- // The saved origin/HEAD survives branch renames. Ask the remote before
365
- // trusting it, keeping the offline fallback when the remote is unavailable.
366
- const remote = runGit(['remote', 'get-url', 'origin'], { cwd: root, check: false });
367
- if (remote.status === 0) {
368
- const live = runGit(['ls-remote', '--symref', 'origin', 'HEAD'], { cwd: root, check: false, timeout: 5000 });
369
- const match = live.status === 0 && String(live.stdout || '').match(/^ref: refs\/heads\/(.+)\s+HEAD$/m);
370
- if (match) {
371
- const head = `origin/${match[1]}`;
372
- if (!refExists(root, head)) {
373
- const fetched = runGit(['fetch', 'origin', `refs/heads/${match[1]}:refs/remotes/${head}`], { cwd: root, check: false, timeout: 5000 });
374
- if (fetched.status !== 0 || !refExists(root, head)) {
375
- throw new Error('could not fetch the remote default branch; stop and retry when the remote is available');
376
- }
377
- }
378
- return head;
379
- }
380
- }
381
- const result = runGit(['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], { cwd: root, check: false });
382
- return result.status === 0 ? result.stdout.trim() : '';
383
- }
384
-
385
- function normalizeTargetRef(root, target) {
386
- const value = String(target || '').trim();
387
- if (!value) return '';
388
- const remotes = runGit(['remote'], { cwd: root, check: false }).stdout.split(/\r?\n/);
389
- const preferRemote =
390
- !value.startsWith('origin/') &&
391
- !value.startsWith('refs/') &&
392
- value !== 'HEAD' &&
393
- !/^[0-9a-f]{7,40}$/i.test(value) &&
394
- remotes.includes('origin');
395
- const remoteRef = value.startsWith('origin/') ? value : `origin/${value}`;
396
- if (preferRemote && refExists(root, remoteRef)) return remoteRef;
397
- if (preferRemote) return remoteRef;
398
- if (refExists(root, value)) return value;
399
- if (refExists(root, remoteRef)) return remoteRef;
400
- if (value.startsWith('origin/') || remotes.includes('origin')) return remoteRef;
401
- return value;
402
- }
403
-
404
- function defaultMainlineBase(root) {
405
- const head = remoteHead(root);
406
- if (head && refExists(root, head)) return head;
407
- for (const candidate of ['origin/main', 'origin/master']) {
408
- if (refExists(root, candidate)) return candidate;
409
- }
410
- return 'HEAD';
411
- }
412
-
413
- function defaultStartBase(root) {
414
- const upstream = currentUpstream(root);
415
- const mainline = defaultMainlineBase(root);
416
- // Always refresh before cutting a worktree: a stale origin/master base sent
417
- // an engine off to rebuild work that had already landed (2026-07-09, packs
418
- // one-click flight rebuilt the stars backend from a base two PRs behind).
419
- refreshRemoteRef(root, mainline);
420
- if (!upstream || !refExists(root, upstream)) return mainline;
421
- if (mainline === 'HEAD' || mainline === upstream) return upstream;
422
- // A launcher upstream that is behind the mainline is a stale cut point
423
- // (2026-07-05: a stale origin/task branch sent two flights into rebase
424
- // conflicts). Start from the mainline unless the upstream carries new work.
425
- refreshRemoteRef(root, upstream);
426
- const behind = runGit(['merge-base', '--is-ancestor', upstream, mainline], { cwd: root, check: false });
427
- return behind.status === 0 ? mainline : upstream;
428
- }
429
-
430
- function defaultShipTarget(root) {
431
- const branch = currentBranch(root);
432
- if (branch) {
433
- const configured = runGit(['config', '--get', `branch.${branch}.atris-base`], { cwd: root, check: false }).stdout.trim();
434
- if (configured && refExists(root, configured)) return configured;
435
- }
436
- const head = remoteHead(root);
437
- if (head && refExists(root, head)) return head;
438
- for (const candidate of ['origin/main', 'origin/master']) {
439
- if (refExists(root, candidate)) return candidate;
440
- }
441
- return 'HEAD';
442
- }
443
-
444
- function refreshRemoteRef(root, ref) {
445
- if (!ref.startsWith('origin/')) return;
446
- const remoteBranch = ref.slice('origin/'.length);
447
- const fetched = runGit(['fetch', 'origin', remoteBranch], { cwd: root, check: false, timeout: 5000 });
448
- return fetched.status === 0 ? '' : (gitFailureDetail(fetched) || 'fetch failed');
449
- }
450
-
451
- function baseBranchName(ref) {
452
- return String(ref || '').replace(/^refs\/heads\//, '').replace(/^origin\//, '');
453
- }
454
-
455
- function statusCounts(root, {
456
- ignoredUnstagedFiles = new Set(),
457
- ignoredUntrackedFiles = new Set(),
458
- ignoreUntracked = null,
459
- ignoreAny = null,
460
- } = {}) {
461
- if (!fs.existsSync(root)) return null;
462
- // -uall expands untracked directories to individual files so ignoredUntrackedFiles
463
- // and ignoreUntracked can match exact paths (plain porcelain collapses them to "?? dir/").
464
- const expandUntracked = ignoredUntrackedFiles.size
465
- || typeof ignoreUntracked === 'function'
466
- || typeof ignoreAny === 'function';
467
- const statusArgs = expandUntracked ? ['status', '--porcelain', '-uall'] : ['status', '--porcelain'];
468
- const result = runGit(statusArgs, { cwd: root, check: false });
469
- if (result.status !== 0) return null;
470
- let staged = 0;
471
- let unstaged = 0;
472
- let untracked = 0;
473
- for (const line of result.stdout.split(/\r?\n/).filter(Boolean)) {
474
- const file = line.slice(3);
475
- if (typeof ignoreAny === 'function' && ignoreAny(file)) continue;
476
- if (ignoredUnstagedFiles.has(file) && line[0] === ' ' && line[1] !== ' ') continue;
477
- if (line.startsWith('??')) {
478
- if (ignoredUntrackedFiles.has(file)) continue;
479
- if (typeof ignoreUntracked === 'function' && ignoreUntracked(file)) continue;
480
- untracked += 1;
481
- continue;
482
- }
483
- if (line[0] !== ' ') staged += 1;
484
- if (line[1] !== ' ') unstaged += 1;
485
- }
486
- return { staged, unstaged, untracked };
487
- }
488
-
489
- // Remove conductor plumbing from the index after a broad `git add -A`, so the commit
490
- // records engine output only. Paths that were never staged are simply not listed.
491
- // GitHub refuses a merge with "Base branch was modified" when another PR
492
- // merged into the same base a moment earlier; the same merge succeeds on a
493
- // second try. On 2026-10-15 five parallel dispatches lost three landings to
494
- // it. Retry only that message, a few times, with a short wait.
495
- const BASE_MODIFIED_PATTERN = /Base branch was modified/i;
496
- function mergePrWithRetry(mergeArgs, root, { run = spawnSync, wait = sleepSync, attempts = 4 } = {}) {
497
- let merged = run('gh', mergeArgs, { cwd: root, encoding: 'utf8' });
498
- for (let attempt = 1; attempt < attempts && merged.status !== 0
499
- && BASE_MODIFIED_PATTERN.test(`${merged.stderr || ''}${merged.stdout || ''}`); attempt += 1) {
500
- console.log(`merge: base branch moved, retrying (${attempt + 1}/${attempts})`);
501
- wait(1500 * attempt);
502
- merged = run('gh', mergeArgs, { cwd: root, encoding: 'utf8' });
503
- }
504
- return merged;
505
- }
506
-
507
- function unstageConductorArtifacts(root) {
508
- const staged = runGit(['diff', '--cached', '--name-only'], { cwd: root, check: false });
509
- if (staged.status !== 0) return [];
510
- const added = runGit(['diff', '--cached', '--name-only', '--diff-filter=A'], { cwd: root, check: false });
511
- const newFiles = new Set(String(added.stdout || '').split(/\r?\n/).filter(Boolean));
512
- // A file this commit would add under .atris/ is the tool's own state, never work.
513
- const drop = String(staged.stdout || '').split(/\r?\n/).filter(Boolean)
514
- .filter((file) => isConductorArtifact(file) || (newFiles.has(file) && isConductorUntracked(file)));
515
- if (drop.length) runGit(['reset', '-q', 'HEAD', '--', ...drop], { cwd: root, check: false });
516
- return drop;
517
- }
518
-
519
- function changedFiles(root, args) {
520
- const result = runGit([...args, '--', ...REGEN_ADAPTER_FILES], { cwd: root, check: false });
521
- if (result.status !== 0) return new Set();
522
- return new Set(result.stdout.split(/\r?\n/).filter(Boolean));
523
- }
524
-
525
- function restoreRegeneratedAdapterChurn(root, message, { dryRun = false } = {}) {
526
- const unstaged = changedFiles(root, ['diff', '--name-only']);
527
- const staged = changedFiles(root, ['diff', '--cached', '--name-only']);
528
- const commitMessage = String(message || '');
529
- const skipped = REGEN_ADAPTER_FILES.filter(
530
- (file) => unstaged.has(file) && !staged.has(file) && !commitMessage.includes(file)
531
- );
532
- if (!skipped.length) return [];
533
- console.log(`ship: skipped regenerated adapter churn: ${skipped.join(', ')}`);
534
- if (!dryRun) runGit(['checkout', '--', ...skipped], { cwd: root });
535
- return skipped;
536
- }
537
-
538
- function swarloClaim(root, { channel, taskKey, content }) {
539
- const script = path.join(root, 'scripts', 'swarlo.py');
540
- if (!fs.existsSync(script)) return 'skip: scripts/swarlo.py not found';
541
- const result = spawnSync('python3', [script, 'claim', channel, taskKey, content], { cwd: root, encoding: 'utf8' });
542
- const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
543
- if (result.status === 0) return output || `claimed ${taskKey}`;
544
- return `skip: swarlo claim failed: ${output || result.status}`;
545
- }
11
+ const worktreeCore = require('../lib/worktree-core');
12
+
13
+ const {
14
+ ATRIS_BIN,
15
+ RESCUE_IDLE_MS,
16
+ finishShippedCopy,
17
+ formatKib,
18
+ SWEEP_IDLE_MS,
19
+ SWEEP_EVERY_HOURS,
20
+ SWEEP_KEPT_LABELS,
21
+ assertOneLapProofTree,
22
+ baseBranchName,
23
+ branchName,
24
+ cleanupWorktrees,
25
+ collidingFlights,
26
+ createAgentWorktree,
27
+ createDetachedShipBranch,
28
+ createOrFindPr,
29
+ currentBranch,
30
+ defaultMainlineBase,
31
+ defaultShipTarget,
32
+ defaultStartBase,
33
+ defaultSweepWorkspace,
34
+ deniedLaneForShip,
35
+ describeFlightAge,
36
+ detachedShipBranch,
37
+ findPrimaryRoot,
38
+ flightStampMs,
39
+ gitFailureDetail,
40
+ gitNetworkEnvironment,
41
+ inFlightAgentFlights,
42
+ listActiveProcessPaths,
43
+ listWorktrees,
44
+ lowDiskRefusal,
45
+ mergePrWithRetry,
46
+ newStatusRows,
47
+ normalizeTargetRef,
48
+ parseAgentFlightName,
49
+ parseProcessArgPaths,
50
+ parseWorktrees,
51
+ plural,
52
+ prMergeRef,
53
+ protectedPushRefusal,
54
+ readOneLapProofBinding,
55
+ reapPushedCopy,
56
+ reapShippedCopy,
57
+ recordWorktreeRemoval,
58
+ refreshMovedMapRefs,
59
+ refreshRemoteRef,
60
+ repoRoot,
61
+ restoreRegeneratedAdapterChurn,
62
+ runCommand,
63
+ runGit,
64
+ runShipHealthCheck,
65
+ scheduleShippedCopyRemoval,
66
+ shipMergeRecord,
67
+ shippedCopyRemovalPlan,
68
+ slugify,
69
+ statusCounts,
70
+ statusSnapshot,
71
+ swarloClaim,
72
+ sweepSchedulePlan,
73
+ sweepWorkspace,
74
+ taskTokens,
75
+ unstageConductorArtifacts,
76
+ worktreeHasActiveProcess,
77
+ } = worktreeCore;
546
78
 
547
79
  function printStatus() {
548
80
  const root = repoRoot();
@@ -562,241 +94,6 @@ function printStatus() {
562
94
  }
563
95
  }
564
96
 
565
- // Programmatic core shared by `atris worktree start` and `atris mission start
566
- // --worktree`: creates the branch + isolated checkout + identity sidecar and
567
- // returns the facts. Throws on failure; callers own messaging and next steps.
568
- //
569
- // checkoutBase (what the branch is cut from) and shipBase (what `atris
570
- // worktree ship` targets, recorded as branch.<branch>.atris-base) are kept
571
- // separate on purpose. checkoutBase defaults to the launcher's own
572
- // upstream/default remote base, so the agent starts from current work.
573
- // shipBase defaults to the remote mainline for agent/member worktrees regardless of
574
- // what branch the launcher happened to have checked out. recording the
575
- // launcher's own feature branch as the ship target false-landed two PRs on
576
- // 2026-07-04 (ship merged into the launcher's branch, never reached master).
577
- // --base/--target still overrides both when the caller wants a different
578
- // checkout point and ship target on purpose.
579
- //
580
- // spaceGuard and deps default to on outside temp folders: a low disk reaps
581
- // finished copies across the workspace and then refuses (ATRIS_LOW_DISK),
582
- // and a matching install is cloned in as node_modules. deps: false skips
583
- // the clone; an object passes options through to seedNodeModules.
584
- function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task, formerTasks = [], branch: branchOverride, path: pathOverride, base: baseOverride, now = new Date(), env = process.env, freeSpace = space.freeBytes, spaceGuard, deps } = {}) {
585
- const owner = member || agent;
586
- if (!owner || !task) throw new Error('createAgentWorktree: owner (member/agent) and task required');
587
- // Pin the caller's flight base, or resolve it once for this new flight.
588
- const mainline = baseOverride || defaultMainlineBase(root);
589
- // Every new flight pays down completed checkout debt before adding another
590
- // full repo copy. Branches and commits survive worktree removal, while real
591
- // uncommitted changes remain protected by cleanupWorktrees.
592
- const cleanupBudget = { count: 0, kib: 0 };
593
- let reapedBeforeStart = [];
594
- let cachePrunedBeforeStart = [];
595
- try {
596
- const cleaned = cleanupWorktrees({ root, base: mainline, apply: true, by: 'atris worktree start', cleanupBudget });
597
- reapedBeforeStart = cleaned.removed;
598
- cachePrunedBeforeStart = cleaned.cachePruned;
599
- } catch {}
600
- let branch = branchOverride || branchName(owner, task, now);
601
- let target = path.resolve(pathOverride || defaultWorktreePath(root, owner, task, now));
602
- // Two runs of the same job in the same second get the same name; number
603
- // the later one instead of failing it.
604
- if (!pathOverride && !branchOverride) {
605
- const baseBranch = branch;
606
- const baseTarget = target;
607
- for (let n = 2; fs.existsSync(target) && n < 100; n += 1) {
608
- branch = `${baseBranch}-${n}`;
609
- target = `${baseTarget}-${n}`;
610
- }
611
- }
612
- // Agent/member worktrees always cut from the mainline: honoring the
613
- // launcher checkout's upstream let a dispatch inherit another session's
614
- // in-progress feature branch (2026-07-09: an auth fix arrived carrying 13
615
- // unpushed commits from a parallel session and could not merge).
616
- const checkoutBase = normalizeTargetRef(root, mainline);
617
- const shipBase = checkoutBase;
618
- if (fs.existsSync(target)) throw new Error(`worktree path already exists: ${target}`);
619
- // A full disk stops every flight at once. Reap first; refuse before cutting.
620
- const room = checkRoomForCopy({ root, target, freeSpace, env, spaceGuard, cleanupBudget });
621
- fs.mkdirSync(path.dirname(target), { recursive: true });
622
- const staleBase = refreshRemoteRef(root, checkoutBase);
623
- // A retry of the same job starts from the last run's unlanded commits; it
624
- // still ships to the mainline.
625
- let resumedFrom = (!pathOverride && !branchOverride)
626
- ? lastRunBranch(root, owner, task, checkoutBase, branch)
627
- : null;
628
- // A last run that now conflicts with the mainline would hand its conflict
629
- // to the retry, which then cannot land on the first try. Start fresh; the
630
- // old branch keeps its commits.
631
- let skippedResume = null;
632
- if (resumedFrom && conflictsWithBase(root, checkoutBase, resumedFrom)) {
633
- skippedResume = resumedFrom;
634
- resumedFrom = null;
635
- console.error(`note: the last run's branch ${skippedResume} no longer merges cleanly with ${checkoutBase}, so this run starts fresh; that branch keeps its commits`);
636
- }
637
- // A display name is not an identity: it repeats across workspaces and changes when the task list
638
- // changes, so a branch under a job's older name is never a starting point and never blocks a
639
- // flight. It can still be this job's own unfinished work, so say where it is and leave the choice
640
- // to a person; this run goes on from the job's own name.
641
- const own = slugify(task, 'task', 36);
642
- for (const former of Array.isArray(formerTasks) ? formerTasks : [formerTasks]) {
643
- if (!former || !String(former).trim() || slugify(former, 'task', 36) === own) continue;
644
- const olderRun = lastRunBranch(root, owner, former, checkoutBase, branch);
645
- if (!olderRun) continue;
646
- console.error(`note: older unfinished work may be on ${olderRun}; ${resumedFrom ? `continuing from ${resumedFrom} instead` : 'starting fresh'}`);
647
- break;
648
- }
649
- const startPoint = resumedFrom || checkoutBase;
650
- const ref = `refs/heads/${branch}`;
651
- const existed = runGit(['show-ref', '--verify', '--quiet', ref], { cwd: root, check: false }).status !== 1;
652
- const baseCommit = runGit(['rev-parse', '--verify', `${startPoint}^{commit}`], { cwd: root, check: false });
653
- const startCommit = baseCommit.status === 0 ? baseCommit.stdout.trim() : '';
654
- // With no local copy the start fails on its own; only a real cut gets the warning.
655
- if (staleBase && startCommit) {
656
- console.error(`warning: could not refresh ${checkoutBase} (${staleBase.split('\n')[0]}); the new worktree starts from the local copy, which may be behind`);
657
- }
658
- const discardFailedStart = () => {
659
- if (existed || !startCommit) return;
660
- const registered = listWorktrees(root).find((wt) => wt.branch === branch);
661
- if (registered) {
662
- if (!fs.existsSync(target) || fs.realpathSync(registered.path) !== fs.realpathSync(target)
663
- || registered.head !== startCommit) return;
664
- const dirt = statusCounts(target, { ignoreUntracked: isConductorUntracked });
665
- if (!dirt || dirt.staged || dirt.unstaged || dirt.untracked) return;
666
- // No engine has started. Normal removal still refuses unexpected dirt.
667
- runGit(['worktree', 'remove', target], { cwd: root });
668
- }
669
- if (!fs.existsSync(target)) runGit(['update-ref', '-d', ref, startCommit], { cwd: root });
670
- };
671
- for (let attempt = 0; attempt < 3; attempt += 1) {
672
- const added = runGit(['worktree', 'add', '-b', branch, target, startPoint], { cwd: root, check: false });
673
- if (added.status === 0) break;
674
- // Git can create the branch before failing to write its tracking config.
675
- // Drop only that untouched ref, never an existing branch or a checkout.
676
- if (!/already exists|already used by worktree/i.test(added.stderr || '')) discardFailedStart();
677
- if (!configLocked(added) || attempt === 2) {
678
- throw new Error(String(added.stderr || added.stdout || 'worktree start failed').trim());
679
- }
680
- sleepSync(100 * (attempt + 1));
681
- }
682
- // A resumed branch takes in what the mainline gained since, or a job that
683
- // keeps resuming drifts further behind every run until it clashes
684
- // (2026-10-15: five long jobs sat 103 to 129 commits behind). The clash
685
- // check above proved this merge is clean; if git still refuses, the run
686
- // keeps the old start instead of failing.
687
- if (resumedFrom) {
688
- const caughtUp = runGit(['merge', '--no-edit', '-m', `Merge ${checkoutBase} into ${branch}`, checkoutBase], { cwd: target, check: false });
689
- if (caughtUp.status !== 0) runGit(['merge', '--abort'], { cwd: target, check: false });
690
- }
691
- try {
692
- runGit(['config', `branch.${branch}.atris-base`, shipBase], { cwd: target });
693
- runGit(['config', `branch.${branch}.atris-owner`, owner], { cwd: target });
694
- runGit(['config', `branch.${branch}.atris-task`, task], { cwd: target });
695
- fs.mkdirSync(path.join(target, '.atris'), { recursive: true });
696
- fs.writeFileSync(
697
- path.join(target, '.atris', 'agent-worktree.json'),
698
- JSON.stringify({
699
- agent: agent || null,
700
- member: member || null,
701
- owner,
702
- task,
703
- branch,
704
- base: shipBase,
705
- checkout_base: checkoutBase,
706
- resumed_from: resumedFrom,
707
- skipped_resume: skippedResume,
708
- workspace_root: root,
709
- created_at: now.toISOString(),
710
- }, null, 2) + '\n',
711
- 'utf8'
712
- );
713
- } catch (error) {
714
- discardFailedStart();
715
- throw error;
716
- }
717
- const seedDeps = deps !== false && env.ATRIS_WORKTREE_NO_DEP_CLONE !== '1' && (deps ?? !space.isScratchPath(target));
718
- const depsResult = seedDeps ? seedCopyDeps({ root, target, options: deps && typeof deps === 'object' ? deps : {} }) : null;
719
- return { path: target, branch, base: shipBase, checkoutBase, resumedFrom, skippedResume, owner, reapedBeforeStart, cachePrunedBeforeStart, space: room, deps: depsResult };
720
- }
721
-
722
- // node_modules as a clone of a matching install (see lib/worktree-space.js),
723
- // so a web copy costs 12 MB and 2 seconds instead of npm ci.
724
- function seedCopyDeps({ root, target, options = {} }) {
725
- try {
726
- const primaryRoot = findPrimaryRoot(root);
727
- return space.seedNodeModules({
728
- target,
729
- workspace: path.dirname(primaryRoot),
730
- repoName: path.basename(primaryRoot),
731
- sources: [primaryRoot, ...listWorktrees(root).map((wt) => wt.path)],
732
- isIgnored: (dir) => runGit(['check-ignore', '-q', '--', 'node_modules/'], { cwd: dir, check: false }).status === 0,
733
- ...options,
734
- });
735
- } catch (error) {
736
- return { status: 'skipped', reason: `could not clone installs: ${String((error && error.message) || error)}` };
737
- }
738
- }
739
-
740
- // When the disk runs low, reaps finished copies of every repo in the
741
- // workspace (merged, clean, nothing running inside, past the fresh-copy
742
- // grace). Below the floor it refuses with ATRIS_LOW_DISK, before any copy
743
- // is cut. Limits: ATRIS_WORKTREE_REAP_BELOW_GB, ATRIS_WORKTREE_MIN_FREE_GB.
744
- // Even when the disk is low, a copy touched in the last ten minutes stays:
745
- // the engine that just finished may still have its last message unread.
746
- const LOW_DISK_IDLE_MS = 10 * 60 * 1000;
747
-
748
- function ensureRoomForCopy({ root, target, freeSpace = space.freeBytes, env = process.env, by = 'atris worktree start', cleanupBudget = null }) {
749
- const limits = space.spaceLimits(env);
750
- const before = freeSpace(target);
751
- const room = { before, free: before, removed: [], cleared: [], ...limits };
752
- if (typeof before !== 'number' || !Number.isFinite(before)) return room;
753
- if (before >= limits.reapBelow && before >= limits.refuseBelow) return room;
754
- const swept = sweepWorkspace({ workspace: path.dirname(findPrimaryRoot(root)), apply: true, idleMs: LOW_DISK_IDLE_MS, freeSpace, by, cleanupBudget });
755
- for (const repo of swept.repos) {
756
- room.removed.push(...(repo.removed || []));
757
- room.cleared.push(...(repo.cachePruned || []));
758
- }
759
- const after = freeSpace(target);
760
- room.free = typeof after === 'number' && Number.isFinite(after) ? after : before;
761
- if (limits.refuseBelow > 0 && room.free < limits.refuseBelow) {
762
- const count = room.removed.length;
763
- const reaped = count
764
- ? `Removed ${count} finished build ${count === 1 ? 'copy' : 'copies'} first, and that was not enough.`
765
- : 'No finished build copies were safe to remove.';
766
- const error = new Error(
767
- `only ${space.formatGb(room.free)} free on this disk, and a new build copy needs at least ${space.formatGb(limits.refuseBelow)}. ${reaped} `
768
- + 'Free some space and retry; `atris worktree sweep` shows what is left, and ATRIS_WORKTREE_MIN_FREE_GB=0 skips this check.'
769
- );
770
- error.code = 'ATRIS_LOW_DISK';
771
- error.space = room;
772
- throw error;
773
- }
774
- return room;
775
- }
776
-
777
- // When the disk is checked before a copy is cut: spaceGuard says so, else anywhere but a temp folder.
778
- function checkRoomForCopy({ root, target, freeSpace, env, spaceGuard, cleanupBudget = null }) {
779
- return (spaceGuard ?? !space.isScratchPath(target))
780
- ? ensureRoomForCopy({ root, target, freeSpace, env, cleanupBudget })
781
- : null;
782
- }
783
-
784
- // The question worktree start asks about the disk, asked on its own so a flight can ask it once before it
785
- // claims anything: the same floor, the same reaping of finished copies, the same words. null when there is
786
- // room, when the disk cannot be read, or when a copy would land in a temp folder. Otherwise the sentence
787
- // worktree start would refuse with, and the numbers behind it. Any other trouble is left for worktree start
788
- // to report when it runs. target names the folder the build will use when it is not worktree start's usual
789
- // one (a sealed review sandbox is cut in the temp folder).
790
- function lowDiskRefusal({ root = repoRoot(), member = '', agent = '', task = 'flight', now = new Date(), env = process.env, freeSpace = space.freeBytes, spaceGuard, target = '' } = {}) {
791
- try {
792
- checkRoomForCopy({ root, target: target ? path.resolve(target) : path.resolve(defaultWorktreePath(root, member || agent, task, now)), freeSpace, env, spaceGuard });
793
- return null;
794
- } catch (error) {
795
- if (!error || error.code !== 'ATRIS_LOW_DISK') return null;
796
- return { message: error.message, free: error.space.free, needs: error.space.refuseBelow };
797
- }
798
- }
799
-
800
97
  function startWorktree(args) {
801
98
  const root = repoRoot();
802
99
  const member = readFlag(args, '--member');
@@ -807,8 +104,6 @@ function startWorktree(args) {
807
104
  console.error('Usage: atris worktree start --member <member>|--agent <name> --task "<short task>" [--former-task "<older name of the task>"] [--claim] [--force] [--no-deps]');
808
105
  return 2;
809
106
  }
810
- // The task's older name, when it has gone by one. It is only mentioned: an unfinished branch under
811
- // it is named in one line, never resumed and never a collision (see createAgentWorktree).
812
107
  const formerTask = readFlag(args, '--former-task');
813
108
  const memberFile = member ? path.join(root, 'atris', 'team', member, 'MEMBER.md') : '';
814
109
  if (memberFile && !fs.existsSync(memberFile)) {
@@ -890,56 +185,6 @@ function startWorktree(args) {
890
185
  return 0;
891
186
  }
892
187
 
893
- function createOrFindPr(root, branch, targetRef, title, dryRun) {
894
- const targetBranch = baseBranchName(targetRef);
895
- // `gh pr view` (no --state filter) returns ANY PR for this branch name,
896
- // including one already MERGED or CLOSED. A worktree branch that ships
897
- // more than once (this same branch, more commits, another `worktree ship`)
898
- // would then reuse the dead PR number: `gh pr merge <dead pr> --merge`
899
- // exits 0 with just a warning (never fails), so the new commits silently
900
- // never land while the ship command still reports success. Only reuse a
901
- // PR that is still OPEN; anything else gets a fresh one.
902
- const existing = spawnSync('gh', ['pr', 'view', '--json', 'number,url,state', '--jq', '"\\(.number) \\(.url) \\(.state)"'], {
903
- cwd: root,
904
- encoding: 'utf8',
905
- });
906
- if (existing.status === 0 && existing.stdout.trim()) {
907
- const parts = existing.stdout.trim().split(/\s+/);
908
- const state = parts.pop();
909
- if (state === 'OPEN') return parts.join(' ');
910
- }
911
- const body = [
912
- 'Automated Atris worktree ship.',
913
- '',
914
- `Branch: ${branch}`,
915
- `Target: ${targetBranch}`,
916
- ].join('\n');
917
- if (dryRun) return `dry-run: gh pr create --base ${targetBranch} --head ${branch}`;
918
- // An engine that produced nothing leaves a branch level with its base. Asking
919
- // GitHub for a PR there fails with "No commits between ...", and that throw used
920
- // to escape the flight and abort the whole run. Say it plainly instead.
921
- const ahead = runGit(['rev-list', '--count', `${targetRef}..HEAD`], { cwd: root, check: false });
922
- if (ahead.status === 0 && String(ahead.stdout || '').trim() === '0') {
923
- console.log('pr: skipped (no commits ahead of base)');
924
- return '';
925
- }
926
- const created = spawnSync(
927
- 'gh',
928
- ['pr', 'create', '--base', targetBranch, '--head', branch, '--title', title, '--body', body],
929
- { cwd: root, encoding: 'utf8' }
930
- );
931
- if (created.status !== 0) {
932
- throw new Error((created.stderr || created.stdout || 'gh pr create failed').trim());
933
- }
934
- return created.stdout.trim();
935
- }
936
-
937
- function prMergeRef(prOutput) {
938
- const text = String(prOutput || '').trim();
939
- if (!text || text.startsWith('dry-run:')) return '';
940
- return text.split(/\s+/)[0];
941
- }
942
-
943
188
  function shipHelp() {
944
189
  console.log('Usage: atris worktree ship --message "<commit>" --verify "<cmd>" [--merge] [--target <ref>] [--local]');
945
190
  console.log('');
@@ -948,171 +193,11 @@ function shipHelp() {
948
193
  console.log(' protected-lane tasks (billing, money, deploy, feedback, ...) can be pushed for review without --merge;');
949
194
  console.log(' landing one with --merge or --local needs --allow-denied-lane on written order');
950
195
  console.log(' --no-map-refresh skip the automatic refresh of moved map line references');
196
+ console.log(' --keep-copy keep this build copy; by default it is removed once the work lands or is pushed');
951
197
  console.log(' unstaged regenerated adapter files are skipped unless staged first or named in --message');
952
198
  console.log(' recommended flight verify: npm run test:fast && node --test <focused files>');
953
199
  }
954
200
 
955
- function deniedLaneForShip(root, branch, message) {
956
- try {
957
- const { protectedLaneOfTask } = require('../lib/fleet');
958
- const refs = new Set();
959
- for (const source of [String(branch || ''), String(message || '')]) {
960
- for (const m of source.toUpperCase().matchAll(/([A-Z]{2,6}-\d+)/g)) refs.add(m[1]);
961
- for (const m of source.toLowerCase().matchAll(/([a-z]{2,6})-(\d+)/g)) refs.add(`${m[1].toUpperCase()}-${m[2]}`);
962
- }
963
- // A flight's branch names its job by row id, lowercased, not by the display id a person reads
964
- // (flightTaskName in lib/fleet.js), so a branch also finds a job by its row id as a whole word.
965
- const words = new Set(String(branch || '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
966
- const projectionPath = path.join(findPrimaryRoot(root), '.atris', 'state', 'tasks.projection.json');
967
- if (!fs.existsSync(projectionPath)) return null;
968
- const projection = JSON.parse(fs.readFileSync(projectionPath, 'utf8'));
969
- for (const task of projection.tasks || []) {
970
- const ids = [task.display_id, task.legacy_ref].filter(Boolean).map(v => String(v).toUpperCase());
971
- // Only a real row id counts: a short or odd one could match any word in a branch name.
972
- const rowId = typeof task.id === 'string' && /^[A-Za-z0-9]{16,}$/.test(task.id) ? task.id.toLowerCase() : '';
973
- if (!ids.some(id => refs.has(id)) && !(rowId && words.has(rowId))) continue;
974
- const hit = protectedLaneOfTask(task);
975
- if (hit) return { taskId: ids[0] || task.id, tag: hit };
976
- }
977
- return null;
978
- } catch {
979
- return null;
980
- }
981
- }
982
-
983
- // Pushing a protected-lane branch for review lands nothing, unless the branch
984
- // already has a pull request with GitHub auto-merge on: that request merges
985
- // itself the moment its checks pass, and the push is what feeds it. A branch
986
- // that shipped earlier with --merge and was retagged into a protected lane since
987
- // would land with no person. One branch can have several open pull requests (one
988
- // per base), and a single push feeds every one of them, so this asks for the whole
989
- // list: `gh pr view` shows just one, and it may be the one without auto-merge.
990
- // Returns the words to refuse with, or '' when the push is safe. It fails closed:
991
- // if gh cannot say, or the list may be incomplete, nothing is pushed.
992
- const OPEN_PR_LIST_LIMIT = 100;
993
-
994
- function protectedPushRefusal(root, branch, run = spawnSync) {
995
- const unknown = (why) => `blocked: cannot tell whether ${branch} has a pull request with auto-merge on (${why}); nothing was pushed`;
996
- // A minute is plenty for one lookup; a gh that hangs must not hang the ship.
997
- const listed = run('gh', [
998
- 'pr', 'list', '--head', branch, '--state', 'open',
999
- '--json', 'number,url,baseRefName,autoMergeRequest',
1000
- '--limit', String(OPEN_PR_LIST_LIMIT),
1001
- ], { cwd: root, encoding: 'utf8', timeout: 60000 });
1002
- if (listed.error) {
1003
- return unknown(listed.error.code === 'ETIMEDOUT' ? 'gh took longer than a minute to answer' : `gh did not run: ${listed.error.message}`);
1004
- }
1005
- if (listed.status !== 0) {
1006
- // No branch has an empty answer to read as "none": an empty list is an
1007
- // exit 0, so a failed lookup is never taken for a clear.
1008
- const said = `${listed.stderr || ''}\n${listed.stdout || ''}`;
1009
- const firstLine = said.split(/\r?\n/).map((line) => line.trim()).find(Boolean);
1010
- return unknown(firstLine || `gh exited ${listed.status}`);
1011
- }
1012
- let open;
1013
- try { open = JSON.parse(String(listed.stdout || '')); } catch { open = null; }
1014
- // Every entry must say whether auto-merge is on (null means off), or the
1015
- // answer is not one this check can lean on.
1016
- const readable = Array.isArray(open)
1017
- && open.every((pr) => pr && typeof pr === 'object' && !Array.isArray(pr) && Object.prototype.hasOwnProperty.call(pr, 'autoMergeRequest'));
1018
- if (!readable) return unknown("gh's answer could not be read");
1019
- // gh stops at its limit without saying so: a full page may be only part of the list.
1020
- if (open.length >= OPEN_PR_LIST_LIMIT) return unknown(`${open.length} open pull requests may have been cut short`);
1021
- const armed = open.filter((pr) => pr.autoMergeRequest);
1022
- if (!armed.length) return '';
1023
- const name = (pr) => `${pr.url || (pr.number ? `#${pr.number}` : branch)}${pr.baseRefName ? ` (into ${pr.baseRefName})` : ''}`;
1024
- const off = (pr) => `gh pr merge ${pr.number || pr.url || branch} --disable-auto`;
1025
- if (armed.length === 1) {
1026
- return `blocked: pull request ${name(armed[0])} has auto-merge on, so pushing ${branch} would land it with no person; turn auto-merge off first (${off(armed[0])}), then ship again`;
1027
- }
1028
- const names = armed.map(name);
1029
- return `blocked: pull requests ${names.slice(0, -1).join(', ')} and ${names[names.length - 1]} have auto-merge on, so pushing ${branch} would land them with no person; turn auto-merge off on each first (${armed.map(off).join(', ')}), then ship again`;
1030
- }
1031
-
1032
- function findPrimaryRoot(root) {
1033
- const worktrees = listWorktrees(root);
1034
- return worktrees[0]?.path || root;
1035
- }
1036
-
1037
- function shellQuote(value) {
1038
- const text = String(value || '');
1039
- if (/^[A-Za-z0-9_./:@+-]+$/.test(text)) return text;
1040
- return `'${text.replace(/'/g, `'"'"'`)}'`;
1041
- }
1042
-
1043
- function experimentHealthProbe(statePath) {
1044
- const script = [
1045
- "const fs=require('fs')",
1046
- 'const state=JSON.parse(fs.readFileSync(process.argv[1],\'utf8\'))',
1047
- 'const last=Date.parse(state.last_run_date)',
1048
- `process.exit(Number.isFinite(last)&&Date.now()-last<=${DAY_MS}?0:1)`,
1049
- ].join(';');
1050
- return `${shellQuote(process.execPath)} -e ${shellQuote(script)} ${shellQuote(statePath)}`;
1051
- }
1052
-
1053
- function runShipHealthCheck(workspaceRoot, options = {}) {
1054
- try {
1055
- const now = options.now ? new Date(options.now) : new Date();
1056
- const statePath = path.join(workspaceRoot, '.atris', 'state', 'experiments-daily.json');
1057
- const state = fs.existsSync(statePath)
1058
- ? JSON.parse(fs.readFileSync(statePath, 'utf8'))
1059
- : {};
1060
- const rawLastRunDate = state && state.last_run_date;
1061
- const lastRunMs = rawLastRunDate ? Date.parse(rawLastRunDate) : null;
1062
- if (rawLastRunDate && !Number.isFinite(lastRunMs)) {
1063
- throw new Error('experiment state date is unreadable');
1064
- }
1065
-
1066
- const ageMs = Number.isFinite(lastRunMs) ? now.getTime() - lastRunMs : null;
1067
- if (ageMs !== null && ageMs <= DAY_MS) return { healthy: true, filed: false };
1068
-
1069
- const days = ageMs === null ? 1 : Math.max(1, Math.floor(ageMs / DAY_MS));
1070
- console.log(`health check failed: no verified experiment in ${days} days. shipping still works, the metabolism does not.`);
1071
- const filed = close.upsertSourceFlag({
1072
- what: 'no verified experiment has run in over a day',
1073
- owner: 'operator',
1074
- lane: 'code',
1075
- ttlDays: 1,
1076
- source: SHIP_HEALTH_SOURCE,
1077
- closeCondition: 'a verified experiment ran within 24 hours',
1078
- probe: experimentHealthProbe(statePath),
1079
- }, workspaceRoot, { now });
1080
- return { healthy: false, ...filed };
1081
- } catch (error) {
1082
- console.log(`health check skipped: ${String(error && error.message || error).toLowerCase()}. shipping still works.`);
1083
- return { healthy: null, filed: false, error };
1084
- }
1085
- }
1086
-
1087
- const MAP_REFRESH_COMMIT_MESSAGE = 'Refresh map references that moved';
1088
- const MAP_REFRESH_TRAILER = 'Co-authored-by: Atris <299057014+atris-builder[bot]@users.noreply.github.com>';
1089
-
1090
- // Rewrites map line refs whose code moved (the same fix `atris doc-health
1091
- // --fix-refs` applies), then commits only the rewritten map files. A human-only
1092
- // remainder or an error never blocks the ship; pass --no-map-refresh to skip.
1093
- function refreshMovedMapRefs(root, args = []) {
1094
- if (hasFlag(args, '--no-map-refresh')) return { skipped: true, refreshed: 0, left: 0 };
1095
- try {
1096
- const { changes, left } = fixMapDocs(root);
1097
- if (changes.length) {
1098
- const docs = [...new Set(changes.map(change => change.doc))];
1099
- runGit(['add', '--', ...docs], { cwd: root });
1100
- runGit(['commit', '-m', MAP_REFRESH_COMMIT_MESSAGE, '-m', MAP_REFRESH_TRAILER], { cwd: root });
1101
- console.log(`map: refreshed ${changes.length} moved references`);
1102
- } else {
1103
- console.log('map: references current');
1104
- }
1105
- if (left.length) {
1106
- console.log(`map: ${left.length} references need a human; run atris doc-health --fix-refs to see them`);
1107
- }
1108
- return { skipped: false, refreshed: changes.length, left: left.length };
1109
- } catch (error) {
1110
- const message = String((error && error.message) || error).toLowerCase().replace(/\s+/g, ' ').trim();
1111
- console.log(`map: refresh skipped (${message}); the ship continues`);
1112
- return { skipped: false, refreshed: 0, left: 0, error };
1113
- }
1114
- }
1115
-
1116
201
  function shipWorktree(args) {
1117
202
  if (hasFlag(args, '--help') || hasFlag(args, '-h') || args[0] === 'help') {
1118
203
  shipHelp();
@@ -1123,6 +208,7 @@ function shipWorktree(args) {
1123
208
  const noPr = hasFlag(args, '--no-pr');
1124
209
  const localMode = hasFlag(args, '--local') || hasFlag(args, '--local-merge');
1125
210
  const merge = hasFlag(args, '--merge');
211
+ const keepCopy = hasFlag(args, '--keep-copy');
1126
212
  const message = readFlag(args, '--message') || readFlag(args, '-m');
1127
213
  const verify = readFlag(args, '--verify');
1128
214
  let branch = currentBranch(root);
@@ -1146,13 +232,6 @@ function shipWorktree(args) {
1146
232
  console.error('blocked: ship from an isolated worktree, not the primary checkout');
1147
233
  return 2;
1148
234
  }
1149
- // Denied lanes are checked at dispatch, but a flight that was staffed
1150
- // before a task entered a denied lane (or killed mid-flight) could still
1151
- // ship: a payments command self-landed twice this way on 2026-07-26. The
1152
- // ship gate is the last door, so it re-checks. It guards landing only:
1153
- // pushing the branch and opening a pull request lands nothing, and that is
1154
- // how protected work reaches a person to review. The one exception is a
1155
- // pull request that already has auto-merge on, which a push would feed.
1156
235
  if (!hasFlag(args, '--allow-denied-lane')) {
1157
236
  const denied = deniedLaneForShip(root, branch, message);
1158
237
  if (denied && (merge || localMode)) {
@@ -1186,8 +265,6 @@ function shipWorktree(args) {
1186
265
  refreshRemoteRef(root, targetRef);
1187
266
  const skippedAdapters = restoreRegeneratedAdapterChurn(root, message, { dryRun });
1188
267
  const ignoredUnstagedFiles = dryRun ? new Set(skippedAdapters) : new Set();
1189
- // Conductor plumbing must not make the checkout look dirty, or a flight where the
1190
- // engine wrote nothing commits the fleet's own prompt file and calls it work.
1191
268
  const counts = statusCounts(root, { ignoredUnstagedFiles, ignoreUntracked: isConductorUntracked })
1192
269
  || { staged: 0, unstaged: 0, untracked: 0 };
1193
270
  const dirty = counts.staged || counts.unstaged || counts.untracked;
@@ -1200,8 +277,6 @@ function shipWorktree(args) {
1200
277
  console.log(`commit: ${message}`);
1201
278
  if (!dryRun) {
1202
279
  runGit(['add', '-A'], { cwd: root });
1203
- // `add -A` sweeps in the fleet's own prompt file and friends. Drop them from the
1204
- // index so a work commit carries work, not harness plumbing. Safe when absent.
1205
280
  unstageConductorArtifacts(root);
1206
281
  if (!proofTreeMatches('index')) return 3;
1207
282
  runGit(['commit', '-m', message], { cwd: root });
@@ -1224,8 +299,6 @@ function shipWorktree(args) {
1224
299
  if (!dryRun) runCommand(verify, { cwd: root });
1225
300
  const afterVerifyRows = dryRun ? [] : statusSnapshot(root);
1226
301
  const verifyDelta = newStatusRows(beforeVerify, afterVerifyRows);
1227
- // This check exists to catch a verifier that writes files. Conductor plumbing was
1228
- // already present before the verifier ran, so counting it here blocks a clean ship.
1229
302
  const afterVerify = dryRun
1230
303
  ? { staged: 0, unstaged: 0, untracked: 0 }
1231
304
  : statusCounts(root, { ignoreUntracked: isConductorUntracked })
@@ -1255,7 +328,6 @@ function shipWorktree(args) {
1255
328
  }
1256
329
  console.log(`merge_check: ${targetRef} clean`);
1257
330
 
1258
- // Set once the change is confirmed merged; the build copy then goes away.
1259
331
  let landedOn = '';
1260
332
  if (localMode) {
1261
333
  console.log('push: skipped (local mode)');
@@ -1284,7 +356,7 @@ function shipWorktree(args) {
1284
356
  }
1285
357
  console.log('pr: skipped (local mode)');
1286
358
  if (merge && !dryRun) runShipHealthCheck(primary || root);
1287
- if (landedOn) announceShippedCopyRemoval(scheduleShippedCopyRemoval({ root, primary, branch, base: landedOn }));
359
+ if (landedOn) finishShippedCopy({ root, primary, branch, landedOn, keep: keepCopy });
1288
360
  console.log('done: worktree shipped');
1289
361
  return 0;
1290
362
  }
@@ -1305,13 +377,9 @@ function shipWorktree(args) {
1305
377
  mergeArgs.push('--merge');
1306
378
  const merged = mergePrWithRetry(mergeArgs, root);
1307
379
  if (merged.status !== 0) throw new Error((merged.stderr || merged.stdout || 'gh pr merge failed').trim());
1308
- // `gh pr merge` on an already-merged PR exits 0 with a warning
1309
- // instead of failing, which would otherwise report a false landing.
1310
380
  if (/already merged/i.test(`${merged.stdout}${merged.stderr}`)) {
1311
381
  throw new Error(`gh pr merge reported the PR was already merged (stale PR reused): ${(merged.stdout || merged.stderr).trim()}`);
1312
382
  }
1313
- // A merge queue accepts the request before merging. Deleting its head
1314
- // here closes the PR and cancels that request, so confirm the outcome.
1315
383
  const confirmation = spawnSync('gh', ['pr', 'view', mergeRef, '--json', 'state,mergedAt,number,url,mergeCommit'], { cwd: root, encoding: 'utf8' });
1316
384
  if (confirmation.status !== 0) {
1317
385
  throw new Error('could not confirm the merge; the source branch was kept');
@@ -1322,14 +390,13 @@ function shipWorktree(args) {
1322
390
  }
1323
391
  if (state.state === 'OPEN' && !state.mergedAt) {
1324
392
  console.log('merge: pending; kept the source branch while required checks finish');
393
+ finishShippedCopy({ root, primary, branch, keep: keepCopy });
1325
394
  return 0;
1326
395
  }
1327
396
  if (state.state !== 'MERGED' || !state.mergedAt) {
1328
397
  throw new Error('the change was not merged; the source branch was kept');
1329
398
  }
1330
399
  console.log('merge: merged');
1331
- // One machine-readable line, so a caller can save which pull request
1332
- // and commit landed. The rating only counts a landing it can point at.
1333
400
  const record = shipMergeRecord(state);
1334
401
  if (record) console.log(`merge: record ${JSON.stringify(record)}`);
1335
402
  landedOn = targetRef;
@@ -1353,117 +420,25 @@ function shipWorktree(args) {
1353
420
  }, { worktree: root });
1354
421
  } catch {}
1355
422
  if (merge && !dryRun) runShipHealthCheck(primary || root);
1356
- if (landedOn) announceShippedCopyRemoval(scheduleShippedCopyRemoval({ root, primary, branch, base: landedOn }));
423
+ if (!dryRun) finishShippedCopy({ root, primary, branch, landedOn, keep: keepCopy });
1357
424
  console.log('done: worktree shipped');
1358
425
  return 0;
1359
426
  }
1360
427
 
1361
- // A shipped build copy is dead weight, but the engine that shipped it is
1362
- // still inside and writes its last message there on exit. A detached
1363
- // `atris worktree reap-shipped` waits until nothing has run in the copy for
1364
- // SHIP_SETTLE_MS, then removes it if it is still merged and clean.
1365
- const SHIP_SETTLE_MS = 10 * 60 * 1000;
1366
- const SHIP_REAP_POLL_MS = 60 * 1000;
1367
- const SHIP_REAP_GIVE_UP_MS = 6 * 60 * 60 * 1000;
1368
- // Right after a merge the local copy of the target can lag; allow a short
1369
- // wait before treating the copy as unmerged and leaving it for the sweep.
1370
- const SHIP_REAP_UNMERGED_GRACE_MS = 15 * 60 * 1000;
1371
- // Reasons that can clear on their own: someone is still inside, or the
1372
- // main branch moved or could not be reached during the check.
1373
- const SHIP_REAP_RETRY_REASONS = new Set([
1374
- 'active_process', 'process_scan_failed', 'recent_activity', 'base_refresh_failed', 'base_unverified',
1375
- 'ignored_scan_failed', 'changed_or_active_during_verification',
1376
- ]);
1377
-
1378
- function shippedCopyRemovalPlan({ root, primary, env = process.env, atrisBin = ATRIS_BIN }) {
1379
- const copy = path.resolve(root);
1380
- if (env.ATRIS_WORKTREE_KEEP_AFTER_SHIP === '1') return { skip: 'ATRIS_WORKTREE_KEEP_AFTER_SHIP=1', announce: true };
1381
- if (!primary || canonicalPath(copy) === canonicalPath(primary)) return { skip: 'primary checkout' };
1382
- if (!space.isAgentCopyPath(copy)) return { skip: 'not an agent build copy' };
1383
- if (space.isScratchPath(copy)) return { skip: 'temp folder' };
1384
- // The removal runs this same atris; one that lives in the copy would delete itself.
1385
- if (space.isInside(atrisBin, copy)) return { skip: 'this atris runs from inside the copy', announce: true };
1386
- return { copy };
1387
- }
1388
-
1389
- function scheduleShippedCopyRemoval({ root, primary, branch, base, env = process.env, spawnProcess = spawn }) {
1390
- const plan = shippedCopyRemovalPlan({ root, primary, env });
1391
- if (plan.skip) return plan;
1392
- try {
1393
- // The copy rides in the environment, not the arguments, so the process
1394
- // scan does not count this waiter as someone working in the copy.
1395
- const child = spawnProcess(process.execPath, [ATRIS_BIN, 'worktree', 'reap-shipped'], {
1396
- cwd: primary,
1397
- detached: true,
1398
- stdio: 'ignore',
1399
- env: { ...env, ATRIS_SKIP_UPDATE_CHECK: '1', ATRIS_REAP_WORKTREE: plan.copy, ATRIS_REAP_BRANCH: branch || '', ATRIS_REAP_BASE: base || '' },
1400
- });
1401
- if (child && typeof child.on === 'function') child.on('error', () => {});
1402
- if (child && typeof child.unref === 'function') child.unref();
1403
- return { ...plan, scheduled: true };
1404
- } catch (error) {
1405
- return { skip: `could not start the removal: ${String((error && error.message) || error)}`, announce: true };
1406
- }
1407
- }
1408
-
1409
- function announceShippedCopyRemoval(plan) {
1410
- if (plan.scheduled) {
1411
- console.log(`cleanup: this build copy is removed once nothing has run in it for ${Math.round(SHIP_SETTLE_MS / 60000)} minutes`);
1412
- } else if (plan.announce) {
1413
- console.log(`cleanup: kept this build copy (${plan.skip})`);
1414
- }
1415
- }
1416
-
1417
- function reapShippedCopy({
1418
- root,
1419
- target,
1420
- base = '',
1421
- settleMs = SHIP_SETTLE_MS,
1422
- pollMs = SHIP_REAP_POLL_MS,
1423
- giveUpMs = SHIP_REAP_GIVE_UP_MS,
1424
- unmergedGraceMs = SHIP_REAP_UNMERGED_GRACE_MS,
1425
- sleep = sleepSync,
1426
- clock = Date.now,
1427
- cleanup: cleanupFn = cleanupWorktrees,
1428
- } = {}) {
1429
- const startedAt = clock();
1430
- sleep(settleMs);
1431
- for (;;) {
1432
- const result = cleanupFn({
1433
- root, base, apply: true, onlyPaths: [target], freshGraceMs: 0, idleMs: settleMs, by: 'atris worktree ship', now: clock(),
1434
- });
1435
- if (result.removed.length) return { removed: true, reason: result.removed[0].reason };
1436
- const entry = result.kept.find((item) => canonicalPath(item.path) === canonicalPath(target));
1437
- if (!entry) return { removed: false, reason: 'not_registered' };
1438
- const waited = clock() - startedAt;
1439
- const retry = SHIP_REAP_RETRY_REASONS.has(entry.reason) || (entry.reason === 'unmerged' && waited < unmergedGraceMs);
1440
- if (!retry || waited >= giveUpMs) return { removed: false, reason: entry.reason };
1441
- sleep(pollMs);
1442
- }
1443
- }
1444
-
1445
428
  function reapShipped() {
1446
429
  const target = process.env.ATRIS_REAP_WORKTREE || '';
1447
430
  if (!target) {
1448
431
  console.error('reap-shipped: ATRIS_REAP_WORKTREE is not set');
1449
432
  return 2;
1450
433
  }
1451
- const result = reapShippedCopy({ root: process.cwd(), target, base: process.env.ATRIS_REAP_BASE || '' });
434
+ const base = process.env.ATRIS_REAP_BASE || '';
435
+ const result = base
436
+ ? reapShippedCopy({ root: process.cwd(), target, base })
437
+ : reapPushedCopy({ primary: process.cwd(), target, branch: process.env.ATRIS_REAP_BRANCH || '' });
1452
438
  console.log(`reap-shipped: ${result.removed ? 'removed' : 'kept'} ${target} (${result.reason})`);
1453
439
  return result.removed ? 0 : 1;
1454
440
  }
1455
441
 
1456
- // The landed pull request from `gh pr view --json`, or null without a merge commit.
1457
- function shipMergeRecord(state) {
1458
- const commit = state && state.mergeCommit && typeof state.mergeCommit === 'object' ? state.mergeCommit.oid : null;
1459
- if (typeof commit !== 'string' || !/^[0-9a-f]{7,64}$/i.test(commit)) return null;
1460
- const record = { merge_commit: commit };
1461
- if (Number.isInteger(state.number) && state.number > 0) record.number = state.number;
1462
- if (typeof state.url === 'string' && state.url) record.url = state.url;
1463
- if (typeof state.mergedAt === 'string' && state.mergedAt) record.merged_at = state.mergedAt;
1464
- return record;
1465
- }
1466
-
1467
442
  function guard(args) {
1468
443
  const root = repoRoot();
1469
444
  const worktrees = listWorktrees(root);
@@ -1473,9 +448,6 @@ function guard(args) {
1473
448
  console.error('run: atris worktree start --member <member>|--agent <name> --task "<short task>" --claim');
1474
449
  return 2;
1475
450
  }
1476
- // Conductor plumbing (worktree metadata, the fleet prompt written for this very
1477
- // engine, runtime scratch, the brief ledger) must not count as dirt, otherwise the
1478
- // harness blocks the engine with the harness's own files.
1479
451
  const counts = statusCounts(root, { ignoreUntracked: isConductorUntracked });
1480
452
  if (counts && (counts.staged || counts.unstaged || counts.untracked) && !hasFlag(args, '--allow-dirty')) {
1481
453
  console.error(`blocked: checkout is dirty staged=${counts.staged} unstaged=${counts.unstaged} untracked=${counts.untracked}`);
@@ -1494,503 +466,6 @@ function prune(args) {
1494
466
  return result.status || 0;
1495
467
  }
1496
468
 
1497
- const PROTECTED_BRANCHES = new Set(['main', 'master']);
1498
-
1499
- // A worktree fresh off `worktree start` is merged into base by definition
1500
- // (zero commits yet), so without a grace window the janitor reaps it while
1501
- // the engine that requested it is still booting inside.
1502
- const WORKTREE_REAP_GRACE_MS = 60 * 60 * 1000;
1503
- const GENERATED_WORKTREE_CACHE_DIRS = [
1504
- '.derivedData',
1505
- '.next',
1506
- '.swiftpm',
1507
- '.cache/swiftpm',
1508
- 'node_modules/.cache',
1509
- ];
1510
-
1511
- function worktreeWithinReapGrace(worktreePath, now = Date.now(), graceMs = WORKTREE_REAP_GRACE_MS) {
1512
- try {
1513
- return now - fs.statSync(worktreePath).mtimeMs < graceMs;
1514
- } catch {
1515
- return false;
1516
- }
1517
- }
1518
-
1519
- // Newest sign of work in a copy: its folder, its branch tip and reflog, web
1520
- // build output, and the engine's last message. The index is left out on
1521
- // purpose: a plain `git status` rewrites it.
1522
- function lastCopyActivityMs(worktreePath) {
1523
- const marks = [worktreePath, path.join(worktreePath, '.next'), path.join(worktreePath, '.codex-last-message.txt')];
1524
- try {
1525
- const pointer = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(path.join(worktreePath, '.git'), 'utf8'));
1526
- if (pointer) {
1527
- const gitDir = path.resolve(worktreePath, pointer[1].trim());
1528
- marks.push(path.join(gitDir, 'HEAD'), path.join(gitDir, 'logs', 'HEAD'));
1529
- }
1530
- } catch {}
1531
- let newest = 0;
1532
- for (const mark of marks) {
1533
- try {
1534
- newest = Math.max(newest, fs.statSync(mark).mtimeMs);
1535
- } catch {}
1536
- }
1537
- return newest;
1538
- }
1539
-
1540
- function canonicalPath(value) {
1541
- try {
1542
- return fs.realpathSync(value);
1543
- } catch {
1544
- return path.resolve(value);
1545
- }
1546
- }
1547
-
1548
- // null means the scan failed: we cannot tell who is inside, so callers keep
1549
- // every folder. An empty list would read as "nobody is here" and delete.
1550
- function listActiveProcessFiles() {
1551
- const result = spawnSync('lsof', ['-n', '-F', 'n'], {
1552
- encoding: 'utf8',
1553
- maxBuffer: COMMAND_MAX_BUFFER_BYTES,
1554
- timeout: 20000,
1555
- killSignal: 'SIGKILL',
1556
- });
1557
- if (result.error || result.status !== 0 || String(result.stderr || '').trim()) return null;
1558
- const paths = String(result.stdout || '')
1559
- .split(/\r?\n/)
1560
- .filter((line) => line.startsWith('n/'))
1561
- .map((line) => canonicalPath(line.slice(1)))
1562
- .filter(Boolean);
1563
- return paths.length ? paths : null;
1564
- }
1565
-
1566
- // Absolute paths named on each program's command line, including flag values
1567
- // like --require=/x.js. A program can run a script from a work folder while
1568
- // its current folder is elsewhere; the folder scan alone misses it. The list
1569
- // prints arguments unquoted, so a path with a space is also kept as the rest
1570
- // of the line from where it starts; the folder match is a prefix check.
1571
- function parseProcessArgPaths(psOutput) {
1572
- const paths = new Set();
1573
- const starts = /(?:^|\s)(?:[^\s=]*=)?(?=\/)/g;
1574
- for (const raw of String(psOutput || '').split(/\r?\n/)) {
1575
- const line = raw.trim();
1576
- for (const match of line.matchAll(starts)) {
1577
- const rest = line.slice(match.index + match[0].length);
1578
- const token = rest.split(/\s/, 1)[0];
1579
- if (token.length > 1) paths.add(token);
1580
- if (rest.length > token.length) paths.add(rest);
1581
- }
1582
- }
1583
- return [...paths];
1584
- }
1585
-
1586
- function listActiveProcessArgPaths() {
1587
- const result = spawnSync('ps', ['-axww', '-o', 'args='], {
1588
- encoding: 'utf8',
1589
- maxBuffer: COMMAND_MAX_BUFFER_BYTES,
1590
- timeout: 5000,
1591
- killSignal: 'SIGKILL',
1592
- });
1593
- if (result.error || result.status !== 0) return null;
1594
- return parseProcessArgPaths(result.stdout);
1595
- }
1596
-
1597
- // Open files and working folders, plus script paths on command lines.
1598
- function listActiveProcessPaths() {
1599
- const files = listActiveProcessFiles();
1600
- if (!Array.isArray(files)) return null;
1601
- const args = listActiveProcessArgPaths();
1602
- if (!Array.isArray(args)) return null;
1603
- return files.concat(args);
1604
- }
1605
-
1606
- function worktreeHasActiveProcess(worktreePath, activeCwds) {
1607
- if (!Array.isArray(activeCwds)) return true;
1608
- const resolved = canonicalPath(worktreePath);
1609
- const prefix = `${resolved}${path.sep}`;
1610
- return activeCwds.some((cwd) => {
1611
- const active = canonicalPath(cwd);
1612
- return active === resolved || active.startsWith(prefix);
1613
- });
1614
- }
1615
-
1616
- // Every work-folder removal leaves one line naming who removed it and why, in
1617
- // the main checkout's state so the line outlives the folder it describes.
1618
- function recordWorktreeRemoval(root, { path: removedPath, branch = '', by, reason }) {
1619
- try {
1620
- const common = runGit(['rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd: root, check: false });
1621
- const main = common.status === 0 ? path.dirname(common.stdout.trim()) : root;
1622
- const file = path.join(main, '.atris', 'state', 'worktree_removals.jsonl');
1623
- fs.mkdirSync(path.dirname(file), { recursive: true });
1624
- fs.appendFileSync(file, `${JSON.stringify({
1625
- at: new Date().toISOString(), path: removedPath, branch: branch || null, by, reason, pid: process.pid,
1626
- })}\n`);
1627
- } catch {}
1628
- }
1629
-
1630
- // Only webpack's disposable pack files have a sufficiently narrow contract
1631
- // for unattended cleanup. Unknown files or links turn the folder into review.
1632
- function webpackCacheSnapshot(worktreePath) {
1633
- try {
1634
- const root = fs.realpathSync(worktreePath);
1635
- let current = root;
1636
- for (const part of ['.next', 'cache', 'webpack']) {
1637
- current = path.join(current, part);
1638
- const stat = fs.lstatSync(current);
1639
- if (!stat.isDirectory() || stat.isSymbolicLink()) return null;
1640
- }
1641
- if (fs.realpathSync(current) !== current) return null;
1642
- const entries = [];
1643
- function walk(dir) {
1644
- for (const name of fs.readdirSync(dir).sort()) {
1645
- const file = path.join(dir, name);
1646
- const stat = fs.lstatSync(file);
1647
- if (stat.isSymbolicLink()) return false;
1648
- if (stat.isDirectory()) {
1649
- entries.push([file, 'd', stat.ino, stat.mtimeMs]);
1650
- if (!walk(file)) return false;
1651
- } else if (stat.isFile() && /^(?:index|\d+)\.pack(?:\.gz|\.old)?$/.test(name)) {
1652
- entries.push([file, 'f', stat.ino, stat.size, stat.mtimeMs]);
1653
- } else return false;
1654
- }
1655
- return true;
1656
- }
1657
- if (!walk(current)) return null;
1658
- const size = spawnSync('du', ['-sk', current], { encoding: 'utf8', timeout: 5000, killSignal: 'SIGKILL' });
1659
- const match = size.status === 0 && String(size.stdout || '').match(/^(\d+)\s/);
1660
- if (!match) return null;
1661
- return { path: current, kib: Number(match[1]), fingerprint: JSON.stringify(entries) };
1662
- } catch {
1663
- return null;
1664
- }
1665
- }
1666
-
1667
- function pruneGeneratedWorktreeCaches(worktreePath, { apply = false, budget } = {}) {
1668
- const candidates = [];
1669
- const pruned = [];
1670
- for (const relative of GENERATED_WORKTREE_CACHE_DIRS) {
1671
- const target = path.join(worktreePath, relative);
1672
- if (!fs.existsSync(target)) continue;
1673
- const ignored = runGit(['check-ignore', '-q', '--', relative], { cwd: worktreePath, check: false });
1674
- if (ignored.status !== 0) continue;
1675
- const item = { worktree: worktreePath, path: target, relative, reason: 'manual_review_required' };
1676
- candidates.push(item);
1677
- }
1678
- const relative = '.next/cache/webpack';
1679
- const ignored = runGit(['check-ignore', '-q', '--', relative], { cwd: worktreePath, check: false });
1680
- const snapshot = ignored.status === 0 ? webpackCacheSnapshot(worktreePath) : null;
1681
- if (snapshot) {
1682
- const item = { worktree: worktreePath, path: snapshot.path, relative, kib: snapshot.kib, reason: 'generated_webpack_cache' };
1683
- candidates.push(item);
1684
- if (apply && budget && budget.count < 20 && budget.kib + snapshot.kib <= 10 * 1024 * 1024) {
1685
- const active = listActiveProcessPaths();
1686
- const fresh = webpackCacheSnapshot(worktreePath);
1687
- const stillIgnored = runGit(['check-ignore', '-q', '--', relative], { cwd: worktreePath, check: false });
1688
- if (Array.isArray(active) && !worktreeHasActiveProcess(worktreePath, active)
1689
- && stillIgnored.status === 0 && fresh && fresh.path === snapshot.path
1690
- && fresh.kib === snapshot.kib && fresh.fingerprint === snapshot.fingerprint) {
1691
- try {
1692
- fs.rmSync(snapshot.path, { recursive: true });
1693
- budget.count += 1;
1694
- budget.kib += snapshot.kib;
1695
- pruned.push(item);
1696
- } catch (error) {
1697
- item.error = String((error && error.message) || error);
1698
- }
1699
- }
1700
- }
1701
- }
1702
- return { candidates, pruned };
1703
- }
1704
-
1705
- function originBaseIsCurrent(root, base) {
1706
- const prefix = base.startsWith('origin/') ? 'origin/'
1707
- : base.startsWith('refs/remotes/origin/') ? 'refs/remotes/origin/' : '';
1708
- if (!prefix) {
1709
- if (base === 'HEAD') {
1710
- const branch = runGit(['symbolic-ref', '--short', 'HEAD'], { cwd: root, check: false });
1711
- return branch.status === 0 && PROTECTED_BRANCHES.has(branch.stdout.trim());
1712
- }
1713
- return PROTECTED_BRANCHES.has(base.replace(/^refs\/heads\//, ''));
1714
- }
1715
- const branch = base.slice(prefix.length);
1716
- const local = runGit(['rev-parse', '--verify', base], { cwd: root, check: false });
1717
- const remote = runGit(['ls-remote', '--exit-code', 'origin', `refs/heads/${branch}`], { cwd: root, check: false, timeout: 5000 });
1718
- const match = String(remote.stdout || '').match(/^([a-f0-9]{40,64})\s+(refs\/heads\/[^\r\n]+)$/m);
1719
- return local.status === 0 && remote.status === 0 && Boolean(match)
1720
- && match[2] === `refs/heads/${branch}` && local.stdout.trim() === match[1];
1721
- }
1722
-
1723
- // Scope and pace options, all off by default:
1724
- // onlyUnder / onlyPaths touch only copies inside that folder / those paths
1725
- // idleMs keep merged copies with work newer than this, and
1726
- // leave their caches alone
1727
- // freshGraceMs how long a merged copy is kept after its start
1728
- function cleanupWorktrees({
1729
- root = repoRoot(),
1730
- base: baseOverride = '',
1731
- apply = false,
1732
- activeCwds: activeCwdsOverride,
1733
- by = 'atris worktree cleanup',
1734
- onlyUnder = '',
1735
- onlyPaths = null,
1736
- idleMs = 0,
1737
- freshGraceMs = WORKTREE_REAP_GRACE_MS,
1738
- now = Date.now(),
1739
- cleanupBudget = null,
1740
- } = {}) {
1741
- const worktrees = listWorktrees(root);
1742
- const primary = worktrees[0]?.path ? path.resolve(worktrees[0].path) : '';
1743
- const current = path.resolve(root);
1744
- const activeCwds = Array.isArray(activeCwdsOverride) ? activeCwdsOverride.map(canonicalPath) : listActiveProcessPaths();
1745
- const base = normalizeTargetRef(root, baseOverride || defaultShipTarget(root));
1746
- const refreshError = refreshRemoteRef(root, base);
1747
- const candidates = [];
1748
- const kept = [];
1749
- const removed = [];
1750
- const cacheCandidates = [];
1751
- const cachePruned = [];
1752
- const cacheBudget = cleanupBudget || { count: 0, kib: 0 };
1753
- const scopePaths = Array.isArray(onlyPaths) ? onlyPaths.map(canonicalPath) : null;
1754
- const inScope = (wtPath) => (!scopePaths || scopePaths.includes(canonicalPath(wtPath)))
1755
- && (!onlyUnder || space.isInside(wtPath, onlyUnder));
1756
- const recentlyUsed = (wtPath) => Boolean(idleMs) && now - lastCopyActivityMs(wtPath) < idleMs;
1757
- const pruneCachesForKept = (wtPath) => {
1758
- if (worktreeWithinReapGrace(wtPath, now)) return;
1759
- if (recentlyUsed(wtPath)) return;
1760
- const caches = pruneGeneratedWorktreeCaches(wtPath, { apply, budget: cacheBudget });
1761
- cacheCandidates.push(...caches.candidates);
1762
- cachePruned.push(...caches.pruned);
1763
- };
1764
-
1765
- for (const wt of worktrees) {
1766
- const wtPath = path.resolve(wt.path);
1767
- const item = { path: wt.path, branch: wt.branch, head: wt.head };
1768
- if (wtPath === primary) {
1769
- kept.push({ ...item, reason: 'primary_checkout' });
1770
- continue;
1771
- }
1772
- if (wtPath === current) {
1773
- kept.push({ ...item, reason: 'current_checkout' });
1774
- continue;
1775
- }
1776
- if (!inScope(wtPath)) {
1777
- kept.push({ ...item, reason: 'out_of_scope' });
1778
- continue;
1779
- }
1780
- if (wt.locked) {
1781
- kept.push({ ...item, reason: 'locked' });
1782
- continue;
1783
- }
1784
- if (PROTECTED_BRANCHES.has(wt.branch)) {
1785
- kept.push({ ...item, reason: 'protected_branch' });
1786
- continue;
1787
- }
1788
- if (worktreeHasActiveProcess(wtPath, activeCwds)) {
1789
- kept.push({ ...item, reason: activeCwds ? 'active_process' : 'process_scan_failed' });
1790
- continue;
1791
- }
1792
- const counts = statusCounts(wt.path);
1793
- if (!counts) {
1794
- kept.push({ ...item, reason: 'missing_or_unreadable' });
1795
- continue;
1796
- }
1797
- if (counts.staged || counts.unstaged || counts.untracked) {
1798
- pruneCachesForKept(wtPath);
1799
- kept.push({ ...item, reason: 'dirty', staged: counts.staged, unstaged: counts.unstaged, untracked: counts.untracked });
1800
- continue;
1801
- }
1802
- // Git removes ignored files along with a worktree. They may include local
1803
- // state, credentials, or user data, so a clean status is not enough proof.
1804
- const ignored = runGit(['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z'], { cwd: wt.path, check: false });
1805
- if (ignored.status !== 0 || ignored.error) {
1806
- kept.push({ ...item, reason: 'ignored_scan_failed' });
1807
- continue;
1808
- }
1809
- if (ignored.stdout) {
1810
- pruneCachesForKept(wtPath);
1811
- kept.push({ ...item, reason: 'ignored_data' });
1812
- continue;
1813
- }
1814
- if (!wt.head) {
1815
- pruneCachesForKept(wtPath);
1816
- kept.push({ ...item, reason: 'missing_head' });
1817
- continue;
1818
- }
1819
- if (refreshError) {
1820
- kept.push({ ...item, reason: 'base_refresh_failed' });
1821
- continue;
1822
- }
1823
- const merged = runGit(['merge-base', '--is-ancestor', wt.head, base], { cwd: root, check: false }).status === 0;
1824
- if (!merged) {
1825
- pruneCachesForKept(wtPath);
1826
- kept.push({ ...item, reason: wt.branch === 'detached' ? 'detached_unmerged' : 'unmerged' });
1827
- continue;
1828
- }
1829
- if (freshGraceMs > 0 && worktreeWithinReapGrace(wtPath, now, freshGraceMs)) {
1830
- kept.push({ ...item, reason: 'fresh_worktree_grace' });
1831
- continue;
1832
- }
1833
- if (recentlyUsed(wtPath)) {
1834
- kept.push({ ...item, reason: 'recent_activity' });
1835
- continue;
1836
- }
1837
- if (!originBaseIsCurrent(root, base)) {
1838
- kept.push({ ...item, reason: 'base_unverified' });
1839
- continue;
1840
- }
1841
- let real;
1842
- try { real = fs.realpathSync(wtPath); } catch { real = ''; }
1843
- if (!real || real !== wtPath) {
1844
- kept.push({ ...item, reason: 'path_not_canonical' });
1845
- continue;
1846
- }
1847
- const measured = spawnSync('du', ['-sk', wtPath], { encoding: 'utf8', timeout: 30000, killSignal: 'SIGKILL' });
1848
- const sizeMatch = measured.status === 0 && !String(measured.stderr || '').trim() && String(measured.stdout || '').match(/^(\d+)\s/);
1849
- const kib = sizeMatch ? Number(sizeMatch[1]) : 0;
1850
- if (!kib || cacheBudget.count >= 20 || cacheBudget.kib + kib > 10 * 1024 * 1024) {
1851
- kept.push({ ...item, reason: 'size_unknown_or_cleanup_limit' });
1852
- continue;
1853
- }
1854
- const candidate = { ...item, reason: 'merged_into_base', size_kib: kib };
1855
- candidates.push(candidate);
1856
- cacheBudget.count += 1;
1857
- cacheBudget.kib += kib;
1858
- if (!apply) continue;
1859
- const freshActive = Array.isArray(activeCwdsOverride) ? activeCwds : listActiveProcessPaths();
1860
- const freshCounts = statusCounts(wt.path);
1861
- const freshIgnored = runGit(['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z'], { cwd: wt.path, check: false });
1862
- const freshSize = spawnSync('du', ['-sk', wtPath], { encoding: 'utf8', timeout: 30000, killSignal: 'SIGKILL' });
1863
- const freshMatch = freshSize.status === 0 && !String(freshSize.stderr || '').trim() && String(freshSize.stdout || '').match(/^(\d+)\s/);
1864
- const gitdir = runGit(['rev-parse', '--absolute-git-dir'], { cwd: wtPath, check: false });
1865
- const freshHead = runGit(['rev-parse', 'HEAD'], { cwd: wtPath, check: false });
1866
- if (!Array.isArray(freshActive) || worktreeHasActiveProcess(wtPath, freshActive)
1867
- || !freshCounts || freshCounts.staged || freshCounts.unstaged || freshCounts.untracked
1868
- || freshIgnored.status !== 0 || freshIgnored.stdout || !freshMatch || Number(freshMatch[1]) !== kib
1869
- || gitdir.status !== 0 || !gitdir.stdout.trim() || fs.existsSync(path.join(gitdir.stdout.trim(), 'locked'))
1870
- || freshHead.status !== 0 || String(freshHead.stdout || '').trim() !== wt.head
1871
- || runGit(['merge-base', '--is-ancestor', wt.head, base], { cwd: root, check: false }).status !== 0
1872
- || !originBaseIsCurrent(root, base)
1873
- || canonicalPath(wtPath) !== wtPath) {
1874
- kept.push({ ...item, reason: 'changed_or_active_during_verification' });
1875
- continue;
1876
- }
1877
- const removedResult = runGit(['worktree', 'remove', wt.path], { cwd: root, check: false });
1878
- if (removedResult.status === 0) {
1879
- removed.push(candidate);
1880
- recordWorktreeRemoval(root, { path: wt.path, branch: wt.branch, by, reason: candidate.reason });
1881
- } else {
1882
- kept.push({ ...item, reason: 'remove_failed', error: (removedResult.stderr || removedResult.stdout || '').trim() });
1883
- }
1884
- }
1885
-
1886
- return { apply, base, candidates, removed, kept, cacheCandidates, cachePruned };
1887
- }
1888
-
1889
- // The sweep: cleanup for every repo with build copies in the workspace.
1890
- // Removes copies that pass every cleanup check (merged into origin's main
1891
- // branch, nothing changed, untracked, or ignored, nothing running inside)
1892
- // and are unused for idleMs; clears verified webpack caches in the idle
1893
- // copies it keeps. Shared installs and unmerged work stay for review.
1894
- const SWEEP_IDLE_MS = 2 * 60 * 60 * 1000;
1895
- const SWEEP_EVERY_HOURS = 3;
1896
- const SWEEP_HIDDEN_KEPT = new Set(['primary_checkout', 'current_checkout', 'out_of_scope']);
1897
-
1898
- function sweepWorkspace({ workspace, apply = false, idleMs = SWEEP_IDLE_MS, activeCwds, freeSpace = space.freeBytes, by = 'atris worktree sweep', cleanupBudget = null } = {}) {
1899
- const root = path.resolve(workspace);
1900
- const before = freeSpace(root);
1901
- const report = { workspace: root, apply, idle_ms: idleMs, before, after: before, repos: [] };
1902
- const scan = Array.isArray(activeCwds) ? activeCwds.map(canonicalPath) : listActiveProcessPaths();
1903
- // Without a process list nobody can tell which copies are in use.
1904
- if (!Array.isArray(scan)) return { ...report, error: 'could not list running programs, so every copy was kept' };
1905
- const scope = path.join(root, space.AGENT_COPIES_DIR);
1906
- // One limit covers the whole sweep, not each repository separately.
1907
- cleanupBudget = cleanupBudget || { count: 0, kib: 0 };
1908
- for (const repo of space.agentCopyRepos(root)) {
1909
- const entry = { name: repo.name, root: repo.root };
1910
- try {
1911
- const base = defaultMainlineBase(repo.root);
1912
- if (!base.startsWith('origin/')) {
1913
- report.repos.push({ ...entry, skipped: 'no origin main branch to compare against' });
1914
- continue;
1915
- }
1916
- // A real removal scans again per repo and right before each copy goes;
1917
- // the shared scan only serves previews and tests.
1918
- const shared = Array.isArray(activeCwds) || !apply ? scan : undefined;
1919
- const result = cleanupWorktrees({ root: repo.root, base, apply, activeCwds: shared, by, onlyUnder: scope, idleMs, cleanupBudget });
1920
- report.repos.push({
1921
- ...entry,
1922
- base: result.base,
1923
- removed: result.removed,
1924
- candidates: result.candidates,
1925
- cachePruned: result.cachePruned,
1926
- cacheCandidates: result.cacheCandidates,
1927
- kept: result.kept.filter((item) => !SWEEP_HIDDEN_KEPT.has(item.reason)),
1928
- // Shared installs may be in use or contain local changes. Leave them
1929
- // for explicit review rather than pruning by age.
1930
- installsPruned: [],
1931
- });
1932
- } catch (error) {
1933
- report.repos.push({ ...entry, error: String((error && error.message) || error) });
1934
- }
1935
- }
1936
- report.after = freeSpace(root);
1937
- return report;
1938
- }
1939
-
1940
- const SWEEP_KEPT_LABELS = {
1941
- active_process: 'in use',
1942
- process_scan_failed: 'in use (could not check)',
1943
- dirty: 'with changes',
1944
- unmerged: 'not merged',
1945
- detached_unmerged: 'not merged',
1946
- ignored_data: 'holding files git ignores (kept for review)',
1947
- ignored_scan_failed: 'unreadable',
1948
- fresh_worktree_grace: 'new',
1949
- recent_activity: 'used recently',
1950
- locked: 'locked',
1951
- protected_branch: 'on a main branch',
1952
- missing_or_unreadable: 'unreadable',
1953
- missing_head: 'without a commit',
1954
- remove_failed: 'failed to remove',
1955
- base_refresh_failed: 'main branch unreachable',
1956
- base_unverified: 'main branch moved during the check',
1957
- path_not_canonical: 'behind a link',
1958
- size_unknown_or_cleanup_limit: 'over the per-run limit',
1959
- changed_or_active_during_verification: 'changed during the check',
1960
- };
1961
-
1962
- function plural(count, one, many) {
1963
- return `${count} ${count === 1 ? one : many}`;
1964
- }
1965
-
1966
- // The folder that holds .agent-worktrees: this folder if it has one, else
1967
- // the one above the repo's main checkout.
1968
- function defaultSweepWorkspace(cwd = process.cwd()) {
1969
- if (fs.existsSync(path.join(cwd, space.AGENT_COPIES_DIR))) return cwd;
1970
- try {
1971
- return path.dirname(findPrimaryRoot(repoRoot(cwd)));
1972
- } catch {
1973
- return cwd;
1974
- }
1975
- }
1976
-
1977
- function sweepSchedulePlan({ workspace, everyHours = SWEEP_EVERY_HOURS, homeDir = os.homedir(), platform = process.platform, atrisBin = ATRIS_BIN, envPath = process.env.PATH || '' }) {
1978
- return planKeep({
1979
- root: workspace,
1980
- home: path.join(homeDir, '.atris', 'worktree-sweep'),
1981
- platform,
1982
- homeDir,
1983
- atrisBin,
1984
- envPath,
1985
- job: {
1986
- name: 'worktree-sweep',
1987
- args: ['worktree', 'sweep', '--apply', '--workspace', workspace],
1988
- every_seconds: Math.round(everyHours * 3600),
1989
- marker: 'ATRIS_WORKTREE_SWEEP',
1990
- },
1991
- });
1992
- }
1993
-
1994
469
  function sweepSchedule(args, workspace) {
1995
470
  const everyHours = Number(readFlag(args, '--every', String(SWEEP_EVERY_HOURS)));
1996
471
  if (!Number.isFinite(everyHours) || everyHours <= 0) {
@@ -2007,7 +482,6 @@ function sweepSchedule(args, workspace) {
2007
482
  console.log(`sweep timer: ${removed.removed === false ? 'was not on' : 'off'} for ${workspace}`);
2008
483
  return 0;
2009
484
  }
2010
- // The timer runs this atris file every few hours; a copy or temp folder vanishes.
2011
485
  if (space.isAgentCopyPath(ATRIS_BIN) || space.isScratchPath(ATRIS_BIN)) {
2012
486
  console.error(`refusing: this atris runs from ${path.dirname(path.dirname(ATRIS_BIN))}, a folder that gets removed; install the timer from a lasting atris install`);
2013
487
  return 2;
@@ -2030,8 +504,14 @@ function sweep(args) {
2030
504
  console.error('Usage: atris worktree sweep [--apply] [--json] [--workspace <dir>] [--idle-hours <n>]');
2031
505
  return 2;
2032
506
  }
507
+ const rescueDays = Number(readFlag(args, '--rescue-after-days', process.env.ATRIS_WORKTREE_RESCUE_DAYS || String(RESCUE_IDLE_MS / 86400000)));
508
+ if (!Number.isFinite(rescueDays) || rescueDays <= 0) {
509
+ console.error('Usage: atris worktree sweep [--apply] [--json] [--workspace <dir>] [--idle-hours <n>] [--rescue] [--rescue-after-days <n>]');
510
+ return 2;
511
+ }
2033
512
  const apply = hasFlag(args, '--apply');
2034
- const report = sweepWorkspace({ workspace, apply, idleMs: idleHours * 3600000 });
513
+ const rescue = hasFlag(args, '--rescue');
514
+ const report = sweepWorkspace({ workspace, apply, rescue, idleMs: idleHours * 3600000, rescueIdleMs: rescueDays * 86400000 });
2035
515
  if (hasFlag(args, '--json')) {
2036
516
  console.log(JSON.stringify({ ok: !report.error, ...report }, null, 2));
2037
517
  return report.error ? 1 : 0;
@@ -2049,9 +529,11 @@ function sweep(args) {
2049
529
  console.log(`${repo.name}: skipped (${repo.error || repo.skipped})`);
2050
530
  continue;
2051
531
  }
2052
- const gone = apply ? repo.removed : repo.candidates;
532
+ const saved = apply ? repo.rescued : repo.rescueCandidates;
533
+ const savedSet = new Set(saved);
534
+ const gone = (apply ? repo.removed : repo.candidates).filter((item) => !savedSet.has(item));
2053
535
  const cleared = apply ? repo.cachePruned : repo.cacheCandidates.filter((item) => item.reason === 'generated_webpack_cache');
2054
- removedTotal += gone.length;
536
+ removedTotal += gone.length + saved.length;
2055
537
  clearedTotal += cleared.length;
2056
538
  reviewTotal += repo.cacheCandidates.filter((item) => item.reason === 'manual_review_required').length;
2057
539
  const counts = {};
@@ -2061,7 +543,18 @@ function sweep(args) {
2061
543
  }
2062
544
  const keptText = Object.entries(counts).map(([label, count]) => `${count} ${label}`).join(', ');
2063
545
  console.log(`${repo.name}: ${apply ? 'removed' : 'would remove'} ${plural(gone.length, 'finished copy', 'finished copies')}, ${apply ? 'cleared' : 'would clear'} ${plural(cleared.length, 'webpack cache', 'webpack caches')}; kept ${repo.kept.length}${keptText ? ` (${keptText})` : ''}`);
546
+ if (saved.length) console.log(` ${apply ? 'saved and removed' : 'would save and remove'} ${plural(saved.length, 'abandoned copy', 'abandoned copies')}`);
547
+ for (const item of saved) {
548
+ const what = item.reason === 'pushed_branch' ? 'already on origin' : `${plural(item.changed_files || 0, 'changed file', 'changed files')}, idle ${item.idle_days} days`;
549
+ console.log(` ${apply ? 'saved' : 'save'}: ${item.path} -> origin/${item.rescue_branch} (${what}, ${formatKib(item.size_kib)})`);
550
+ }
2064
551
  for (const item of gone) console.log(` ${apply ? 'removed' : 'remove'}: ${item.path}`);
552
+ for (const item of repo.kept) {
553
+ if (item.reason === 'rescue_push_failed') console.log(` kept: ${item.path} (could not push its work to origin/${item.rescue_branch}: ${item.error})`);
554
+ if (item.reason === 'unprovable') console.log(` kept: ${item.path} (${item.why})`);
555
+ if (item.reason === 'stale_dirty' || item.reason === 'stale_unpushed') console.log(` stale: ${item.path} (idle ${item.idle_days} days; atris worktree sweep --rescue --apply saves and removes it)`);
556
+ if (item.reason === 'ignored_data' && item.files) console.log(` kept: ${item.path} (ignored files atris cannot rebuild: ${item.files.join(', ')})`);
557
+ }
2065
558
  for (const item of cleared) console.log(` ${apply ? 'cleared' : 'clear'}: ${item.path}`);
2066
559
  }
2067
560
  if (reviewTotal) console.log(`review: ${plural(reviewTotal, 'other build folder waits', 'other build folders wait')} for a person; \`atris worktree cleanup\` in each repo lists them`);
@@ -2138,7 +631,8 @@ function guide() {
2138
631
  console.log('Start reaps completed checkouts first. Cleanup preserves branches, source changes, and files git ignores, skips live work, and prunes only verified webpack caches.');
2139
632
  console.log('Start clones node_modules from a matching install when package-lock.json matches; skip npm ci when it prints "deps: cloned".');
2140
633
  console.log('Below 25 GB free, start reaps finished copies in every repo; below 10 GB it refuses (ATRIS_WORKTREE_REAP_BELOW_GB, ATRIS_WORKTREE_MIN_FREE_GB).');
2141
- console.log('A copy shipped with --merge is removed once nothing has run in it for 10 minutes (ATRIS_WORKTREE_KEEP_AFTER_SHIP=1 keeps it).');
634
+ console.log('A shipped copy is removed as soon as its work is merged or on origin and nothing else runs in it; if something does, a merged copy goes once it has been quiet for 10 minutes (--keep-copy or ATRIS_WORKTREE_KEEP_AFTER_SHIP=1 keeps it).');
635
+ console.log('The sweep reports abandoned copies with unsaved work (idle 7 days). With --rescue it saves one to commits on a new atris-rescue/ branch, pushes it, and removes the copy only after origin has it; it removes a copy only when it can prove every byte is saved, and anything unusual keeps the copy for a person. The timer never rescues. Rescues are logged in .atris/state/worktree_removals.jsonl.');
2142
636
  console.log('Use `atris worktree status` to see all worktrees and dirty counts.');
2143
637
  return 0;
2144
638
  }
@@ -2155,12 +649,14 @@ function help() {
2155
649
  console.log(' --target <ref> override the default landing target (default: branch atris-base, else origin default branch)');
2156
650
  console.log(' --local merge into the local primary checkout instead of pushing and opening a PR');
2157
651
  console.log(' --no-map-refresh skip the automatic refresh of moved map line references');
652
+ console.log(' --keep-copy keep this build copy after it ships');
2158
653
  console.log(' recommended verify: npm run test:fast && node --test <focused files>');
2159
654
  console.log(' atris worktree status');
2160
655
  console.log(' atris worktree guard [--allow-primary] [--allow-dirty]');
2161
656
  console.log(' atris worktree prune [--apply]');
2162
657
  console.log(' atris worktree cleanup [--apply] [--json] [--base origin/master] reap completed checkouts and verified webpack caches');
2163
- console.log(' atris worktree sweep [--apply] [--json] [--workspace <dir>] [--idle-hours 2] reap merged, idle copies in every repo');
658
+ console.log(' atris worktree sweep [--apply] [--json] [--workspace <dir>] [--idle-hours 2] [--rescue] [--rescue-after-days 7]');
659
+ console.log(' removes merged or pushed idle copies in every repo and reports abandoned ones; --rescue saves those to a new branch on origin, then removes them');
2164
660
  console.log(' atris worktree sweep --install [--every 3] | --uninstall run the sweep on a timer');
2165
661
  }
2166
662
 
@@ -2188,6 +684,7 @@ function worktreeCommand(args = []) {
2188
684
  }
2189
685
 
2190
686
  module.exports = {
687
+ ...worktreeCore,
2191
688
  branchName,
2192
689
  collidingFlights,
2193
690
  mergePrWithRetry,