atris 3.82.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.
package/README.md CHANGED
@@ -18,7 +18,7 @@ A computer can be personal or shared by a business. It can run a job like code,
18
18
 
19
19
  Atris gives Claude Code, Codex, Cursor and any other agent a memory that lasts across sessions. The memory is plain files in your repo: `atris/MAP.md` (where everything lives, with file and line numbers), `atris/wiki/` (what the team learned, as pages linked with `[[page-name]]`), `atris/logs/` (what was done and checked each day), and `atris task` (open work, who claimed it, and the proof). Every agent reads the same files before it acts, so several agents and people share one memory, reviewed in git like code.
20
20
 
21
- Mem0, Zep and Letta remember the users of a chat app. Atris remembers the project. Guide and comparison: https://atris.ai/blog/memory-for-coding-agents
21
+ Mem0, Zep and Letta remember the users of a chat app. Atris remembers the project. Patterns and setup: [docs/agent-memory.md](docs/agent-memory.md). Comparison: https://atris.ai/blog/memory-for-coding-agents
22
22
 
23
23
  ## Check what ChatGPT can read about your business
24
24
 
@@ -7,7 +7,7 @@ const crypto = require('crypto');
7
7
  const readline = require('readline');
8
8
  const { spawn, spawnSync } = require('child_process');
9
9
  const { hasFlag, readFlag, readIntFlag } = require('../lib/arg-parser');
10
- const { memberProcessPrompt } = require('../lib/member-context');
10
+ const { memberProcessPrompt, currentWorkPrompt } = require('../lib/member-context');
11
11
  const { memberIdentityLine, missionObjectiveLine } = require('../lib/member-identity');
12
12
  const { formatToolStep, formatCodexEvent, appendProgress, reportProgress, startedLine, newRunToken, runMarker } = require('../lib/member-progress');
13
13
  const {
@@ -8147,6 +8147,8 @@ function buildTickPrompt(mission, tickIndex, maxTicks, frozen, pings = [], cwd =
8147
8147
  ...turnDownLines,
8148
8148
  ...(sharedProcess ? ['', sharedProcess] : []),
8149
8149
  ...preloadedContextLines,
8150
+ '',
8151
+ currentWorkPrompt(cwd, { ...mission, verifier: frozen.verifier }),
8150
8152
  ``,
8151
8153
  `## Your task`,
8152
8154
  `Do ONE increment of work toward the stop condition. ONE. No more.`,
package/commands/task.js CHANGED
@@ -10,6 +10,7 @@ const { execSync } = require('node:child_process');
10
10
  const path = require('path');
11
11
  const os = require('os');
12
12
  const { hasFlag } = require('../lib/arg-parser');
13
+ const { buildNextWorkContext, reconcileTaskRows } = require('../lib/next-work-context');
13
14
  const { argsWantHelp } = require('../lib/noninteractive');
14
15
  const {
15
16
  compactErrorPayload,
@@ -74,6 +75,7 @@ const {
74
75
  } = require('../lib/task-decision');
75
76
  const {
76
77
  buildFirstMinute,
78
+ loadLocalTaskState,
77
79
  deskNextCommand,
78
80
  firstTalkCommand,
79
81
  folderName,
@@ -5880,27 +5882,11 @@ function cmdNextTruth(args) {
5880
5882
  const taskDb = getTaskDb();
5881
5883
  const db = taskDb.open();
5882
5884
  const workspaceRoot = taskDb.workspaceRoot();
5883
- const rows = taskDb.listTasks(db, { workspaceRoot, limit: 500 });
5884
- const existingProj = readProjectionFile(workspaceRoot);
5885
- const dbHasActionable = rows.some((task) => {
5886
- const status = task && task.status;
5887
- return status === 'open' || status === 'claimed' || status === 'review';
5888
- });
5889
- const projHasActionable = Boolean(
5890
- existingProj
5891
- && Array.isArray(existingProj.tasks)
5892
- && existingProj.tasks.some((task) => {
5893
- const status = task && task.status;
5894
- return status === 'open' || status === 'claimed' || status === 'review';
5895
- }),
5896
- );
5885
+ const snapshot = loadLocalTaskState(workspaceRoot);
5897
5886
  let projection;
5898
5887
  let outPath;
5899
- if (!dbHasActionable && projHasActionable) {
5900
- projection = existingProj;
5901
- outPath = path.resolve(path.join(workspaceRoot || '.', '.atris', 'state', 'tasks.projection.json'));
5902
- } else if (rows.length === 0 && existingProj && Array.isArray(existingProj.tasks) && existingProj.tasks.length > 0) {
5903
- projection = existingProj;
5888
+ if (snapshot.source !== 'database') {
5889
+ projection = { tasks: snapshot.tasks };
5904
5890
  outPath = path.resolve(path.join(workspaceRoot || '.', '.atris', 'state', 'tasks.projection.json'));
5905
5891
  } else {
5906
5892
  const written = writeDefaultProjection(taskDb, db);
@@ -5928,6 +5914,7 @@ function cmdNextTruth(args) {
5928
5914
  scope: normalizeTaskQueueScope(scope),
5929
5915
  projection_path: outPath,
5930
5916
  task: task ? compactTaskFromProjection(projection, task.id) : null,
5917
+ context: buildNextWorkContext({ root: workspaceRoot, tasks: reconcileTaskRows(snapshot.tasks, projection.tasks || []), selected: task, owner: String(owner), taskSource: snapshot.source, generatedAt: snapshot.generated_at }),
5931
5918
  });
5932
5919
  return;
5933
5920
  }
@@ -12,10 +12,12 @@ const worktreeCore = require('../lib/worktree-core');
12
12
 
13
13
  const {
14
14
  ATRIS_BIN,
15
+ RESCUE_IDLE_MS,
16
+ finishShippedCopy,
17
+ formatKib,
15
18
  SWEEP_IDLE_MS,
16
19
  SWEEP_EVERY_HOURS,
17
20
  SWEEP_KEPT_LABELS,
18
- announceShippedCopyRemoval,
19
21
  assertOneLapProofTree,
20
22
  baseBranchName,
21
23
  branchName,
@@ -50,6 +52,7 @@ const {
50
52
  prMergeRef,
51
53
  protectedPushRefusal,
52
54
  readOneLapProofBinding,
55
+ reapPushedCopy,
53
56
  reapShippedCopy,
54
57
  recordWorktreeRemoval,
55
58
  refreshMovedMapRefs,
@@ -190,6 +193,7 @@ function shipHelp() {
190
193
  console.log(' protected-lane tasks (billing, money, deploy, feedback, ...) can be pushed for review without --merge;');
191
194
  console.log(' landing one with --merge or --local needs --allow-denied-lane on written order');
192
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');
193
197
  console.log(' unstaged regenerated adapter files are skipped unless staged first or named in --message');
194
198
  console.log(' recommended flight verify: npm run test:fast && node --test <focused files>');
195
199
  }
@@ -204,6 +208,7 @@ function shipWorktree(args) {
204
208
  const noPr = hasFlag(args, '--no-pr');
205
209
  const localMode = hasFlag(args, '--local') || hasFlag(args, '--local-merge');
206
210
  const merge = hasFlag(args, '--merge');
211
+ const keepCopy = hasFlag(args, '--keep-copy');
207
212
  const message = readFlag(args, '--message') || readFlag(args, '-m');
208
213
  const verify = readFlag(args, '--verify');
209
214
  let branch = currentBranch(root);
@@ -351,7 +356,7 @@ function shipWorktree(args) {
351
356
  }
352
357
  console.log('pr: skipped (local mode)');
353
358
  if (merge && !dryRun) runShipHealthCheck(primary || root);
354
- if (landedOn) announceShippedCopyRemoval(scheduleShippedCopyRemoval({ root, primary, branch, base: landedOn }));
359
+ if (landedOn) finishShippedCopy({ root, primary, branch, landedOn, keep: keepCopy });
355
360
  console.log('done: worktree shipped');
356
361
  return 0;
357
362
  }
@@ -385,6 +390,7 @@ function shipWorktree(args) {
385
390
  }
386
391
  if (state.state === 'OPEN' && !state.mergedAt) {
387
392
  console.log('merge: pending; kept the source branch while required checks finish');
393
+ finishShippedCopy({ root, primary, branch, keep: keepCopy });
388
394
  return 0;
389
395
  }
390
396
  if (state.state !== 'MERGED' || !state.mergedAt) {
@@ -414,7 +420,7 @@ function shipWorktree(args) {
414
420
  }, { worktree: root });
415
421
  } catch {}
416
422
  if (merge && !dryRun) runShipHealthCheck(primary || root);
417
- if (landedOn) announceShippedCopyRemoval(scheduleShippedCopyRemoval({ root, primary, branch, base: landedOn }));
423
+ if (!dryRun) finishShippedCopy({ root, primary, branch, landedOn, keep: keepCopy });
418
424
  console.log('done: worktree shipped');
419
425
  return 0;
420
426
  }
@@ -425,7 +431,10 @@ function reapShipped() {
425
431
  console.error('reap-shipped: ATRIS_REAP_WORKTREE is not set');
426
432
  return 2;
427
433
  }
428
- 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 || '' });
429
438
  console.log(`reap-shipped: ${result.removed ? 'removed' : 'kept'} ${target} (${result.reason})`);
430
439
  return result.removed ? 0 : 1;
431
440
  }
@@ -495,8 +504,14 @@ function sweep(args) {
495
504
  console.error('Usage: atris worktree sweep [--apply] [--json] [--workspace <dir>] [--idle-hours <n>]');
496
505
  return 2;
497
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
+ }
498
512
  const apply = hasFlag(args, '--apply');
499
- 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 });
500
515
  if (hasFlag(args, '--json')) {
501
516
  console.log(JSON.stringify({ ok: !report.error, ...report }, null, 2));
502
517
  return report.error ? 1 : 0;
@@ -514,9 +529,11 @@ function sweep(args) {
514
529
  console.log(`${repo.name}: skipped (${repo.error || repo.skipped})`);
515
530
  continue;
516
531
  }
517
- 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));
518
535
  const cleared = apply ? repo.cachePruned : repo.cacheCandidates.filter((item) => item.reason === 'generated_webpack_cache');
