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 +1 -1
- package/commands/mission.js +3 -1
- package/commands/task.js +6 -19
- package/commands/worktree.js +40 -9
- package/lib/first-minute.js +37 -4
- package/lib/intents.js +7 -0
- package/lib/member-context.js +20 -1
- package/lib/next-work-context.js +190 -0
- package/lib/task-db.js +2 -1
- package/lib/worktree-core.js +530 -15
- package/package.json +1 -1
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.
|
|
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
|
|
package/commands/mission.js
CHANGED
|
@@ -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
|
|
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 (
|
|
5900
|
-
projection =
|
|
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
|
}
|
package/commands/worktree.js
CHANGED
|
@@ -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)
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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]
|
|
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
|
|
package/lib/first-minute.js
CHANGED
|
@@ -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
|
|
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)
|
|
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']),
|
package/lib/member-context.js
CHANGED
|
@@ -39,4 +39,23 @@ function memberProcessPrompt(cwd = process.cwd()) {
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
|
|
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
|
package/lib/worktree-core.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
1273
|
-
if (
|
|
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 (
|
|
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
|
|
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
|
-
||
|
|
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
|
|
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.
|
|
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",
|