atris 3.74.27 → 3.74.29

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.
@@ -1,15 +1,20 @@
1
1
  'use strict';
2
2
 
3
3
  const fs = require('fs');
4
+ const os = require('os');
4
5
  const path = require('path');
5
- const { spawnSync } = require('child_process');
6
+ const { spawn, spawnSync } = require('child_process');
6
7
  const { hasFlag, readFlag } = require('../lib/arg-parser');
7
8
  const { stampLatestOpenBriefForWorktree } = require('../lib/brief-ledger');
9
+ const { installKeep, planKeep, removeKeep } = require('../lib/campaign-keep');
8
10
  const { isConductorArtifact, isConductorUntracked } = require('../lib/conductor-artifacts');
9
11
  const { fixMapDocs } = require('../lib/map-refs');
10
12
  const { sleepSync } = require('../lib/sleep-sync');
13
+ const space = require('../lib/worktree-space');
11
14
  const close = require('./close');
12
15
 
16
+ const ATRIS_BIN = path.resolve(__dirname, '..', 'bin', 'atris.js');
17
+
13
18
  // Files atris regenerates on its own. Unstaged churn in them is restored
14
19
  // before ship commits, unless the commit message names the file. The task
15
20
  // projection is rewritten by any task command run in a work folder, and two
@@ -571,7 +576,12 @@ function printStatus() {
571
576
  // 2026-07-04 (ship merged into the launcher's branch, never reached master).
572
577
  // --base/--target still overrides both when the caller wants a different
573
578
  // checkout point and ship target on purpose.
574
- function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task, branch: branchOverride, path: pathOverride, base: baseOverride, now = new Date() } = {}) {
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, branch: branchOverride, path: pathOverride, base: baseOverride, now = new Date(), env = process.env, freeSpace = space.freeBytes, spaceGuard, deps } = {}) {
575
585
  const owner = member || agent;
576
586
  if (!owner || !task) throw new Error('createAgentWorktree: owner (member/agent) and task required');
577
587
  // Pin the caller's flight base, or resolve it once for this new flight.
@@ -605,6 +615,10 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
605
615
  const checkoutBase = normalizeTargetRef(root, mainline);
606
616
  const shipBase = checkoutBase;
607
617
  if (fs.existsSync(target)) throw new Error(`worktree path already exists: ${target}`);
618
+ // A full disk stops every flight at once. Reap first; refuse before cutting.
619
+ const room = (spaceGuard ?? !space.isScratchPath(target))
620
+ ? ensureRoomForCopy({ root, target, freeSpace, env })
621
+ : null;
608
622
  fs.mkdirSync(path.dirname(target), { recursive: true });
609
623
  const staleBase = refreshRemoteRef(root, checkoutBase);
610
624
  // A retry of the same job starts from the last run's unlanded commits; it
@@ -689,7 +703,64 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
689
703
  discardFailedStart();
690
704
  throw error;
691
705
  }
692
- return { path: target, branch, base: shipBase, checkoutBase, resumedFrom, skippedResume, owner, reapedBeforeStart, cachePrunedBeforeStart };
706
+ const seedDeps = deps !== false && env.ATRIS_WORKTREE_NO_DEP_CLONE !== '1' && (deps ?? !space.isScratchPath(target));
707
+ const depsResult = seedDeps ? seedCopyDeps({ root, target, options: deps && typeof deps === 'object' ? deps : {} }) : null;
708
+ return { path: target, branch, base: shipBase, checkoutBase, resumedFrom, skippedResume, owner, reapedBeforeStart, cachePrunedBeforeStart, space: room, deps: depsResult };
709
+ }
710
+
711
+ // node_modules as a clone of a matching install (see lib/worktree-space.js),
712
+ // so a web copy costs 12 MB and 2 seconds instead of npm ci.
713
+ function seedCopyDeps({ root, target, options = {} }) {
714
+ try {
715
+ const primaryRoot = findPrimaryRoot(root);
716
+ return space.seedNodeModules({
717
+ target,
718
+ workspace: path.dirname(primaryRoot),
719
+ repoName: path.basename(primaryRoot),
720
+ sources: [primaryRoot, ...listWorktrees(root).map((wt) => wt.path)],
721
+ isIgnored: (dir) => runGit(['check-ignore', '-q', '--', 'node_modules/'], { cwd: dir, check: false }).status === 0,
722
+ ...options,
723
+ });
724
+ } catch (error) {
725
+ return { status: 'skipped', reason: `could not clone installs: ${String((error && error.message) || error)}` };
726
+ }
727
+ }
728
+
729
+ // When the disk runs low, reaps finished copies of every repo in the
730
+ // workspace (merged, clean, nothing running inside, past the fresh-copy
731
+ // grace). Below the floor it refuses with ATRIS_LOW_DISK, before any copy
732
+ // is cut. Limits: ATRIS_WORKTREE_REAP_BELOW_GB, ATRIS_WORKTREE_MIN_FREE_GB.
733
+ // Even when the disk is low, a copy touched in the last ten minutes stays:
734
+ // the engine that just finished may still have its last message unread.
735
+ const LOW_DISK_IDLE_MS = 10 * 60 * 1000;
736
+
737
+ function ensureRoomForCopy({ root, target, freeSpace = space.freeBytes, env = process.env, by = 'atris worktree start' }) {
738
+ const limits = space.spaceLimits(env);
739
+ const before = freeSpace(target);
740
+ const room = { before, free: before, removed: [], cleared: [], ...limits };
741
+ if (typeof before !== 'number' || !Number.isFinite(before)) return room;
742
+ if (before >= limits.reapBelow && before >= limits.refuseBelow) return room;
743
+ const swept = sweepWorkspace({ workspace: path.dirname(findPrimaryRoot(root)), apply: true, idleMs: LOW_DISK_IDLE_MS, freeSpace, by });
744
+ for (const repo of swept.repos) {
745
+ room.removed.push(...(repo.removed || []));
746
+ room.cleared.push(...(repo.cachePruned || []));
747
+ }
748
+ const after = freeSpace(target);
749
+ room.free = typeof after === 'number' && Number.isFinite(after) ? after : before;
750
+ if (limits.refuseBelow > 0 && room.free < limits.refuseBelow) {
751
+ const count = room.removed.length;
752
+ const reaped = count
753
+ ? `Removed ${count} finished build ${count === 1 ? 'copy' : 'copies'} first, and that was not enough.`
754
+ : 'No finished build copies were safe to remove.';
755
+ const error = new Error(
756
+ `only ${space.formatGb(room.free)} free on this disk, and a new build copy needs at least ${space.formatGb(limits.refuseBelow)}. ${reaped} `
757
+ + 'Free some space and retry; `atris worktree sweep` shows what is left, and ATRIS_WORKTREE_MIN_FREE_GB=0 skips this check.'
758
+ );
759
+ error.code = 'ATRIS_LOW_DISK';
760
+ error.space = room;
761
+ throw error;
762
+ }
763
+ return room;
693
764
  }