519
- removedTotal += gone.length;
536
+ removedTotal += gone.length + saved.length;
520
537
  clearedTotal += cleared.length;
521
538
  reviewTotal += repo.cacheCandidates.filter((item) => item.reason === 'manual_review_required').length;
522
539
  const counts = {};
@@ -526,7 +543,18 @@ function sweep(args) {
526
543
  }
527
544
  const keptText = Object.entries(counts).map(([label, count]) => `${count} ${label}`).join(', ');
528
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
+ }
529
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
+ }
530
558
  for (const item of cleared) console.log(` ${apply ? 'cleared' : 'clear'}: ${item.path}`);
531
559
  }
532
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`);
@@ -603,7 +631,8 @@ function guide() {
603
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.');
604
632
  console.log('Start clones node_modules from a matching install when package-lock.json matches; skip npm ci when it prints "deps: cloned".');
605
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).');
606
- 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.');
607
636
  console.log('Use `atris worktree status` to see all worktrees and dirty counts.');
608
637
  return 0;
609
638
  }
@@ -620,12 +649,14 @@ function help() {
620
649
  console.log(' --target <ref> override the default landing target (default: branch atris-base, else origin default branch)');
621
650
  console.log(' --local merge into the local primary checkout instead of pushing and opening a PR');
622
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');
623
653
  console.log(' recommended verify: npm run test:fast && node --test <focused files>');
624
654
  console.log(' atris worktree status');
625
655
  console.log(' atris worktree guard [--allow-primary] [--allow-dirty]');
626
656
  console.log(' atris worktree prune [--apply]');
627
657
  console.log(' atris worktree cleanup [--apply] [--json] [--base origin/master] reap completed checkouts and verified webpack caches');
628
- 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');
629
660
  console.log(' atris worktree sweep --install [--every 3] | --uninstall run the sweep on a timer');
630
661
  }
631
662
 
@@ -8,6 +8,7 @@ const { spawnSync } = require('child_process');
8
8
  const { isGenericScratchRoot } = require('./scratch-root');
9
9
  const { resolveDefaultVerifier } = require('./default-verifier');
10
10
  const { nonFalsifyingIssue } = require('./accept-verify-gate');
11
+ const { reconcileTaskRows } = require('./next-work-context');
11
12
 
12
13
  const FIRST_USE_COMMAND = 'atris "help me choose the first useful step for this project"';
13
14
  const FIRST_TALK_ASK = 'what do you want here';
@@ -176,8 +177,10 @@ function titlesFromTodoSection(root, section) {
176
177
  }
177
178
  }
178
179
 
179
- function loadLocalTasks(root = process.cwd()) {
180
+ function loadLocalTaskState(root = process.cwd(), { taskIds = [] } = {}) {
180
181
  let dbRows = [];
182
+ let lookup = () => null;
183
+ let databaseRead = false;
181
184
  try {
182
185
  const taskDb = require('./task-db');
183
186
  // Reading never creates the db, so a first look writes nothing.
@@ -185,20 +188,49 @@ function loadLocalTasks(root = process.cwd()) {
185
188
  const db = taskDb.open();
186
189
  const workspaceRoot = taskDb.workspaceRoot(root);
187
190
  const rows = taskDb.listTasks(db, { workspaceRoot, limit: 200 });
191
+ // Busy queues must not crowd the last outcomes out of the handoff.
192
+ const recent = ['done', 'failed'].flatMap((status) => taskDb.listTasks(db, { workspaceRoot, status, limit: 3, orderBy: 'updated_at' }));
193
+ const seen = new Set(rows.map((row) => row.id));
194
+ rows.push(...recent.filter((row) => !seen.has(row.id)));
188
195
  if (Array.isArray(rows) && rows.length) dbRows = taskDb.withTaskDisplayRefs(rows);
196
+ databaseRead = true;
197
+ lookup = (id) => {
198
+ const row = taskDb.getTask(db, id);
199
+ // A foreign id in a damaged projection is not this workspace's work.
200
+ if (row && !taskDb.rowInWorkspace(row, workspaceRoot)) return false;
201
+ return row ? taskDb.withTaskDisplayRefs([row])[0] : null;
202
+ };
203
+ // A shortlist is an output budget, not authority to invent a bound job.
204
+ for (const id of taskIds.slice(0, 8)) {
205
+ if (dbRows.some((row) => row.id === id)) continue;
206
+ const row = lookup(id);
207
+ if (row) dbRows.push(row);
208
+ }
189
209
  } catch {
190
210
  // Fall through to the local projection. Tests and fresh folders often have no db.
191
211
  }
192
- if (dbRows.some(isActionableTask)) return dbRows;
212
+ if (dbRows.some(isActionableTask)) return { tasks: dbRows, source: 'database' };
193
213
  const projectionPath = path.join(root, '.atris', 'state', 'tasks.projection.json');
194
214
  try {
195
215
  const parsed = JSON.parse(fs.readFileSync(projectionPath, 'utf8'));
216
+ if (parsed.workspace_root && path.resolve(parsed.workspace_root) !== path.resolve(require('./task-db').workspaceRoot(root))) {
217
+ return { tasks: dbRows, source: databaseRead ? 'database' : 'none' };
218
+ }
196
219
  const projected = Array.isArray(parsed.tasks) ? parsed.tasks : [];
197
- if (projected.some(isActionableTask) || !dbRows.length) return projected;
220
+ if (projected.some(isActionableTask) || !dbRows.length) {
221
+ const tasks = reconcileTaskRows(dbRows, projected, lookup);
222
+ const known = new Set(dbRows.map((row) => row.id));
223
+ const unknown = tasks.some((row) => !row.id || (!known.has(row.id) && !lookup(row.id)));
224
+ return { tasks, source: unknown || !databaseRead ? (dbRows.length ? 'mixed' : 'projection') : 'database', generated_at: parsed.generated_at || null };
225
+ }
198
226
  } catch {
199
227
  // Keep any done-only db rows below.
200
228
  }
201
- return dbRows;
229
+ return { tasks: dbRows, source: databaseRead ? 'database' : 'none' };
230
+ }
231
+
232
+ function loadLocalTasks(root = process.cwd()) {
233
+ return loadLocalTaskState(root).tasks;
202
234
  }
203
235
 
204
236
  function loadLatestRecap(root = process.cwd()) {
@@ -1170,6 +1202,7 @@ module.exports = {
1170
1202
  spokenBuiltList,
1171
1203
  taskStartedAt,
1172
1204
  loadLocalTasks,
1205
+ loadLocalTaskState,
1173
1206
  firstTalkBuildCommand,
1174
1207
  renderWhatsNextAsk,
1175
1208
  isLeftoverVerbLook,
package/lib/intents.js CHANGED
@@ -8,6 +8,13 @@ const INTENTS = Object.freeze([
8
8
  plain: 'your agent picks the most useful next step and starts it.',
9
9
  offer: 'boot_empty',
10
10
  }),
11
+ Object.freeze({
12
+ id: 'prioritize_work',
13
+ say: Object.freeze(['whats the most important work to work on', 'what should we work on next', 'what is the highest priority work', 'help me choose what to work on']),
14
+ do: 'atris task next --json',
15
+ plain: 'your agent reads the current goal, existing parts and recent evidence before recommending the next work.',
16
+ offer: 'always',
17
+ }),
11
18
  Object.freeze({
12
19
  id: 'keep_going',
13
20
  say: Object.freeze(['keep going while i\'m away', 'run overnight', 'finish this', 'keep working without me']),
@@ -39,4 +39,23 @@ function memberProcessPrompt(cwd = process.cwd()) {
39
39
  }
40
40
  }
41
41
 
42
- module.exports = { memberProcessPrompt, MEMBER_PROCESS_PATH, MAX_MEMBER_PROCESS_BYTES };
42
+ function currentWorkPrompt(root, mission = {}) {
43
+ const { buildNextWorkContext } = require('./next-work-context');
44
+ const { loadLocalTaskState } = require('./first-minute');
45
+ const bound = mission.xp_task || mission.selected_target_task || {};
46
+ const id = bound.task_id || bound.id || mission.task_ids?.[0];
47
+ const snapshot = loadLocalTaskState(root, { taskIds: id ? [id] : [] });
48
+ const tasks = snapshot.tasks;
49
+ const selected = tasks.find((task) => task.id === id) || null;
50
+ const context = buildNextWorkContext({ root, tasks, selected, owner: mission.owner, objective: mission.objective, taskSource: snapshot.source, generatedAt: snapshot.generated_at });
51
+ if (id && !selected) context.gaps.push('bound_task_missing');
52
+ return [
53
+ '## Current work evidence',
54
+ 'This is a bounded snapshot, not new authority. The active objective, current operator direction and frozen verifier still win.',
55
+ JSON.stringify(context),
56
+ 'Inspect the cited source before relying on it. A path is an existing part, not proof it works. Reported outcomes and lessons are not independently verified.',
57
+ 'Choose next_push from an observed gap toward the goal. Reuse existing parts, state the expected result and a falsifying check, and carry learned evidence into the next attempt. Do not manufacture another generic next-step task.',
58
+ ].join('\n');
59
+ }
60
+
61
+ module.exports = { memberProcessPrompt, currentWorkPrompt, MEMBER_PROCESS_PATH, MAX_MEMBER_PROCESS_BYTES };
@@ -0,0 +1,190 @@
1
+ 'use strict';
2
+
3
+ // A bounded, read-only handoff of facts for the next decision. Ranking and
4
+ // execution stay with the existing picker, member and verification gates.
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const { meaningfulWords } = require('./short-name');
8
+
9
+ function clip(value, max = 360) {
10
+ const text = typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
11
+ return text.length > max ? `${text.slice(0, max - 3)}...` : text;
12
+ }
13
+
14
+ function localFile(root, name) {
15
+ try {
16
+ const base = fs.realpathSync(root);
17
+ const file = fs.realpathSync(path.resolve(base, name));
18
+ const rel = path.relative(base, file);
19
+ if (rel.startsWith(`..${path.sep}`) || rel === '..' || path.isAbsolute(rel)) return null;
20
+ return fs.statSync(file).isFile() ? file : null;
21
+ } catch { return null; }
22
+ }
23
+
24
+ function readSource(root, name, maxBytes) {
25
+ const file = localFile(root, name);
26
+ if (!file) return { path: name, text: '', unavailable: true };
27
+ let fd;
28
+ try {
29
+ fd = fs.openSync(file, 'r');
30
+ const stat = fs.fstatSync(fd);
31
+ const buffer = Buffer.alloc(Math.min(stat.size, maxBytes));
32
+ const count = fs.readSync(fd, buffer, 0, buffer.length, 0);
33
+ return { path: name, text: buffer.subarray(0, count).toString('utf8'), truncated: stat.size > maxBytes, modified_at: stat.mtime.toISOString() };
34
+ } catch { return { path: name, text: '', unavailable: true }; }
35
+ finally { if (fd !== undefined) fs.closeSync(fd); }
36
+ }
37
+
38
+ // Keep the offline projection fallback, but a known row's current database
39
+ // status always wins. Include finished rows so their outcomes are not lost.
40
+ function reconcileTaskRows(rows, projected, lookup = () => null) {
41
+ const known = new Map(rows.map((row) => [row.id, row]));
42
+ const seen = new Set();
43
+ const merged = [];
44
+ for (const row of projected) {
45
+ if (!row || typeof row !== 'object') continue;
46
+ // Older offline snapshots have only a display reference. Preserve that
47
+ // evidence without pretending it has a database identity or authority.
48
+ if (!row.id) { merged.push(row); continue; }
49
+ if (seen.has(row.id)) continue;
50
+ seen.add(row.id);
51
+ const current = known.get(row.id) || lookup(row.id);
52
+ if (current !== false) merged.push(current || row);
53
+ }
54
+ return [...merged, ...rows.filter((row) => !seen.has(row.id))];
55
+ }
56
+
57
+ function matchingParts(root, map, query) {
58
+ const wanted = new Set(meaningfulWords(query, 100));
59
+ const found = new Map();
60
+ map.text.split(/\r?\n/).forEach((line, index) => {
61
+ if (!line.startsWith('|')) return;
62
+ const score = meaningfulWords(line, 100).filter((word) => wanted.has(word)).length;
63
+ if (!score) return;
64
+ for (const match of line.matchAll(/`([^`]+)`/g)) {
65
+ const name = match[1].replace(/:\d+$/, '');
66
+ if (path.isAbsolute(name) || !localFile(root, name) || found.has(name)) continue;
67
+ found.set(name, { path: name, source: `${map.path}:${index + 1}`, description: clip(line.replace(/`/g, ''), 180), score });
68
+ }
69
+ });
70
+ return [...found.values()].sort((a, b) => b.score - a.score).slice(0, 5)
71
+ .map(({ score, ...part }) => part);
72
+ }
73
+
74
+ function recentOutcomes(tasks, selected) {
75
+ return tasks.filter((task) => ['done', 'failed', 'review'].includes(task.status))
76
+ .sort((a, b) => Number(b.id === selected?.id) - Number(a.id === selected?.id)
77
+ || Number(b.updated_at || 0) - Number(a.updated_at || 0))
78
+ .slice(0, 3).map((task) => {
79
+ const meta = task.metadata || {};
80
+ return {
81
+ task_id: clip(task.id, 120),
82
+ title: clip(task.title, 160),
83
+ status: task.status,
84
+ result: clip(task.result || meta.result),
85
+ lesson: clip(task.review?.lesson || meta.latest_agent_lesson),
86
+ next: clip(task.review?.next_task || meta.latest_agent_next_task),
87
+ proof: clip(task.review?.proof || meta.latest_agent_proof),
88
+ validation: task.status === 'failed' ? 'failed' : task.status === 'review' ? 'review_pending'
89
+ : meta.accepted_by && meta.accepted_at ? 'accepted' : 'reported',
90
+ };
91
+ });
92
+ }
93
+
94
+ function otherOwner(task, owner) {
95
+ const assigned = typeof task.metadata?.assigned_to === 'string' ? task.metadata.assigned_to.trim() : '';
96
+ return Boolean((task.claimed_by && task.claimed_by !== owner) || (assigned && assigned !== owner));
97
+ }
98
+
99
+ function candidateEvidence(tasks, selected, goal, owner) {
100
+ const words = new Set(meaningfulWords(goal?.text, 100));
101
+ const { heldForPerson } = require('./member-task-pick');
102
+ return tasks.filter((task) => task.id !== selected?.id && ['open', 'claimed', 'review'].includes(task.status))
103
+ .map((task) => {
104
+ const meta = task.metadata || {};
105
+ const why = clip(meta.why_it_matters || task.explanation?.why_it_matters, 240);
106
+ const overlap = new Set(meaningfulWords(`${task.title} ${meta.objective || ''} ${why}`, 100).filter((word) => words.has(word))).size;
107
+ return {
108
+ task_id: clip(task.id, 120), title: clip(task.title, 160), status: task.status,
109
+ owner: clip(task.claimed_by || meta.assigned_to, 80) || null,
110
+ why: why || null,
111
+ held: heldForPerson(task) || (otherOwner(task, owner) ? 'other_owner' : null),
112
+ goal_word_overlap: overlap,
113
+ };
114
+ }).sort((a, b) => b.goal_word_overlap - a.goal_word_overlap).slice(0, 3);
115
+ }
116
+
117
+ function buildNextWorkContext({ root, tasks = [], selected = null, owner = '', taskSource = 'projection', objective = '', generatedAt = null }) {
118
+ const scoreboard = readSource(root, '.atris/state/scoreboard.json', 16384);
119
+ const map = readSource(root, 'atris/MAP.md', 65536);
120
+ let goal = null;
121
+ try {
122
+ const value = scoreboard.truncated ? null : JSON.parse(scoreboard.text);
123
+ if (clip(value?.goal)) goal = { text: clip(value.goal, 600), source: scoreboard.path, updated_at: typeof value.updated_at === 'string' ? clip(value.updated_at, 80) : null };
124
+ } catch { /* Missing or unreadable knowledge stays unknown. */ }
125
+ const meta = selected?.metadata || {};
126
+ const rawCheck = typeof (meta.verify || meta.verifier) === 'string' ? (meta.verify || meta.verifier).trim() : '';
127
+ // Never offer an ellipsis as an executable command.
128
+ const check = rawCheck.length <= 600 ? rawCheck : '';
129
+ const { taskCheck, heldForPerson } = require('./member-task-pick');
130
+ const runnable = check && taskCheck({ metadata: { verify: check } }).runnable;
131
+ const parts = matchingParts(root, map, [objective, selected?.title, meta.objective, goal?.text].filter(Boolean).join(' '));
132
+ const gaps = [];
133
+ if (!goal) gaps.push('current_goal_missing');
134
+ if (taskSource === 'none') gaps.push('task_state_missing');
135
+ if (scoreboard.truncated) gaps.push('goal_source_truncated');
136
+ if (map.unavailable) gaps.push('parts_map_missing');
137
+ else if (!parts.length) gaps.push('matching_parts_missing');
138
+ if (map.truncated) gaps.push('parts_map_truncated');
139
+ if (selected && !runnable) gaps.push('runnable_check_missing');
140
+ if (rawCheck.length > 600) gaps.push('check_too_large_for_snapshot');
141
+ if (taskSource === 'projection' || taskSource === 'mixed') gaps.push('task_snapshot_not_authoritative');
142
+ if (generatedAt && Date.now() - Date.parse(generatedAt) > 3 * 86400000) gaps.push('task_snapshot_stale');
143
+ const why = clip(meta.why_it_matters || selected?.explanation?.why_it_matters);
144
+ if (selected && !why) gaps.push('reason_missing');
145
+ let kind = selected && runnable ? 'execute' : 'discover';
146
+ if (gaps.includes('task_snapshot_not_authoritative') || gaps.includes('task_snapshot_stale')) kind = 'discover';
147
+ let reason = selected ? 'Inspect the relevant source and establish the missing evidence before changing it.'
148
+ : 'No selectable job is recorded. Find one observed gap toward the current goal, with a falsifying check.';
149
+ if (kind === 'execute') reason = why || 'Run the recorded check against the relevant source before claiming progress.';
150
+ if (selected && !['open', 'claimed', 'review'].includes(selected.status)) {
151
+ kind = 'discover'; reason = 'The bound task is no longer active. Inspect its recorded result before proposing another gap.';
152
+ gaps.push(selected.status === 'done' ? 'bound_task_already_finished' : 'bound_task_not_active');
153
+ } else if (selected && heldForPerson(selected)) { kind = 'human_decision'; reason = 'This work is held for a person by the existing task policy.'; }
154
+ else if (selected?.status === 'review') {
155
+ const certified = selected.review?.agent_certified || meta.agent_certified || Number(meta.agent_review_pass_count) >= 2;
156
+ kind = certified ? 'human_decision' : selected.claimed_by === owner ? 'wait' : 'review';
157
+ reason = certified ? 'The recorded work needs human acceptance.' : 'A different actor must check the finished diff before more work is chosen.';
158
+ } else if (selected && otherOwner(selected, owner)) {
159
+ kind = 'wait'; reason = 'Another owner holds this job. Do not duplicate their work.';
160
+ }
161
+ const context = {
162
+ schema: 'atris.next_work_context.v1',
163
+ workspace_root: path.resolve(root),
164
+ captured_at: new Date().toISOString(),
165
+ task_source: taskSource,
166
+ goal,
167
+ parts,
168
+ candidates: candidateEvidence(tasks, selected, goal, owner),
169
+ recent_outcomes: recentOutcomes(tasks, selected),
170
+ gaps,
171
+ next_step: { kind, reason, check: check || null },
172
+ sources: [scoreboard, map].map(({ text, ...source }) => source),
173
+ selection_basis: 'existing queue policy; candidate keyword overlap is a discovery hint, not measured business value',
174
+ };
175
+ // Keep the packet bounded even with long unicode text or unusual paths.
176
+ while (Buffer.byteLength(JSON.stringify(context)) > 10000) {
177
+ if (!gaps.includes('context_budget_limited')) gaps.push('context_budget_limited');
178
+ if (context.parts.length) context.parts.pop();
179
+ else if (context.candidates.length) context.candidates.pop();
180
+ else if (context.recent_outcomes.length) context.recent_outcomes.pop();
181
+ else {
182
+ context.next_step.check = null;
183
+ if (context.next_step.kind === 'execute') context.next_step.kind = 'discover';
184
+ break;
185
+ }
186
+ }
187
+ return context;
188
+ }
189
+
190
+ module.exports = { buildNextWorkContext, reconcileTaskRows };
package/lib/task-db.js CHANGED
@@ -545,7 +545,7 @@ function rowInWorkspace(row, ws) {
545
545
  return Boolean(rowRoot) && workspaceViewRoots(ws).includes(rowRoot);
546
546
  }
547
547
 
548
- function listTasks(db, { workspaceRoot: ws, status, claimedBy, limit }) {
548
+ function listTasks(db, { workspaceRoot: ws, status, claimedBy, limit, orderBy }) {
549
549
  const where = [];
550
550
  const args = [];
551
551
  if (ws) {
@@ -560,6 +560,7 @@ function listTasks(db, { workspaceRoot: ws, status, claimedBy, limit }) {
560
560
  FROM tasks
561
561
  ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
562
562
  ORDER BY
563
+ ${orderBy === 'updated_at' ? 'updated_at DESC,' : ''}
563
564
  CASE status WHEN 'open' THEN 0 WHEN 'claimed' THEN 1 WHEN 'review' THEN 2 WHEN 'failed' THEN 3 WHEN 'done' THEN 4 WHEN 'archived' THEN 5 ELSE 6 END,
564
565
  CASE WHEN tag='endgame' THEN 0 ELSE 1 END,
565
566
  created_at DESC
@@ -52,7 +52,8 @@ function runGit(args, { cwd = process.cwd(), check = true, timeout } = {}) {
52
52
  const network = ['fetch', 'push', 'ls-remote'].includes(args[0]);
53
53
  // Only network calls need the SSH probe; local calls skip its extra git spawn.
54
54
  const defaultTimeout = args[0] === 'push' ? GIT_PUSH_TIMEOUT_MS : network ? 30000 : undefined;
55
- const options = { cwd, encoding: 'utf8', timeout: timeout ?? defaultTimeout,
55
+ // Listing a large repo's index or files can pass the 1 MB default buffer.
56
+ const options = { cwd, encoding: 'utf8', timeout: timeout ?? defaultTimeout, maxBuffer: COMMAND_MAX_BUFFER_BYTES,
56
57
  killSignal: 'SIGKILL', env: network ? gitNetworkEnvironment(process.env, cwd) : undefined };
57
58
  let result = spawnSync('git', args, options);
58
59
  // Linked checkouts share config. A concurrent start must not silently lose
@@ -1092,14 +1093,14 @@ function worktreeHasActiveProcess(worktreePath, activeCwds) {
1092
1093
  });
1093
1094
  }
1094
1095
 
1095
- function recordWorktreeRemoval(root, { path: removedPath, branch = '', by, reason }) {
1096
+ function recordWorktreeRemoval(root, { path: removedPath, branch = '', by, reason, ...details }) {
1096
1097
  try {
1097
1098
  const common = runGit(['rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd: root, check: false });
1098
1099
  const main = common.status === 0 ? path.dirname(common.stdout.trim()) : root;
1099
1100
  const file = path.join(main, '.atris', 'state', 'worktree_removals.jsonl');
1100
1101
  fs.mkdirSync(path.dirname(file), { recursive: true });
1101
1102
  fs.appendFileSync(file, `${JSON.stringify({
1102
- at: new Date().toISOString(), path: removedPath, branch: branch || null, by, reason, pid: process.pid,
1103
+ at: new Date().toISOString(), path: removedPath, branch: branch || null, by, reason, ...details, pid: process.pid,
1103
1104
  })}\n`);
1104
1105
  } catch {}
1105
1106
  }
@@ -1195,6 +1196,478 @@ function originBaseIsCurrent(root, base) {
1195
1196
  && match[2] === `refs/heads/${branch}` && local.stdout.trim() === match[1];
1196
1197
  }
1197
1198
 
1199
+ // The guarantee: a copy is removed only when every byte in it is proven saved
1200
+ // (committed and on origin) or proven throwaway. Anything unusual, or anything
1201
+ // the checks cannot read, keeps the copy for a person.
1202
+ //
1203
+ // Throwaway ignored folders are matched by exact place, never by name alone:
1204
+ // an install or cache folder right beside the package file that makes it
1205
+ // (package.json for node_modules, .next, .cache, .turbo; pyproject.toml,
1206
+ // setup.py or setup.cfg for the Python test caches). uploads/.cache/ and a
1207
+ // .cache/ with no package.json beside it are not throwaway.
1208
+ const JS_THROWAWAY_DIRS = new Set(['node_modules', '.next', '.cache', '.turbo']);
1209
+ const JS_PACKAGE_FILES = ['package.json'];
1210
+ const PY_THROWAWAY_DIRS = new Set(['.pytest_cache', '.mypy_cache', '.ruff_cache']);
1211
+ const PY_PACKAGE_FILES = ['pyproject.toml', 'setup.py', 'setup.cfg'];
1212
+ // Written by atris itself at the top of a copy: the start sidecar, prompt
1213
+ // files, watchdogs, its scratch and cache folders, and command usage counts.
1214
+ const ATRIS_PLUMBING_IGNORED = /^\.atris\/(?:agent-worktree\.json|fleet-prompt-[^/]+\.md|codex-watchdog\.js|codex-watchdog-[^/]+\.json|runtime-tmp\/.*|cache\/.*|state\/usage\.jsonl)$/;
1215
+ const THROWAWAY_IGNORED_FILE = /^(?:(?:.*\/)?\.DS_Store|tsconfig\.tsbuildinfo|next-env\.d\.ts)$/;
1216
+ const IGNORED_WALK_LIMIT = 20000;
1217
+ const RESCUE_IDLE_MS = 7 * DAY_MS;
1218
+
1219
+ function throwawayIgnoredDir(wtPath, relative) {
1220
+ const name = path.basename(relative);
1221
+ const parent = path.dirname(relative);
1222
+ // Even at the top of the copy, a cache counts only beside the package file that makes it.
1223
+ const besidePackage = (files) => files.some((file) => {
1224
+ try { return fs.lstatSync(path.join(wtPath, parent, file)).isFile(); } catch { return false; }
1225
+ });
1226
+ if (JS_THROWAWAY_DIRS.has(name)) return besidePackage(JS_PACKAGE_FILES);
1227
+ if (PY_THROWAWAY_DIRS.has(name)) return besidePackage(PY_PACKAGE_FILES);
1228
+ // Python bytecode folders sit beside every module; one counts only when it
1229
+ // holds nothing but .pyc files.
1230
+ if (name === '__pycache__') {
1231
+ try {
1232
+ return fs.readdirSync(path.join(wtPath, relative), { withFileTypes: true })
1233
+ .every((entry) => entry.isFile() && entry.name.endsWith('.pyc'));
1234
+ } catch { return false; }
1235
+ }
1236
+ return false;
1237
+ }
1238
+
1239
+ function throwawayIgnoredFile(relative) {
1240
+ return THROWAWAY_IGNORED_FILE.test(relative) || ATRIS_PLUMBING_IGNORED.test(relative);
1241
+ }
1242
+
1243
+ // Ignored files in a copy that removal would lose: everything git ignores that
1244
+ // is not proven throwaway. null when the copy cannot be read or holds too many
1245
+ // files to check.
1246
+ function ignoredFilesToKeep(wtPath) {
1247
+ const listed = runGit(['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z'], { cwd: wtPath, check: false });
1248
+ if (listed.status !== 0 || listed.error) return null;
1249
+ const keepers = [];
1250
+ let walked = 0;
1251
+ for (const entry of String(listed.stdout || '').split('\0').filter(Boolean)) {
1252
+ const clean = entry.replace(/\/+$/, '');
1253
+ if (!entry.endsWith('/')) {
1254
+ if (!throwawayIgnoredFile(clean)) keepers.push(clean);
1255
+ continue;
1256
+ }
1257
+ if (throwawayIgnoredDir(wtPath, clean)) continue;
1258
+ const stack = [clean];
1259
+ while (stack.length) {
1260
+ const rel = stack.pop();
1261
+ let children;
1262
+ try { children = fs.readdirSync(path.join(wtPath, rel), { withFileTypes: true }); } catch { return null; }
1263
+ for (const child of children) {
1264
+ walked += 1;
1265
+ if (walked > IGNORED_WALK_LIMIT) return null;
1266
+ const childRel = `${rel}/${child.name}`;
1267
+ if (child.isDirectory()) {
1268
+ if (!throwawayIgnoredDir(wtPath, childRel)) stack.push(childRel);
1269
+ } else if (!throwawayIgnoredFile(childRel)) {
1270
+ keepers.push(childRel);
1271
+ }
1272
+ }
1273
+ }
1274
+ }
1275
+ return keepers;
1276
+ }
1277
+
1278
+ // Why a copy holds work the snapshot cannot prove it saved, or '' when it
1279
+ // holds none: index entries told to hide their edits, conflicts, sparse
1280
+ // checkouts, and nested repositories other than a registered submodule that
1281
+ // sits clean at a commit its own origin already has.
1282
+ function unprovableReason(wtPath) {
1283
+ const flags = runGit(['ls-files', '-v', '-z'], { cwd: wtPath, check: false });
1284
+ if (flags.status !== 0) return 'could not read the index';
1285
+ const hidden = String(flags.stdout || '').split('\0').filter(Boolean)
1286
+ .filter((row) => /^[a-zS]/.test(row)).map((row) => row.slice(2));
1287
+ if (hidden.length) return `index entries hide their edits (assume-unchanged or skip-worktree): ${hidden.slice(0, 3).join(', ')}`;
1288
+ const conflicts = runGit(['ls-files', '-u'], { cwd: wtPath, check: false });
1289
+ if (conflicts.status !== 0 || String(conflicts.stdout || '').trim()) return 'unresolved merge conflicts';
1290
+ const sparse = runGit(['config', '--bool', 'core.sparseCheckout'], { cwd: wtPath, check: false });
1291
+ if (String(sparse.stdout || '').trim() === 'true') return 'sparse checkout';
1292
+ // git lists an untracked folder that is its own repository as `name/`;
1293
+ // committing it would save only a pointer, not its files or commits.
1294
+ const untracked = runGit(['ls-files', '--others', '--exclude-standard', '-z'], { cwd: wtPath, check: false });
1295
+ if (untracked.status !== 0) return 'could not list untracked files';
1296
+ const nested = String(untracked.stdout || '').split('\0').filter((entry) => entry.endsWith('/'));
1297
+ if (nested.length) return `nested git repository ${nested[0].replace(/\/$/, '')} is not saved by a commit`;
1298
+ const listed = runGit(['ls-files', '-s', '-z'], { cwd: wtPath, check: false });
1299
+ if (listed.status !== 0) return 'could not read the index';
1300
+ const links = new Map();
1301
+ for (const row of String(listed.stdout || '').split('\0').filter(Boolean)) {
1302
+ const match = row.match(/^160000 ([0-9a-f]+) \d\t(.+)$/);
1303
+ if (match) links.set(match[2], match[1]);
1304
+ }
1305
+ if (!links.size) return '';
1306
+ const registered = runGit(['config', '--file', '.gitmodules', '--get-regexp', '^submodule\\..*\\.path$'], { cwd: wtPath, check: false });
1307
+ const paths = new Set(String(registered.stdout || '').split('\n').map((line) => line.split(' ').slice(1).join(' ')).filter(Boolean));
1308
+ for (const [link, oid] of links) {
1309
+ if (!paths.has(link)) return `nested git repository ${link} is not a registered submodule`;
1310
+ const dir = path.join(wtPath, link);
1311
+ let entries = [];
1312
+ try { entries = fs.readdirSync(dir); } catch {}
1313
+ if (!entries.length) continue;
1314
+ const head = runGit(['rev-parse', 'HEAD'], { cwd: dir, check: false });
1315
+ const status = runGit(['status', '--porcelain', '--ignored=matching'], { cwd: dir, check: false });
1316
+ const pushed = runGit(['branch', '-r', '--contains', oid], { cwd: dir, check: false });
1317
+ if (head.status !== 0 || head.stdout.trim() !== oid || status.status !== 0 || String(status.stdout || '').trim()
1318
+ || pushed.status !== 0 || !String(pushed.stdout || '').trim()) {
1319
+ return `submodule ${link} has work not proven on its origin`;
1320
+ }
1321
+ // A clean status proves nothing if the submodule hides edits too.
1322
+ const inner = unprovableReason(dir);
1323
+ if (inner) return `submodule ${link}: ${inner}`;
1324
+ }
1325
+ return '';
1326
+ }
1327
+
1328
+ // Settings that let git change bytes between disk and a commit. A content
1329
+ // filter (clean filters, LFS and the like) or the ident attribute (where
1330
+ // `$Id: any text $` hashes the same as `$Id$`) can hide local text even from
1331
+ // `git status`, so either keeps every copy, clean or dirty, on every removal
1332
+ // path. Line-ending and encoding conversion also keep a dirty copy, where the
1333
+ // rescue snapshot must match the disk byte for byte; on a clean copy they only
1334
+ // differ by reversible line endings or encoding, an accepted limit. A filter defined only in global config and named by no
1335
+ // attribute changes nothing and is allowed.
1336
+ const FILTER_ATTRIBUTE = /(?:^|\s)(?:filter|ident)(?:[=\s]|$)/m;
1337
+ const BYTE_CHANGING_ATTRIBUTE = /(?:^|\s)(?:filter|working-tree-encoding|eol|text|crlf|ident)(?:[=\s]|$)/m;
1338
+
1339
+ function attributeFiles(wtPath) {
1340
+ const files = [];
1341
+ const listed = runGit(['ls-files', '--cached', '--others', '-z', '--', '.gitattributes', '*/.gitattributes'], { cwd: wtPath, check: false });
1342
+ if (listed.status !== 0) return null;
1343
+ for (const rel of String(listed.stdout || '').split('\0').filter(Boolean)) files.push(path.join(wtPath, rel));
1344
+ const info = runGit(['rev-parse', '--path-format=absolute', '--git-path', 'info/attributes'], { cwd: wtPath, check: false });
1345
+ if (info.status === 0 && info.stdout.trim()) files.push(info.stdout.trim());
1346
+ const configured = runGit(['config', '--path', 'core.attributesFile'], { cwd: wtPath, check: false });
1347
+ const fallback = path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'git', 'attributes');
1348
+ files.push(configured.status === 0 && configured.stdout.trim() ? configured.stdout.trim() : fallback);
1349
+ return files;
1350
+ }
1351
+
1352
+ function byteChangingSetting(wtPath, { filtersOnly = false } = {}) {
1353
+ const local = runGit(['config', '--local', '--get-regexp', '^filter\\.'], { cwd: wtPath, check: false });
1354
+ if (String(local.stdout || '').trim()) return 'a content filter is set in this repo';
1355
+ const worktreeScope = runGit(['config', '--worktree', '--get-regexp', '^filter\\.'], { cwd: wtPath, check: false });
1356
+ if (String(worktreeScope.stdout || '').trim()) return 'a content filter is set in this copy';
1357
+ for (const key of filtersOnly ? [] : ['core.autocrlf', 'core.eol']) {
1358
+ const value = String(runGit(['config', key], { cwd: wtPath, check: false }).stdout || '').trim().toLowerCase();
1359
+ if (value && value !== 'false' && !(key === 'core.eol' && value === 'native')) return `${key} converts line endings`;
1360
+ }
1361
+ const files = attributeFiles(wtPath);
1362
+ if (!files) return 'could not list attribute files';
1363
+ for (const file of files) {
1364
+ let text = '';
1365
+ try { text = fs.readFileSync(file, 'utf8'); } catch (error) {
1366
+ if (error && error.code === 'ENOENT') continue;
1367
+ return `could not read ${file}`;
1368
+ }
1369
+ const rows = text.split(/\r?\n/).filter((row) => !/^\s*(?:#|$)/.test(row));
1370
+ const hit = rows.find((row) => (filtersOnly ? FILTER_ATTRIBUTE : BYTE_CHANGING_ATTRIBUTE).test(row));
1371
+ if (hit) return `git attributes can change bytes on commit (${path.basename(file)}: ${hit.trim().slice(0, 60)})`;
1372
+ }
1373
+ return '';
1374
+ }
1375
+
1376
+ function sizeKib(target) {
1377
+ const measured = spawnSync('du', ['-sk', target], { encoding: 'utf8', timeout: 30000, killSignal: 'SIGKILL' });
1378
+ const match = measured.status === 0 && !String(measured.stderr || '').trim() && String(measured.stdout || '').match(/^(\d+)\s/);
1379
+ return match ? Number(match[1]) : 0;
1380
+ }
1381
+
1382
+ function formatKib(kib) {
1383
+ if (!Number.isFinite(kib) || kib <= 0) return 'an unknown amount';
1384
+ if (kib >= 1024 * 1024) return `${(kib / (1024 * 1024)).toFixed(1)} GB`;
1385
+ if (kib >= 1024) return `${Math.round(kib / 1024)} MB`;
1386
+ return `${kib} KB`;
1387
+ }
1388
+
1389
+ function worktreeEntry(root, wtPath) {
1390
+ return listWorktrees(root).find((wt) => canonicalPath(wt.path) === canonicalPath(wtPath)) || null;
1391
+ }
1392
+
1393
+ function remoteBranchOid(root, branch) {
1394
+ const remote = runGit(['ls-remote', 'origin', `refs/heads/${branch}`], { cwd: root, check: false });
1395
+ if (remote.status !== 0 || remote.error) return null;
1396
+ const match = String(remote.stdout || '').match(/^([0-9a-f]{40,64})\s+refs\/heads\/(\S+)$/m);
1397
+ return match && match[2] === branch ? match[1] : '';
1398
+ }
1399
+
1400
+ // Where the copy's last commit is safe outside the copy: merged into the
1401
+ // branch it landed on, or sitting on the same branch at origin.
1402
+ function shippedWorkHome(root, { head, branch, landedOn }) {
1403
+ if (landedOn) {
1404
+ if (landedOn.startsWith('origin/')) refreshRemoteRef(root, landedOn);
1405
+ if (runGit(['merge-base', '--is-ancestor', head, landedOn], { cwd: root, check: false }).status === 0) {
1406
+ return baseBranchName(landedOn);
1407
+ }
1408
+ }
1409
+ if (branch && remoteBranchOid(root, branch) === head) return `origin/${branch}`;
1410
+ return '';
1411
+ }
1412
+
1413
+ // Remove a copy right after ship lands or pushes it. Runs from the main
1414
+ // checkout, never from inside the copy. A copy with changes, files that cannot
1415
+ // come back, or another program inside it is kept and the reason returned.
1416
+ function removeShippedCopyNow({ root, primary, branch = '', landedOn = '', keep = false, env = process.env, atrisBin = ATRIS_BIN, activePaths = listActiveProcessPaths } = {}) {
1417
+ if (keep) return { removed: false, reason: 'kept on request (--keep-copy)' };
1418
+ if (env.ATRIS_WORKTREE_KEEP_AFTER_SHIP === '1') return { removed: false, reason: 'ATRIS_WORKTREE_KEEP_AFTER_SHIP=1' };
1419
+ const copy = canonicalPath(root);
1420
+ if (!primary || copy === canonicalPath(primary)) return { removed: false, reason: 'primary checkout', quiet: true };
1421
+ if (!space.isAgentCopyPath(copy)) return { removed: false, reason: 'not an agent build copy', quiet: true };
1422
+ if (space.isInside(atrisBin, copy)) return { removed: false, reason: 'this atris runs from inside the copy' };
1423
+ const mainRoot = canonicalPath(primary);
1424
+ const wt = worktreeEntry(mainRoot, copy);
1425
+ if (!wt) return { removed: false, reason: 'not registered' };
1426
+ if (wt.locked) return { removed: false, reason: 'locked' };
1427
+ const counts = statusCounts(copy);
1428
+ if (!counts) return { removed: false, reason: 'unreadable' };
1429
+ if (counts.staged || counts.unstaged || counts.untracked) {
1430
+ return { removed: false, reason: `it still has ${counts.staged + counts.unstaged + counts.untracked} unsaved changes` };
1431
+ }
1432
+ const keepers = ignoredFilesToKeep(copy);
1433
+ if (!keepers) return { removed: false, reason: 'could not check the files git ignores', retry: true };
1434
+ if (keepers.length) return { removed: false, reason: `it holds ignored files atris cannot rebuild: ${keepers.slice(0, 3).join(', ')}` };
1435
+ const unprovable = byteChangingSetting(copy, { filtersOnly: true }) || unprovableReason(copy);
1436
+ if (unprovable) return { removed: false, reason: unprovable };
1437
+ const head = runGit(['rev-parse', 'HEAD'], { cwd: copy, check: false });
1438
+ const headOid = head.status === 0 ? head.stdout.trim() : '';
1439
+ if (!headOid) return { removed: false, reason: 'no commit' };
1440
+ const home = shippedWorkHome(mainRoot, { head: headOid, branch, landedOn });
1441
+ if (!home) return { removed: false, reason: 'could not confirm the work reached origin', retry: true };
1442
+ // Step out first so this process is not one of the programs inside. The
1443
+ // shell that ran ship still is, so a copy someone stands in waits for the
1444
+ // delayed remover instead of vanishing under them.
1445
+ if (space.isInside(process.cwd(), copy)) {
1446
+ try { process.chdir(mainRoot); } catch { return { removed: false, reason: 'could not step out of the copy', retry: true }; }
1447
+ }
1448
+ const active = activePaths();
1449
+ if (!Array.isArray(active)) return { removed: false, reason: 'could not list running programs', retry: true };
1450
+ if (worktreeHasActiveProcess(copy, active)) return { removed: false, reason: 'a program is still working in it', retry: true };
1451
+ const kib = sizeKib(copy);
1452
+ const removed = runGit(['worktree', 'remove', copy], { cwd: mainRoot, check: false });
1453
+ if (removed.status !== 0) return { removed: false, reason: `git refused: ${gitFailureDetail(removed) || 'remove failed'}`, retry: true };
1454
+ recordWorktreeRemoval(mainRoot, { path: copy, branch, by: 'atris worktree ship', reason: 'shipped', saved_on: home, size_kib: kib });
1455
+ return { removed: true, kib, home, path: copy };
1456
+ }
1457
+
1458
+ // After a successful ship: remove the copy now, or hand a landed copy to the
1459
+ // delayed remover when something still runs inside it.
1460
+ function finishShippedCopy({ root, primary, branch, landedOn = '', keep = false, log = console.log, schedule = scheduleShippedCopyRemoval }) {
1461
+ const result = removeShippedCopyNow({ root, primary, branch, landedOn, keep });
1462
+ if (result.removed) {
1463
+ log(`cleanup: removed this build copy and freed ${formatKib(result.kib)}; the work is safe on ${result.home}`);
1464
+ return result;
1465
+ }
1466
+ if (result.retry && !keep) {
1467
+ log(`cleanup: not yet (${result.reason})`);
1468
+ announceShippedCopyRemoval(schedule({ root, primary, branch, base: landedOn }));
1469
+ return result;
1470
+ }
1471
+ if (!result.quiet) log(`cleanup: kept this build copy (${result.reason})`);
1472
+ return result;
1473
+ }
1474
+
1475
+ // The delayed remover for a copy whose branch was pushed but not merged:
1476
+ // wait for quiet, then retry the same checks ship ran until the copy is free.
1477
+ function reapPushedCopy({
1478
+ primary,
1479
+ target,
1480
+ branch = '',
1481
+ settleMs = SHIP_SETTLE_MS,
1482
+ pollMs = SHIP_REAP_POLL_MS,
1483
+ giveUpMs = SHIP_REAP_GIVE_UP_MS,
1484
+ sleep = sleepSync,
1485
+ clock = Date.now,
1486
+ remove = removeShippedCopyNow,
1487
+ } = {}) {
1488
+ const startedAt = clock();
1489
+ sleep(settleMs);
1490
+ for (;;) {
1491
+ const result = remove({ root: target, primary, branch });
1492
+ if (result.removed) return { removed: true, reason: 'pushed' };
1493
+ if (!result.retry || clock() - startedAt >= giveUpMs) return { removed: false, reason: result.reason };
1494
+ sleep(pollMs);
1495
+ }
1496
+ }
1497
+
1498
+ // Snapshot a copy as two git trees through a throwaway copy of its index, so
1499
+ // the copy and its own index stay untouched: what is staged, and everything on
1500
+ // disk including untracked files. Keeping both means a staged version that
1501
+ // differs from the file on disk survives too.
1502
+ function snapshotTrees(wtPath) {
1503
+ const realIndex = String(runGit(['rev-parse', '--path-format=absolute', '--git-path', 'index'], { cwd: wtPath, check: false }).stdout || '').trim();
1504
+ const index = path.join(os.tmpdir(), `atris-rescue-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.index`);
1505
+ const env = { ...process.env, GIT_INDEX_FILE: index };
1506
+ const run = (args) => spawnSync('git', args, { cwd: wtPath, encoding: 'utf8', env, maxBuffer: COMMAND_MAX_BUFFER_BYTES, timeout: 120000, killSignal: 'SIGKILL' });
1507
+ try {
1508
+ if (realIndex && fs.existsSync(realIndex)) fs.copyFileSync(realIndex, index);
1509
+ else if (run(['read-tree', 'HEAD']).status !== 0) return null;
1510
+ const staged = run(['write-tree']);
1511
+ if (staged.status !== 0) return null;
1512
+ if (run(['add', '-A']).status !== 0) return null;
1513
+ const disk = run(['write-tree']);
1514
+ if (disk.status !== 0) return null;
1515
+ return { staged: staged.stdout.trim(), disk: disk.stdout.trim() };
1516
+ } finally {
1517
+ try { fs.rmSync(index, { force: true }); } catch {}
1518
+ }
1519
+ }
1520
+
1521
+ function newestChangeMs(wtPath) {
1522
+ let newest = lastCopyActivityMs(wtPath);
1523
+ const status = runGit(['status', '--porcelain=v1', '-z', '--untracked-files=all'], { cwd: wtPath, check: false });
1524
+ if (status.status !== 0) return newest;
1525
+ const fields = String(status.stdout || '').split('\0');
1526
+ for (let i = 0; i < fields.length; i += 1) {
1527
+ const row = fields[i];
1528
+ if (row.length < 4) continue;
1529
+ if (row[0] === 'R' || row[0] === 'C') i += 1;
1530
+ try { newest = Math.max(newest, fs.lstatSync(path.join(wtPath, row.slice(3))).mtimeMs); } catch {}
1531
+ }
1532
+ return newest;
1533
+ }
1534
+
1535
+ function commitIdentityEnv(wtPath) {
1536
+ const email = runGit(['config', 'user.email'], { cwd: wtPath, check: false });
1537
+ const name = runGit(['config', 'user.name'], { cwd: wtPath, check: false });
1538
+ const env = { ...process.env };
1539
+ if (email.status !== 0 || !email.stdout.trim()) {
1540
+ env.GIT_AUTHOR_EMAIL = '299057014+atris-builder[bot]@users.noreply.github.com';
1541
+ env.GIT_COMMITTER_EMAIL = env.GIT_AUTHOR_EMAIL;
1542
+ }
1543
+ if (name.status !== 0 || !name.stdout.trim()) {
1544
+ env.GIT_AUTHOR_NAME = 'atris worktree sweep';
1545
+ env.GIT_COMMITTER_NAME = env.GIT_AUTHOR_NAME;
1546
+ }
1547
+ return env;
1548
+ }
1549
+
1550
+ // Rescue of abandoned dirty copies. Off unless the sweep runs with --rescue;
1551
+ // the timer never passes it, and the default sweep only reports these copies.
1552
+ //
1553
+ // Guarantee: a dirty copy is removed only when every byte in it is proven
1554
+ // saved on origin or proven throwaway. The staged and on-disk versions are
1555
+ // committed onto a brand-new atris-rescue/<copy>-<time> branch, created only
1556
+ // if the name is free here and on origin, pushed, confirmed on origin, and
1557
+ // the copy is checked again before removal. No existing branch ever moves.
1558
+ // Anything unusual keeps the copy for a person, with the reason.
1559
+ //
1560
+ // Accepted limits, each of which keeps the copy instead of risking it:
1561
+ // - ignored files other than installs and caches beside their package file,
1562
+ // or atris's own plumbing
1563
+ // - nested repositories other than a submodule clean at a pushed commit
1564
+ // - index flags that hide edits, conflicts, sparse checkouts
1565
+ // - content filters and the ident attribute (on every removal path, clean
1566
+ // copies too), and for dirty copies line-ending or encoding conversion
1567
+ // - a push the remote or its hooks refuse
1568
+ // What it cannot see: a program that writes into the copy between the last
1569
+ // check and the removal without holding a file open or standing in it.
1570
+ //
1571
+ // Returns { kept } with a reason, { candidate } on a dry run, or { removed }.
1572
+ function rescueAbandonedCopy({ root, wt, wtPath, item, counts, apply, now, idleMs, rescueIdleMs, rescue = false, activeCwdsOverride, budget, by }) {
1573
+ const dirty = Boolean(counts && (counts.staged || counts.unstaged || counts.untracked));
1574
+ const baseReason = dirty ? 'dirty' : (wt.branch === 'detached' ? 'detached_unmerged' : 'unmerged');
1575
+ const keep = (reason, extra = {}) => ({ kept: { ...item, reason, ...(dirty ? { staged: counts.staged, unstaged: counts.unstaged, untracked: counts.untracked } : {}), ...extra } });
1576
+ const named = Boolean(wt.branch && wt.branch !== 'detached');
1577
+ if (runGit(['remote', 'get-url', 'origin'], { cwd: root, check: false }).status !== 0) return keep(baseReason, { rescue: 'no origin to push to' });
1578
+ const keepers = ignoredFilesToKeep(wtPath);
1579
+ if (!keepers) return keep('ignored_scan_failed');
1580
+ if (keepers.length) return keep('ignored_data', { files: keepers.slice(0, 5) });
1581
+ const unprovable = byteChangingSetting(wtPath, { filtersOnly: !dirty }) || unprovableReason(wtPath);
1582
+ if (unprovable) return keep('unprovable', { why: unprovable });
1583
+ const headOid = String(runGit(['rev-parse', 'HEAD'], { cwd: wtPath, check: false }).stdout || '').trim();
1584
+ if (!headOid) return keep('missing_head');
1585
+ const ownRemote = named ? remoteBranchOid(root, wt.branch) : '';
1586
+ if (ownRemote === null) return keep(baseReason, { rescue: 'origin unreachable' });
1587
+ const alreadySaved = !dirty && named && ownRemote === headOid;
1588
+ const idleFor = now - newestChangeMs(wtPath);
1589
+ const limit = alreadySaved ? idleMs : rescueIdleMs;
1590
+ const idleDays = Math.round((idleFor / DAY_MS) * 10) / 10;
1591
+ if (idleFor < limit) return keep(baseReason, { idle_days: idleDays });
1592
+ // Without --rescue the sweep only reports a stale copy that needs saving.
1593
+ if (!alreadySaved && !rescue) return keep(dirty ? 'stale_dirty' : 'stale_unpushed', { idle_days: idleDays, next: 'atris worktree sweep --rescue' });
1594
+ const kib = sizeKib(wtPath);
1595
+ if (!kib || budget.count >= 20 || budget.kib + kib > 10 * 1024 * 1024) return keep('size_unknown_or_cleanup_limit');
1596
+ const fileCount = dirty ? counts.staged + counts.unstaged + counts.untracked : 0;
1597
+ const rescueBranch = alreadySaved ? wt.branch
1598
+ : `atris-rescue/${slugify(path.basename(wtPath), 'copy', 60)}-${stamp(new Date(now))}-${Math.random().toString(16).slice(2, 6)}`;
1599
+ const candidate = {
1600
+ ...item,
1601
+ reason: alreadySaved ? 'pushed_branch' : 'rescued_to_branch',
1602
+ rescue_branch: rescueBranch,
1603
+ changed_files: fileCount,
1604
+ idle_days: idleDays,
1605
+ size_kib: kib,
1606
+ };
1607
+ budget.count += 1;
1608
+ budget.kib += kib;
1609
+ if (!apply) return { candidate };
1610
+
1611
+ const headTree = String(runGit(['rev-parse', 'HEAD^{tree}'], { cwd: wtPath, check: false }).stdout || '').trim();
1612
+ const trees = dirty ? snapshotTrees(wtPath) : null;
1613
+ if (dirty && !trees) return keep(baseReason, { rescue: 'could not snapshot the changes' });
1614
+ let commit = headOid;
1615
+ if (dirty) {
1616
+ const trailer = 'Co-authored-by: Atris <299057014+atris-builder[bot]@users.noreply.github.com>';
1617
+ const note = `The sweep found ${path.basename(wtPath)} idle for ${idleDays} days with changes nobody committed. It saved them here before removing the copy. Nothing was reviewed or merged.`;
1618
+ const identity = commitIdentityEnv(wtPath);
1619
+ const commitTree = (tree, parent, title) => {
1620
+ const made = spawnSync('git', ['commit-tree', tree, '-p', parent, '-m', [title, '', note, '', trailer].join('\n')], {
1621
+ cwd: wtPath, encoding: 'utf8', env: identity, timeout: 30000, killSignal: 'SIGKILL',
1622
+ });
1623
+ return made.status === 0 ? made.stdout.trim() : '';
1624
+ };
1625
+ // Staged work first, then what was on disk on top, so neither version is lost.
1626
+ if (trees.staged !== headTree) {
1627
+ commit = commitTree(trees.staged, commit, 'Rescue staged work from an abandoned build copy');
1628
+ if (!commit) return keep(baseReason, { rescue: 'commit of the staged work failed' });
1629
+ }
1630
+ if (trees.disk !== trees.staged) {
1631
+ commit = commitTree(trees.disk, commit, `Rescue unsaved work from an abandoned build copy (${fileCount} changed files)`);
1632
+ if (!commit) return keep(baseReason, { rescue: 'commit of the work on disk failed' });
1633
+ }
1634
+ }
1635
+ if (!alreadySaved) {
1636
+ // Fresh name only: refuse if origin or this repo already has it.
1637
+ const taken = remoteBranchOid(root, rescueBranch);
1638
+ if (taken !== '') return keep('rescue_push_failed', { rescue_branch: rescueBranch, error: taken === null ? 'origin unreachable' : 'rescue branch name already on origin' });
1639
+ const zero = '0'.repeat(commit.length);
1640
+ const created = runGit(['update-ref', `refs/heads/${rescueBranch}`, commit, zero], { cwd: root, check: false });
1641
+ if (created.status !== 0) return keep('rescue_push_failed', { rescue_branch: rescueBranch, error: `could not create the rescue branch: ${gitFailureDetail(created)}` });
1642
+ const pushed = runGit(['push', 'origin', `refs/heads/${rescueBranch}:refs/heads/${rescueBranch}`], { cwd: root, check: false });
1643
+ if (remoteBranchOid(root, rescueBranch) !== commit) {
1644
+ return keep('rescue_push_failed', { rescue_branch: rescueBranch, rescue_commit: commit, error: (gitFailureDetail(pushed) || 'origin does not show the pushed commit').slice(0, 300) });
1645
+ }
1646
+ }
1647
+ const freshActive = Array.isArray(activeCwdsOverride) ? activeCwdsOverride.map(canonicalPath) : listActiveProcessPaths();
1648
+ const fresh = worktreeEntry(root, wtPath);
1649
+ const freshHead = String(runGit(['rev-parse', 'HEAD'], { cwd: wtPath, check: false }).stdout || '').trim();
1650
+ const freshTrees = dirty ? snapshotTrees(wtPath) : null;
1651
+ const freshKeepers = ignoredFilesToKeep(wtPath);
1652
+ const freshCounts = statusCounts(wtPath);
1653
+ if (!Array.isArray(freshActive) || worktreeHasActiveProcess(wtPath, freshActive)
1654
+ || !fresh || fresh.locked || freshHead !== headOid
1655
+ || (dirty && (!freshTrees || freshTrees.staged !== trees.staged || freshTrees.disk !== trees.disk))
1656
+ || !freshKeepers || freshKeepers.length || unprovableReason(wtPath) || byteChangingSetting(wtPath, { filtersOnly: !dirty }) || !freshCounts
1657
+ || Boolean(freshCounts.staged || freshCounts.unstaged || freshCounts.untracked) !== dirty) {
1658
+ return keep('changed_or_active_during_verification', { rescue_branch: rescueBranch, rescue_commit: commit });
1659
+ }
1660
+ const removeArgs = dirty ? ['worktree', 'remove', '--force', wtPath] : ['worktree', 'remove', wtPath];
1661
+ const removed = runGit(removeArgs, { cwd: root, check: false });
1662
+ if (removed.status !== 0) {
1663
+ return keep('remove_failed', { rescue_branch: rescueBranch, rescue_commit: commit, error: gitFailureDetail(removed) });
1664
+ }
1665
+ const originUrl = String(runGit(['remote', 'get-url', 'origin'], { cwd: root, check: false }).stdout || '').trim();
1666
+ const rescueRecord = { branch: rescueBranch, commit, remote: originUrl, copy_branch: named ? wt.branch : null, changed_files: fileCount };
1667
+ recordWorktreeRemoval(root, { path: wt.path, branch: wt.branch, by, reason: candidate.reason, rescue: rescueRecord, size_kib: kib });
1668
+ return { removed: { ...candidate, rescue_commit: commit } };
1669
+ }
1670
+
1198
1671
  function cleanupWorktrees({
1199
1672
  root = repoRoot(),
1200
1673
  base: baseOverride = '',
@@ -1207,6 +1680,8 @@ function cleanupWorktrees({
1207
1680
  freshGraceMs = WORKTREE_REAP_GRACE_MS,
1208
1681
  now = Date.now(),
1209
1682
  cleanupBudget = null,
1683
+ rescueIdleMs = 0,
1684
+ rescue = false,
1210
1685
  } = {}) {
1211
1686
  const worktrees = listWorktrees(root);
1212
1687
  const primary = worktrees[0]?.path ? path.resolve(worktrees[0].path) : '';
@@ -1219,6 +1694,8 @@ function cleanupWorktrees({
1219
1694
  const removed = [];
1220
1695
  const cacheCandidates = [];
1221
1696
  const cachePruned = [];
1697
+ const rescued = [];
1698
+ const rescueCandidates = [];
1222
1699
  const cacheBudget = cleanupBudget || { count: 0, kib: 0 };
1223
1700
  const scopePaths = Array.isArray(onlyPaths) ? onlyPaths.map(canonicalPath) : null;
1224
1701
  const inScope = (wtPath) => (!scopePaths || scopePaths.includes(canonicalPath(wtPath)))
@@ -1264,19 +1741,44 @@ function cleanupWorktrees({
1264
1741
  kept.push({ ...item, reason: 'missing_or_unreadable' });
1265
1742
  continue;
1266
1743
  }
1744
+ const tryRescue = (dirtyCounts) => {
1745
+ const outcome = rescueAbandonedCopy({
1746
+ root, wt, wtPath, item, counts: dirtyCounts, apply, now, idleMs, rescueIdleMs, rescue, activeCwdsOverride, budget: cacheBudget, by,
1747
+ });
1748
+ if (outcome.removed) {
1749
+ removed.push(outcome.removed);
1750
+ rescued.push(outcome.removed);
1751
+ } else if (outcome.candidate) {
1752
+ candidates.push(outcome.candidate);
1753
+ rescueCandidates.push(outcome.candidate);
1754
+ } else {
1755
+ if (outcome.kept.reason !== 'changed_or_active_during_verification') pruneCachesForKept(wtPath);
1756
+ kept.push(outcome.kept);
1757
+ }
1758
+ };
1267
1759
  if (counts.staged || counts.unstaged || counts.untracked) {
1760
+ if (rescueIdleMs > 0) {
1761
+ tryRescue(counts);
1762
+ continue;
1763
+ }
1268
1764
  pruneCachesForKept(wtPath);
1269
1765
  kept.push({ ...item, reason: 'dirty', staged: counts.staged, unstaged: counts.unstaged, untracked: counts.untracked });
1270
1766
  continue;
1271
1767
  }
1272
- const ignored = runGit(['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z'], { cwd: wt.path, check: false });
1273
- if (ignored.status !== 0 || ignored.error) {
1768
+ const keepers = ignoredFilesToKeep(wt.path);
1769
+ if (!keepers) {
1274
1770
  kept.push({ ...item, reason: 'ignored_scan_failed' });
1275
1771
  continue;
1276
1772
  }
1277
- if (ignored.stdout) {
1773
+ if (keepers.length) {
1278
1774
  pruneCachesForKept(wtPath);
1279
- kept.push({ ...item, reason: 'ignored_data' });
1775
+ kept.push({ ...item, reason: 'ignored_data', files: keepers.slice(0, 5) });
1776
+ continue;
1777
+ }
1778
+ const unprovable = byteChangingSetting(wt.path, { filtersOnly: true }) || unprovableReason(wt.path);
1779
+ if (unprovable) {
1780
+ pruneCachesForKept(wtPath);
1781
+ kept.push({ ...item, reason: 'unprovable', why: unprovable });
1280
1782
  continue;
1281
1783
  }
1282
1784
  if (!wt.head) {
@@ -1289,6 +1791,10 @@ function cleanupWorktrees({
1289
1791
  continue;
1290
1792
  }
1291
1793
  const merged = runGit(['merge-base', '--is-ancestor', wt.head, base], { cwd: root, check: false }).status === 0;
1794
+ if (!merged && rescueIdleMs > 0) {
1795
+ tryRescue(null);
1796
+ continue;
1797
+ }
1292
1798
  if (!merged) {
1293
1799
  pruneCachesForKept(wtPath);
1294
1800
  kept.push({ ...item, reason: wt.branch === 'detached' ? 'detached_unmerged' : 'unmerged' });
@@ -1326,14 +1832,14 @@ function cleanupWorktrees({
1326
1832
  if (!apply) continue;
1327
1833
  const freshActive = Array.isArray(activeCwdsOverride) ? activeCwds : listActiveProcessPaths();
1328
1834
  const freshCounts = statusCounts(wt.path);
1329
- const freshIgnored = runGit(['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z'], { cwd: wt.path, check: false });
1835
+ const freshKeepers = ignoredFilesToKeep(wt.path);
1330
1836
  const freshSize = spawnSync('du', ['-sk', wtPath], { encoding: 'utf8', timeout: 30000, killSignal: 'SIGKILL' });
1331
1837
  const freshMatch = freshSize.status === 0 && !String(freshSize.stderr || '').trim() && String(freshSize.stdout || '').match(/^(\d+)\s/);
1332
1838
  const gitdir = runGit(['rev-parse', '--absolute-git-dir'], { cwd: wtPath, check: false });
1333
1839
  const freshHead = runGit(['rev-parse', 'HEAD'], { cwd: wtPath, check: false });
1334
1840
  if (!Array.isArray(freshActive) || worktreeHasActiveProcess(wtPath, freshActive)
1335
1841
  || !freshCounts || freshCounts.staged || freshCounts.unstaged || freshCounts.untracked
1336
- || freshIgnored.status !== 0 || freshIgnored.stdout || !freshMatch || Number(freshMatch[1]) !== kib
1842
+ || !freshKeepers || freshKeepers.length || unprovableReason(wt.path) || byteChangingSetting(wt.path, { filtersOnly: true }) || !freshMatch || Number(freshMatch[1]) !== kib
1337
1843
  || gitdir.status !== 0 || !gitdir.stdout.trim() || fs.existsSync(path.join(gitdir.stdout.trim(), 'locked'))
1338
1844
  || freshHead.status !== 0 || String(freshHead.stdout || '').trim() !== wt.head
1339
1845
  || runGit(['merge-base', '--is-ancestor', wt.head, base], { cwd: root, check: false }).status !== 0
@@ -1351,17 +1857,17 @@ function cleanupWorktrees({
1351
1857
  }
1352
1858
  }
1353
1859
 
1354
- return { apply, base, candidates, removed, kept, cacheCandidates, cachePruned };
1860
+ return { apply, base, candidates, removed, kept, cacheCandidates, cachePruned, rescued, rescueCandidates };
1355
1861
  }
1356
1862
 
1357
1863
  const SWEEP_IDLE_MS = 2 * 60 * 60 * 1000;
1358
1864
  const SWEEP_EVERY_HOURS = 3;
1359
1865
  const SWEEP_HIDDEN_KEPT = new Set(['primary_checkout', 'current_checkout', 'out_of_scope']);
1360
1866
 
1361
- function sweepWorkspace({ workspace, apply = false, idleMs = SWEEP_IDLE_MS, activeCwds, freeSpace = space.freeBytes, by = 'atris worktree sweep', cleanupBudget = null } = {}) {
1867
+ function sweepWorkspace({ workspace, apply = false, idleMs = SWEEP_IDLE_MS, rescueIdleMs = RESCUE_IDLE_MS, rescue = false, activeCwds, freeSpace = space.freeBytes, by = 'atris worktree sweep', cleanupBudget = null } = {}) {
1362
1868
  const root = path.resolve(workspace);
1363
1869
  const before = freeSpace(root);
1364
- const report = { workspace: root, apply, idle_ms: idleMs, before, after: before, repos: [] };
1870
+ const report = { workspace: root, apply, rescue, idle_ms: idleMs, rescue_idle_ms: rescueIdleMs, before, after: before, repos: [] };
1365
1871
  const scan = Array.isArray(activeCwds) ? activeCwds.map(canonicalPath) : listActiveProcessPaths();
1366
1872
  if (!Array.isArray(scan)) return { ...report, error: 'could not list running programs, so every copy was kept' };
1367
1873
  const scope = path.join(root, space.AGENT_COPIES_DIR);
@@ -1375,12 +1881,14 @@ function sweepWorkspace({ workspace, apply = false, idleMs = SWEEP_IDLE_MS, acti
1375
1881
  continue;
1376
1882
  }
1377
1883
  const shared = Array.isArray(activeCwds) || !apply ? scan : undefined;
1378
- const result = cleanupWorktrees({ root: repo.root, base, apply, activeCwds: shared, by, onlyUnder: scope, idleMs, cleanupBudget });
1884
+ const result = cleanupWorktrees({ root: repo.root, base, apply, activeCwds: shared, by, onlyUnder: scope, idleMs, rescueIdleMs, rescue, cleanupBudget });
1379
1885
  report.repos.push({
1380
1886
  ...entry,
1381
1887
  base: result.base,
1382
1888
  removed: result.removed,
1383
1889
  candidates: result.candidates,
1890
+ rescued: result.rescued,
1891
+ rescueCandidates: result.rescueCandidates,
1384
1892
  cachePruned: result.cachePruned,
1385
1893
  cacheCandidates: result.cacheCandidates,
1386
1894
  kept: result.kept.filter((item) => !SWEEP_HIDDEN_KEPT.has(item.reason)),
@@ -1400,7 +1908,7 @@ const SWEEP_KEPT_LABELS = {
1400
1908
  dirty: 'with changes',
1401
1909
  unmerged: 'not merged',
1402
1910
  detached_unmerged: 'not merged',
1403
- ignored_data: 'holding files git ignores (kept for review)',
1911
+ ignored_data: 'holding ignored files that cannot be rebuilt (kept for review)',
1404
1912
  ignored_scan_failed: 'unreadable',
1405
1913
  fresh_worktree_grace: 'new',
1406
1914
  recent_activity: 'used recently',
@@ -1414,6 +1922,10 @@ const SWEEP_KEPT_LABELS = {
1414
1922
  path_not_canonical: 'behind a link',
1415
1923
  size_unknown_or_cleanup_limit: 'over the per-run limit',
1416
1924
  changed_or_active_during_verification: 'changed during the check',
1925
+ rescue_push_failed: 'push of its saved work failed',
1926
+ unprovable: 'holding work atris cannot prove it saved',
1927
+ stale_dirty: 'stale with unsaved changes (save with --rescue)',
1928
+ stale_unpushed: 'stale with commits not on origin (save with --rescue)',
1417
1929
  };
1418
1930
 
1419
1931
  function plural(count, one, many) {
@@ -1448,10 +1960,13 @@ function sweepSchedulePlan({ workspace, everyHours = SWEEP_EVERY_HOURS, homeDir
1448
1960
 
1449
1961
  module.exports = {
1450
1962
  ATRIS_BIN,
1963
+ reapPushedCopy,
1964
+ RESCUE_IDLE_MS,
1965
+ finishShippedCopy,
1966
+ formatKib,
1451
1967
  SWEEP_IDLE_MS,
1452
1968
  SWEEP_EVERY_HOURS,
1453
1969
  SWEEP_KEPT_LABELS,
1454
- announceShippedCopyRemoval,
1455
1970
  assertOneLapProofTree,
1456
1971
  baseBranchName,
1457
1972
  branchName,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.82.1",
3
+ "version": "3.82.2",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof. project memory your coding agents share across sessions.",
5
5
  "mcpName": "io.github.atrislabs/atris",
6
6
  "main": "bin/atris.js",