694
765
 
695
766
  function startWorktree(args) {
@@ -699,7 +770,7 @@ function startWorktree(args) {
699
770
  const owner = member || agent;
700
771
  const task = readFlag(args, '--task');
701
772
  if (!owner || !task) {
702
- console.error('Usage: atris worktree start --member <member>|--agent <name> --task "<short task>" [--claim] [--force]');
773
+ console.error('Usage: atris worktree start --member <member>|--agent <name> --task "<short task>" [--claim] [--force] [--no-deps]');
703
774
  return 2;
704
775
  }
705
776
  const memberFile = member ? path.join(root, 'atris', 'team', member, 'MEMBER.md') : '';
@@ -736,6 +807,7 @@ function startWorktree(args) {
736
807
  branch: readFlag(args, '--branch'),
737
808
  path: readFlag(args, '--path'),
738
809
  base: readFlag(args, '--base') || readFlag(args, '--target'),
810
+ ...(hasFlag(args, '--no-deps') ? { deps: false } : {}),
739
811
  });
740
812
  } catch (e) {
741
813
  console.error(`refusing: ${e.message}`);
@@ -749,6 +821,17 @@ function startWorktree(args) {
749
821
  if (created.cachePrunedBeforeStart.length) {
750
822
  console.log(`cleanup: pruned ${created.cachePrunedBeforeStart.length} generated ${created.cachePrunedBeforeStart.length === 1 ? 'cache' : 'caches'}`);
751
823
  }
824
+ if (created.space && created.space.removed.length) {
825
+ const count = created.space.removed.length;
826
+ console.log(`space: disk was low, removed ${count} finished build ${count === 1 ? 'copy' : 'copies'} across the workspace; ${space.formatGb(created.space.free)} free now`);
827
+ } else if (created.space && created.space.free !== null && created.space.free < created.space.reapBelow) {
828
+ console.log(`space: ${space.formatGb(created.space.free)} free; no finished build copies were safe to remove`);
829
+ }
830
+ if (created.deps && created.deps.status === 'cloned') {
831
+ console.log(`deps: cloned node_modules from a matching install in ${(created.deps.ms / 1000).toFixed(1)}s; skip npm ci`);
832
+ } else if (created.deps && created.deps.reason !== 'no package-lock.json') {
833
+ console.log(`deps: ${created.deps.reason}`);
834
+ }
752
835
 
753
836
  const counts = statusCounts(root);
754
837
  if (counts && (counts.staged || counts.unstaged || counts.untracked)) {
@@ -824,6 +907,8 @@ function shipHelp() {
824
907
  console.log('');
825
908
  console.log(' --target <ref> override the default landing target (default: branch atris-base, else origin default branch)');
826
909
  console.log(' --local merge into the local primary checkout instead of pushing and opening a PR');
910
+ console.log(' protected-lane tasks (billing, money, deploy, feedback, ...) can be pushed for review without --merge;');
911
+ console.log(' landing one with --merge or --local needs --allow-denied-lane on written order');
827
912
  console.log(' --no-map-refresh skip the automatic refresh of moved map line references');
828
913
  console.log(' unstaged regenerated adapter files are skipped unless staged first or named in --message');
829
914
  console.log(' recommended flight verify: npm run test:fast && node --test <focused files>');
@@ -831,7 +916,7 @@ function shipHelp() {
831
916
 
832
917
  function deniedLaneForShip(root, branch, message) {
833
918
  try {
834
- const { DENIED_TAGS } = require('../lib/fleet');
919
+ const { protectedLaneOfTask } = require('../lib/fleet');
835
920
  const refs = new Set();
836
921
  for (const source of [String(branch || ''), String(message || '')]) {
837
922
  for (const m of source.toUpperCase().matchAll(/([A-Z]{2,6}-\d+)/g)) refs.add(m[1]);
@@ -844,8 +929,7 @@ function deniedLaneForShip(root, branch, message) {
844
929
  for (const task of projection.tasks || []) {
845
930
  const ids = [task.display_id, task.legacy_ref].filter(Boolean).map(v => String(v).toUpperCase());
846
931
  if (!ids.some(id => refs.has(id))) continue;
847
- const tags = [task.tag, ...(task.metadata?.todo_tags || [])].filter(Boolean).map(t => String(t).toLowerCase());
848
- const hit = tags.find(t => DENIED_TAGS.includes(t));
932
+ const hit = protectedLaneOfTask(task);
849
933
  if (hit) return { taskId: ids[0], tag: hit };
850
934
  }
851
935
  return null;
@@ -854,6 +938,55 @@ function deniedLaneForShip(root, branch, message) {
854
938
  }
855
939
  }
856
940
 
941
+ // Pushing a protected-lane branch for review lands nothing, unless the branch
942
+ // already has a pull request with GitHub auto-merge on: that request merges
943
+ // itself the moment its checks pass, and the push is what feeds it. A branch
944
+ // that shipped earlier with --merge and was retagged into a protected lane since
945
+ // would land with no person. One branch can have several open pull requests (one
946
+ // per base), and a single push feeds every one of them, so this asks for the whole
947
+ // list: `gh pr view` shows just one, and it may be the one without auto-merge.
948
+ // Returns the words to refuse with, or '' when the push is safe. It fails closed:
949
+ // if gh cannot say, or the list may be incomplete, nothing is pushed.
950
+ const OPEN_PR_LIST_LIMIT = 100;
951
+
952
+ function protectedPushRefusal(root, branch, run = spawnSync) {
953
+ const unknown = (why) => `blocked: cannot tell whether ${branch} has a pull request with auto-merge on (${why}); nothing was pushed`;
954
+ // A minute is plenty for one lookup; a gh that hangs must not hang the ship.
955
+ const listed = run('gh', [
956
+ 'pr', 'list', '--head', branch, '--state', 'open',
957
+ '--json', 'number,url,baseRefName,autoMergeRequest',
958
+ '--limit', String(OPEN_PR_LIST_LIMIT),
959
+ ], { cwd: root, encoding: 'utf8', timeout: 60000 });
960
+ if (listed.error) {
961
+ return unknown(listed.error.code === 'ETIMEDOUT' ? 'gh took longer than a minute to answer' : `gh did not run: ${listed.error.message}`);
962
+ }
963
+ if (listed.status !== 0) {
964
+ // No branch has an empty answer to read as "none": an empty list is an
965
+ // exit 0, so a failed lookup is never taken for a clear.
966
+ const said = `${listed.stderr || ''}\n${listed.stdout || ''}`;
967
+ const firstLine = said.split(/\r?\n/).map((line) => line.trim()).find(Boolean);
968
+ return unknown(firstLine || `gh exited ${listed.status}`);
969
+ }
970
+ let open;
971
+ try { open = JSON.parse(String(listed.stdout || '')); } catch { open = null; }
972
+ // Every entry must say whether auto-merge is on (null means off), or the
973
+ // answer is not one this check can lean on.
974
+ const readable = Array.isArray(open)
975
+ && open.every((pr) => pr && typeof pr === 'object' && !Array.isArray(pr) && Object.prototype.hasOwnProperty.call(pr, 'autoMergeRequest'));
976
+ if (!readable) return unknown("gh's answer could not be read");
977
+ // gh stops at its limit without saying so: a full page may be only part of the list.
978
+ if (open.length >= OPEN_PR_LIST_LIMIT) return unknown(`${open.length} open pull requests may have been cut short`);
979
+ const armed = open.filter((pr) => pr.autoMergeRequest);
980
+ if (!armed.length) return '';
981
+ const name = (pr) => `${pr.url || (pr.number ? `#${pr.number}` : branch)}${pr.baseRefName ? ` (into ${pr.baseRefName})` : ''}`;
982
+ const off = (pr) => `gh pr merge ${pr.number || pr.url || branch} --disable-auto`;
983
+ if (armed.length === 1) {
984
+ 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`;
985
+ }
986
+ const names = armed.map(name);
987
+ 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`;
988
+ }
989
+
857
990
  function findPrimaryRoot(root) {
858
991
  const worktrees = listWorktrees(root);
859
992
  return worktrees[0]?.path || root;
@@ -974,13 +1107,21 @@ function shipWorktree(args) {
974
1107
  // Denied lanes are checked at dispatch, but a flight that was staffed
975
1108
  // before a task entered a denied lane (or killed mid-flight) could still
976
1109
  // ship: a payments command self-landed twice this way on 2026-07-26. The
977
- // ship gate is the last door, so it re-checks.
1110
+ // ship gate is the last door, so it re-checks. It guards landing only:
1111
+ // pushing the branch and opening a pull request lands nothing, and that is
1112
+ // how protected work reaches a person to review. The one exception is a
1113
+ // pull request that already has auto-merge on, which a push would feed.
978
1114
  if (!hasFlag(args, '--allow-denied-lane')) {
979
1115
  const denied = deniedLaneForShip(root, branch, message);
980
- if (denied) {
1116
+ if (denied && (merge || localMode)) {
981
1117
  console.error(`blocked: this branch carries ${denied.taskId} which sits in the protected ${denied.tag} lane; a human lands it (or pass --allow-denied-lane on written order)`);
982
1118
  return 2;
983
1119
  }
1120
+ const refusal = denied ? protectedPushRefusal(root, branch) : '';
1121
+ if (refusal) {
1122
+ console.error(refusal);
1123
+ return 2;
1124
+ }
984
1125
  }
985
1126
 
986
1127
  let oneLapProof = null;
@@ -1072,6 +1213,8 @@ function shipWorktree(args) {
1072
1213
  }
1073
1214
  console.log(`merge_check: ${targetRef} clean`);
1074
1215
 
1216
+ // Set once the change is confirmed merged; the build copy then goes away.
1217
+ let landedOn = '';
1075
1218
  if (localMode) {
1076
1219
  console.log('push: skipped (local mode)');
1077
1220
  if (merge) {
@@ -1094,10 +1237,12 @@ function shipWorktree(args) {
1094
1237
  }
1095
1238
  runGit(['merge', '--no-ff', branch, '-m', `Merge ${branch}`], { cwd: primary });
1096
1239
  console.log('merge: merged (local mode)');
1240
+ landedOn = `refs/heads/${targetBranch}`;
1097
1241
  }
1098
1242
  }
1099
1243
  console.log('pr: skipped (local mode)');
1100
1244
  if (merge && !dryRun) runShipHealthCheck(primary || root);
1245
+ if (landedOn) announceShippedCopyRemoval(scheduleShippedCopyRemoval({ root, primary, branch, base: landedOn }));
1101
1246
  console.log('done: worktree shipped');
1102
1247
  return 0;
1103
1248
  }
@@ -1145,6 +1290,7 @@ function shipWorktree(args) {
1145
1290
  // and commit landed. The rating only counts a landing it can point at.
1146
1291
  const record = shipMergeRecord(state);
1147
1292
  if (record) console.log(`merge: record ${JSON.stringify(record)}`);
1293
+ landedOn = targetRef;
1148
1294
  const deleted = runGit(['push', 'origin', '--delete', branch], { cwd: root, check: false });
1149
1295
  if (deleted.status === 0) {
1150
1296
  console.log(`merge: remote branch deleted ${branch}`);
@@ -1165,10 +1311,106 @@ function shipWorktree(args) {
1165
1311
  }, { worktree: root });
1166
1312
  } catch {}
1167
1313
  if (merge && !dryRun) runShipHealthCheck(primary || root);
1314
+ if (landedOn) announceShippedCopyRemoval(scheduleShippedCopyRemoval({ root, primary, branch, base: landedOn }));
1168
1315
  console.log('done: worktree shipped');
1169
1316
  return 0;
1170
1317
  }
1171
1318
 
1319
+ // A shipped build copy is dead weight, but the engine that shipped it is
1320
+ // still inside and writes its last message there on exit. A detached
1321
+ // `atris worktree reap-shipped` waits until nothing has run in the copy for
1322
+ // SHIP_SETTLE_MS, then removes it if it is still merged and clean.
1323
+ const SHIP_SETTLE_MS = 10 * 60 * 1000;
1324
+ const SHIP_REAP_POLL_MS = 60 * 1000;
1325
+ const SHIP_REAP_GIVE_UP_MS = 6 * 60 * 60 * 1000;
1326
+ // Right after a merge the local copy of the target can lag; allow a short
1327
+ // wait before treating the copy as unmerged and leaving it for the sweep.
1328
+ const SHIP_REAP_UNMERGED_GRACE_MS = 15 * 60 * 1000;
1329
+ // Reasons that can clear on their own: someone is still inside, or the
1330
+ // main branch moved or could not be reached during the check.
1331
+ const SHIP_REAP_RETRY_REASONS = new Set([
1332
+ 'active_process', 'process_scan_failed', 'recent_activity', 'base_refresh_failed', 'base_unverified',
1333
+ 'ignored_scan_failed', 'changed_or_active_during_verification',
1334
+ ]);
1335
+
1336
+ function shippedCopyRemovalPlan({ root, primary, env = process.env, atrisBin = ATRIS_BIN }) {
1337
+ const copy = path.resolve(root);
1338
+ if (env.ATRIS_WORKTREE_KEEP_AFTER_SHIP === '1') return { skip: 'ATRIS_WORKTREE_KEEP_AFTER_SHIP=1', announce: true };
1339
+ if (!primary || canonicalPath(copy) === canonicalPath(primary)) return { skip: 'primary checkout' };
1340
+ if (!space.isAgentCopyPath(copy)) return { skip: 'not an agent build copy' };
1341
+ if (space.isScratchPath(copy)) return { skip: 'temp folder' };
1342
+ // The removal runs this same atris; one that lives in the copy would delete itself.
1343
+ if (space.isInside(atrisBin, copy)) return { skip: 'this atris runs from inside the copy', announce: true };
1344
+ return { copy };
1345
+ }
1346
+
1347
+ function scheduleShippedCopyRemoval({ root, primary, branch, base, env = process.env, spawnProcess = spawn }) {
1348
+ const plan = shippedCopyRemovalPlan({ root, primary, env });
1349
+ if (plan.skip) return plan;
1350
+ try {
1351
+ // The copy rides in the environment, not the arguments, so the process
1352
+ // scan does not count this waiter as someone working in the copy.
1353
+ const child = spawnProcess(process.execPath, [ATRIS_BIN, 'worktree', 'reap-shipped'], {
1354
+ cwd: primary,
1355
+ detached: true,
1356
+ stdio: 'ignore',
1357
+ env: { ...env, ATRIS_SKIP_UPDATE_CHECK: '1', ATRIS_REAP_WORKTREE: plan.copy, ATRIS_REAP_BRANCH: branch || '', ATRIS_REAP_BASE: base || '' },
1358
+ });
1359
+ if (child && typeof child.on === 'function') child.on('error', () => {});
1360
+ if (child && typeof child.unref === 'function') child.unref();
1361
+ return { ...plan, scheduled: true };
1362
+ } catch (error) {
1363
+ return { skip: `could not start the removal: ${String((error && error.message) || error)}`, announce: true };
1364
+ }
1365
+ }
1366
+
1367
+ function announceShippedCopyRemoval(plan) {
1368
+ if (plan.scheduled) {
1369
+ console.log(`cleanup: this build copy is removed once nothing has run in it for ${Math.round(SHIP_SETTLE_MS / 60000)} minutes`);
1370
+ } else if (plan.announce) {
1371
+ console.log(`cleanup: kept this build copy (${plan.skip})`);
1372
+ }
1373
+ }
1374
+
1375
+ function reapShippedCopy({
1376
+ root,
1377
+ target,
1378
+ base = '',
1379
+ settleMs = SHIP_SETTLE_MS,
1380
+ pollMs = SHIP_REAP_POLL_MS,
1381
+ giveUpMs = SHIP_REAP_GIVE_UP_MS,
1382
+ unmergedGraceMs = SHIP_REAP_UNMERGED_GRACE_MS,
1383
+ sleep = sleepSync,
1384
+ clock = Date.now,
1385
+ cleanup: cleanupFn = cleanupWorktrees,
1386
+ } = {}) {
1387
+ const startedAt = clock();
1388
+ sleep(settleMs);
1389
+ for (;;) {
1390
+ const result = cleanupFn({
1391
+ root, base, apply: true, onlyPaths: [target], freshGraceMs: 0, idleMs: settleMs, by: 'atris worktree ship', now: clock(),
1392
+ });
1393
+ if (result.removed.length) return { removed: true, reason: result.removed[0].reason };
1394
+ const entry = result.kept.find((item) => canonicalPath(item.path) === canonicalPath(target));
1395
+ if (!entry) return { removed: false, reason: 'not_registered' };
1396
+ const waited = clock() - startedAt;
1397
+ const retry = SHIP_REAP_RETRY_REASONS.has(entry.reason) || (entry.reason === 'unmerged' && waited < unmergedGraceMs);
1398
+ if (!retry || waited >= giveUpMs) return { removed: false, reason: entry.reason };
1399
+ sleep(pollMs);
1400
+ }
1401
+ }
1402
+
1403
+ function reapShipped() {
1404
+ const target = process.env.ATRIS_REAP_WORKTREE || '';
1405
+ if (!target) {
1406
+ console.error('reap-shipped: ATRIS_REAP_WORKTREE is not set');
1407
+ return 2;
1408
+ }
1409
+ const result = reapShippedCopy({ root: process.cwd(), target, base: process.env.ATRIS_REAP_BASE || '' });
1410
+ console.log(`reap-shipped: ${result.removed ? 'removed' : 'kept'} ${target} (${result.reason})`);
1411
+ return result.removed ? 0 : 1;
1412
+ }
1413
+
1172
1414
  // The landed pull request from `gh pr view --json`, or null without a merge commit.
1173
1415
  function shipMergeRecord(state) {
1174
1416
  const commit = state && state.mergeCommit && typeof state.mergeCommit === 'object' ? state.mergeCommit.oid : null;
@@ -1224,14 +1466,35 @@ const GENERATED_WORKTREE_CACHE_DIRS = [
1224
1466
  'node_modules/.cache',
1225
1467
  ];
1226
1468
 
1227
- function worktreeWithinReapGrace(worktreePath, now = Date.now()) {
1469
+ function worktreeWithinReapGrace(worktreePath, now = Date.now(), graceMs = WORKTREE_REAP_GRACE_MS) {
1228
1470
  try {
1229
- return now - fs.statSync(worktreePath).mtimeMs < WORKTREE_REAP_GRACE_MS;
1471
+ return now - fs.statSync(worktreePath).mtimeMs < graceMs;
1230
1472
  } catch {
1231
1473
  return false;
1232
1474
  }
1233
1475
  }
1234
1476
 
1477
+ // Newest sign of work in a copy: its folder, its branch tip and reflog, web
1478
+ // build output, and the engine's last message. The index is left out on
1479
+ // purpose: a plain `git status` rewrites it.
1480
+ function lastCopyActivityMs(worktreePath) {
1481
+ const marks = [worktreePath, path.join(worktreePath, '.next'), path.join(worktreePath, '.codex-last-message.txt')];
1482
+ try {
1483
+ const pointer = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(path.join(worktreePath, '.git'), 'utf8'));
1484
+ if (pointer) {
1485
+ const gitDir = path.resolve(worktreePath, pointer[1].trim());
1486
+ marks.push(path.join(gitDir, 'HEAD'), path.join(gitDir, 'logs', 'HEAD'));
1487
+ }
1488
+ } catch {}
1489
+ let newest = 0;
1490
+ for (const mark of marks) {
1491
+ try {
1492
+ newest = Math.max(newest, fs.statSync(mark).mtimeMs);
1493
+ } catch {}
1494
+ }
1495
+ return newest;
1496
+ }
1497
+
1235
1498
  function canonicalPath(value) {
1236
1499
  try {
1237
1500
  return fs.realpathSync(value);
@@ -1415,7 +1678,23 @@ function originBaseIsCurrent(root, base) {
1415
1678
  && match[2] === `refs/heads/${branch}` && local.stdout.trim() === match[1];
1416
1679
  }
1417
1680
 
1418
- function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply = false, activeCwds: activeCwdsOverride, by = 'atris worktree cleanup' } = {}) {
1681
+ // Scope and pace options, all off by default:
1682
+ // onlyUnder / onlyPaths touch only copies inside that folder / those paths
1683
+ // idleMs keep merged copies with work newer than this, and
1684
+ // leave their caches alone
1685
+ // freshGraceMs how long a merged copy is kept after its start
1686
+ function cleanupWorktrees({
1687
+ root = repoRoot(),
1688
+ base: baseOverride = '',
1689
+ apply = false,
1690
+ activeCwds: activeCwdsOverride,
1691
+ by = 'atris worktree cleanup',
1692
+ onlyUnder = '',
1693
+ onlyPaths = null,
1694
+ idleMs = 0,
1695
+ freshGraceMs = WORKTREE_REAP_GRACE_MS,
1696
+ now = Date.now(),
1697
+ } = {}) {
1419
1698
  const worktrees = listWorktrees(root);
1420
1699
  const primary = worktrees[0]?.path ? path.resolve(worktrees[0].path) : '';
1421
1700
  const current = path.resolve(root);
@@ -1428,8 +1707,13 @@ function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply =
1428
1707
  const cacheCandidates = [];
1429
1708
  const cachePruned = [];
1430
1709
  const cacheBudget = { count: 0, kib: 0 };
1710
+ const scopePaths = Array.isArray(onlyPaths) ? onlyPaths.map(canonicalPath) : null;
1711
+ const inScope = (wtPath) => (!scopePaths || scopePaths.includes(canonicalPath(wtPath)))
1712
+ && (!onlyUnder || space.isInside(wtPath, onlyUnder));
1713
+ const recentlyUsed = (wtPath) => Boolean(idleMs) && now - lastCopyActivityMs(wtPath) < idleMs;
1431
1714
  const pruneCachesForKept = (wtPath) => {
1432
- if (worktreeWithinReapGrace(wtPath)) return;
1715
+ if (worktreeWithinReapGrace(wtPath, now)) return;
1716
+ if (recentlyUsed(wtPath)) return;
1433
1717
  const caches = pruneGeneratedWorktreeCaches(wtPath, { apply, budget: cacheBudget });
1434
1718
  cacheCandidates.push(...caches.candidates);
1435
1719
  cachePruned.push(...caches.pruned);
@@ -1446,6 +1730,10 @@ function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply =
1446
1730
  kept.push({ ...item, reason: 'current_checkout' });
1447
1731
  continue;
1448
1732
  }
1733
+ if (!inScope(wtPath)) {
1734
+ kept.push({ ...item, reason: 'out_of_scope' });
1735
+ continue;
1736
+ }
1449
1737
  if (wt.locked) {
1450
1738
  kept.push({ ...item, reason: 'locked' });
1451
1739
  continue;
@@ -1495,10 +1783,14 @@ function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply =
1495
1783
  kept.push({ ...item, reason: wt.branch === 'detached' ? 'detached_unmerged' : 'unmerged' });
1496
1784
  continue;
1497
1785
  }
1498
- if (worktreeWithinReapGrace(wtPath)) {
1786
+ if (freshGraceMs > 0 && worktreeWithinReapGrace(wtPath, now, freshGraceMs)) {
1499
1787
  kept.push({ ...item, reason: 'fresh_worktree_grace' });
1500
1788
  continue;
1501
1789
  }
1790
+ if (recentlyUsed(wtPath)) {
1791
+ kept.push({ ...item, reason: 'recent_activity' });
1792
+ continue;
1793
+ }
1502
1794
  if (!originBaseIsCurrent(root, base)) {
1503
1795
  kept.push({ ...item, reason: 'base_unverified' });
1504
1796
  continue;
@@ -1551,6 +1843,190 @@ function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply =
1551
1843
  return { apply, base, candidates, removed, kept, cacheCandidates, cachePruned };
1552
1844
  }
1553
1845
 
1846
+ // The sweep: cleanup for every repo with build copies in the workspace.
1847
+ // Removes copies that pass every cleanup check (merged into origin's main
1848
+ // branch, nothing changed, untracked, or ignored, nothing running inside)
1849
+ // and are unused for idleMs; clears verified webpack caches in the idle
1850
+ // copies it keeps; trims the shared install store. Unmerged work always
1851
+ // stays.
1852
+ const SWEEP_IDLE_MS = 2 * 60 * 60 * 1000;
1853
+ const SWEEP_EVERY_HOURS = 3;
1854
+ const SWEEP_HIDDEN_KEPT = new Set(['primary_checkout', 'current_checkout', 'out_of_scope']);
1855
+
1856
+ function sweepWorkspace({ workspace, apply = false, idleMs = SWEEP_IDLE_MS, activeCwds, freeSpace = space.freeBytes, by = 'atris worktree sweep' } = {}) {
1857
+ const root = path.resolve(workspace);
1858
+ const before = freeSpace(root);
1859
+ const report = { workspace: root, apply, idle_ms: idleMs, before, after: before, repos: [] };
1860
+ const scan = Array.isArray(activeCwds) ? activeCwds.map(canonicalPath) : listActiveProcessPaths();
1861
+ // Without a process list nobody can tell which copies are in use.
1862
+ if (!Array.isArray(scan)) return { ...report, error: 'could not list running programs, so every copy was kept' };
1863
+ const scope = path.join(root, space.AGENT_COPIES_DIR);
1864
+ for (const repo of space.agentCopyRepos(root)) {
1865
+ const entry = { name: repo.name, root: repo.root };
1866
+ try {
1867
+ const base = defaultMainlineBase(repo.root);
1868
+ if (!base.startsWith('origin/')) {
1869
+ report.repos.push({ ...entry, skipped: 'no origin main branch to compare against' });
1870
+ continue;
1871
+ }
1872
+ // A real removal scans again per repo and right before each copy goes;
1873
+ // the shared scan only serves previews and tests.
1874
+ const shared = Array.isArray(activeCwds) || !apply ? scan : undefined;
1875
+ const result = cleanupWorktrees({ root: repo.root, base, apply, activeCwds: shared, by, onlyUnder: scope, idleMs });
1876
+ report.repos.push({
1877
+ ...entry,
1878
+ base: result.base,
1879
+ removed: result.removed,
1880
+ candidates: result.candidates,
1881
+ cachePruned: result.cachePruned,
1882
+ cacheCandidates: result.cacheCandidates,
1883
+ kept: result.kept.filter((item) => !SWEEP_HIDDEN_KEPT.has(item.reason)),
1884
+ installsPruned: apply ? space.pruneDepsCache({ workspace: root, repoName: repo.name }) : [],
1885
+ });
1886
+ } catch (error) {
1887
+ report.repos.push({ ...entry, error: String((error && error.message) || error) });
1888
+ }
1889
+ }
1890
+ report.after = freeSpace(root);
1891
+ return report;
1892
+ }
1893
+
1894
+ const SWEEP_KEPT_LABELS = {
1895
+ active_process: 'in use',
1896
+ process_scan_failed: 'in use (could not check)',
1897
+ dirty: 'with changes',
1898
+ unmerged: 'not merged',
1899
+ detached_unmerged: 'not merged',
1900
+ ignored_data: 'holding files git ignores (kept for review)',
1901
+ ignored_scan_failed: 'unreadable',
1902
+ fresh_worktree_grace: 'new',
1903
+ recent_activity: 'used recently',
1904
+ locked: 'locked',
1905
+ protected_branch: 'on a main branch',
1906
+ missing_or_unreadable: 'unreadable',
1907
+ missing_head: 'without a commit',
1908
+ remove_failed: 'failed to remove',
1909
+ base_refresh_failed: 'main branch unreachable',
1910
+ base_unverified: 'main branch moved during the check',
1911
+ path_not_canonical: 'behind a link',
1912
+ size_unknown_or_cleanup_limit: 'over the per-run limit',
1913
+ changed_or_active_during_verification: 'changed during the check',
1914
+ };
1915
+
1916
+ function plural(count, one, many) {
1917
+ return `${count} ${count === 1 ? one : many}`;
1918
+ }
1919
+
1920
+ // The folder that holds .agent-worktrees: this folder if it has one, else
1921
+ // the one above the repo's main checkout.
1922
+ function defaultSweepWorkspace(cwd = process.cwd()) {
1923
+ if (fs.existsSync(path.join(cwd, space.AGENT_COPIES_DIR))) return cwd;
1924
+ try {
1925
+ return path.dirname(findPrimaryRoot(repoRoot(cwd)));
1926
+ } catch {
1927
+ return cwd;
1928
+ }
1929
+ }
1930
+
1931
+ function sweepSchedulePlan({ workspace, everyHours = SWEEP_EVERY_HOURS, homeDir = os.homedir(), platform = process.platform, atrisBin = ATRIS_BIN, envPath = process.env.PATH || '' }) {
1932
+ return planKeep({
1933
+ root: workspace,
1934
+ home: path.join(homeDir, '.atris', 'worktree-sweep'),
1935
+ platform,
1936
+ homeDir,
1937
+ atrisBin,
1938
+ envPath,
1939
+ job: {
1940
+ name: 'worktree-sweep',
1941
+ args: ['worktree', 'sweep', '--apply', '--workspace', workspace],
1942
+ every_seconds: Math.round(everyHours * 3600),
1943
+ marker: 'ATRIS_WORKTREE_SWEEP',
1944
+ },
1945
+ });
1946
+ }
1947
+
1948
+ function sweepSchedule(args, workspace) {
1949
+ const everyHours = Number(readFlag(args, '--every', String(SWEEP_EVERY_HOURS)));
1950
+ if (!Number.isFinite(everyHours) || everyHours <= 0) {
1951
+ console.error('Usage: atris worktree sweep --install [--every <hours>] [--workspace <dir>]');
1952
+ return 2;
1953
+ }
1954
+ const plan = sweepSchedulePlan({ workspace, everyHours });
1955
+ if (hasFlag(args, '--uninstall')) {
1956
+ const removed = removeKeep(plan);
1957
+ if (!removed.ok) {
1958
+ console.error(`refusing: ${removed.error}`);
1959
+ return 1;
1960
+ }
1961
+ console.log(`sweep timer: ${removed.removed === false ? 'was not on' : 'off'} for ${workspace}`);
1962
+ return 0;
1963
+ }
1964
+ // The timer runs this atris file every few hours; a copy or temp folder vanishes.
1965
+ if (space.isAgentCopyPath(ATRIS_BIN) || space.isScratchPath(ATRIS_BIN)) {
1966
+ 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`);
1967
+ return 2;
1968
+ }
1969
+ const installed = installKeep(plan);
1970
+ if (!installed.ok) {
1971
+ console.error(`refusing: ${installed.error}`);
1972
+ return 1;
1973
+ }
1974
+ console.log(`sweep timer: on for ${workspace}, every ${everyHours} ${everyHours === 1 ? 'hour' : 'hours'}`);
1975
+ console.log(`log: ${plan.log_path}`);
1976
+ return 0;
1977
+ }
1978
+
1979
+ function sweep(args) {
1980
+ const workspace = path.resolve(readFlag(args, '--workspace') || defaultSweepWorkspace());
1981
+ if (hasFlag(args, '--install') || hasFlag(args, '--uninstall')) return sweepSchedule(args, workspace);
1982
+ const idleHours = Number(readFlag(args, '--idle-hours', String(SWEEP_IDLE_MS / 3600000)));
1983
+ if (!Number.isFinite(idleHours) || idleHours < 0) {
1984
+ console.error('Usage: atris worktree sweep [--apply] [--json] [--workspace <dir>] [--idle-hours <n>]');
1985
+ return 2;
1986
+ }
1987
+ const apply = hasFlag(args, '--apply');
1988
+ const report = sweepWorkspace({ workspace, apply, idleMs: idleHours * 3600000 });
1989
+ if (hasFlag(args, '--json')) {
1990
+ console.log(JSON.stringify({ ok: !report.error, ...report }, null, 2));
1991
+ return report.error ? 1 : 0;
1992
+ }
1993
+ console.log(`sweep: ${apply ? 'applied' : 'dry run'} in ${workspace}, ${space.formatGb(report.before)} free`);
1994
+ if (report.error) {
1995
+ console.error(`refusing: ${report.error}`);
1996
+ return 1;
1997
+ }
1998
+ let removedTotal = 0;
1999
+ let clearedTotal = 0;
2000
+ let reviewTotal = 0;
2001
+ for (const repo of report.repos) {
2002
+ if (repo.error || repo.skipped) {
2003
+ console.log(`${repo.name}: skipped (${repo.error || repo.skipped})`);
2004
+ continue;
2005
+ }
2006
+ const gone = apply ? repo.removed : repo.candidates;
2007
+ const cleared = apply ? repo.cachePruned : repo.cacheCandidates.filter((item) => item.reason === 'generated_webpack_cache');
2008
+ removedTotal += gone.length;
2009
+ clearedTotal += cleared.length;
2010
+ reviewTotal += repo.cacheCandidates.filter((item) => item.reason === 'manual_review_required').length;
2011
+ const counts = {};
2012
+ for (const item of repo.kept) {
2013
+ const label = SWEEP_KEPT_LABELS[item.reason] || item.reason;
2014
+ counts[label] = (counts[label] || 0) + 1;
2015
+ }
2016
+ const keptText = Object.entries(counts).map(([label, count]) => `${count} ${label}`).join(', ');
2017
+ 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})` : ''}`);
2018
+ for (const item of gone) console.log(` ${apply ? 'removed' : 'remove'}: ${item.path}`);
2019
+ for (const item of cleared) console.log(` ${apply ? 'cleared' : 'clear'}: ${item.path}`);
2020
+ }
2021
+ 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`);
2022
+ if (apply) {
2023
+ console.log(`free: ${space.formatGb(report.before)} before, ${space.formatGb(report.after)} after`);
2024
+ } else if (removedTotal || clearedTotal) {
2025
+ console.log('next: atris worktree sweep --apply');
2026
+ }
2027
+ return 0;
2028
+ }
2029
+
1554
2030
  function cleanup(args) {
1555
2031
  const asJson = hasFlag(args, '--json');
1556
2032
  const result = cleanupWorktrees({
@@ -1606,22 +2082,28 @@ function guide() {
1606
2082
  console.log(' --target <ref> overrides the default landing target (default: branch atris-base, else origin default branch)');
1607
2083
  console.log(' recommended verify: npm run test:fast && node --test <focused files>');
1608
2084
  console.log('');
1609
- console.log('5. Cleanup is part of creation and the janitor; run it directly any time:');
2085
+ console.log('5. Cleanup is part of creation, shipping, and the sweep; run it directly any time:');
1610
2086
  console.log(' atris worktree cleanup');
1611
2087
  console.log(' atris worktree cleanup --apply');
2088
+ console.log(' atris worktree sweep --apply every repo in the workspace');
2089
+ console.log(' atris worktree sweep --install run the sweep every 3 hours');
1612
2090
  console.log('');
1613
2091
  console.log('Notes: start uses the current upstream/default remote base, not dirty local HEAD.');
1614
- console.log('Start reaps completed checkouts first. Cleanup preserves branches and source changes, skips live work, and prunes only ignored build caches.');
2092
+ 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.');
2093
+ console.log('Start clones node_modules from a matching install when package-lock.json matches; skip npm ci when it prints "deps: cloned".');
2094
+ 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).');
2095
+ 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).');
1615
2096
  console.log('Use `atris worktree status` to see all worktrees and dirty counts.');
1616
2097
  return 0;
1617
2098
  }
1618
2099
 
1619
2100
  function help() {
1620
- console.log('Usage: atris worktree <guide|start|ship|status|guard|prune|cleanup>');
2101
+ console.log('Usage: atris worktree <guide|start|ship|status|guard|prune|cleanup|sweep>');
1621
2102
  console.log('');
1622
2103
  console.log(' atris worktree guide');
1623
- console.log(' atris worktree start --member <member>|--agent <name> --task "<task>" [--claim] [--force]');
2104
+ console.log(' atris worktree start --member <member>|--agent <name> --task "<task>" [--claim] [--force] [--no-deps]');
1624
2105
  console.log(' --force start even when another recent flight shares a task word');
2106
+ console.log(' --no-deps do not clone node_modules from a matching install');
1625
2107
  console.log(' atris worktree ship --message "<commit>" --verify "<cmd>" [--merge] [--target <ref>] [--local]');
1626
2108
  console.log(' --target <ref> override the default landing target (default: branch atris-base, else origin default branch)');
1627
2109
  console.log(' --local merge into the local primary checkout instead of pushing and opening a PR');
@@ -1631,6 +2113,8 @@ function help() {
1631
2113
  console.log(' atris worktree guard [--allow-primary] [--allow-dirty]');
1632
2114
  console.log(' atris worktree prune [--apply]');
1633
2115
  console.log(' atris worktree cleanup [--apply] [--json] [--base origin/master] reap completed checkouts and verified webpack caches');
2116
+ console.log(' atris worktree sweep [--apply] [--json] [--workspace <dir>] [--idle-hours 2] reap merged, idle copies in every repo');
2117
+ console.log(' atris worktree sweep --install [--every 3] | --uninstall run the sweep on a timer');
1634
2118
  }
1635
2119
 
1636
2120
  function worktreeCommand(args = []) {
@@ -1650,6 +2134,8 @@ function worktreeCommand(args = []) {
1650
2134
  if (sub === 'guard') return guard(rest);
1651
2135
  if (sub === 'prune') return prune(rest);
1652
2136
  if (sub === 'cleanup' || sub === 'clean') return cleanup(rest);
2137
+ if (sub === 'sweep') return sweep(rest);
2138
+ if (sub === 'reap-shipped') return reapShipped();
1653
2139
  help();
1654
2140
  return 2;
1655
2141
  }
@@ -1660,8 +2146,14 @@ module.exports = {
1660
2146
  mergePrWithRetry,
1661
2147
  createAgentWorktree,
1662
2148
  deniedLaneForShip,
2149
+ protectedPushRefusal,
1663
2150
  createOrFindPr,
1664
2151
  cleanupWorktrees,
2152
+ reapShippedCopy,
2153
+ scheduleShippedCopyRemoval,
2154
+ shippedCopyRemovalPlan,
2155
+ sweepSchedulePlan,
2156
+ sweepWorkspace,
1665
2157
  defaultMainlineBase,
1666
2158
  defaultStartBase,
1667
2159
  gitFailureDetail,