atris 3.63.0 → 3.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +4 -0
  2. package/atris/skills/commerce/SKILL.md +24 -0
  3. package/atris/skills/x-search/SKILL.md +3 -1
  4. package/ax +4 -3
  5. package/bin/atris.js +28 -2
  6. package/cli/atris_code.py +25 -1
  7. package/commands/agent-spawn.js +1 -0
  8. package/commands/auth.js +79 -10
  9. package/commands/autoland.js +11 -1
  10. package/commands/browse.js +2 -2
  11. package/commands/business.js +3 -3
  12. package/commands/buy.js +3 -3
  13. package/commands/deck.js +2 -2
  14. package/commands/engine.js +292 -65
  15. package/commands/feedback.js +2 -2
  16. package/commands/fork.js +2 -2
  17. package/commands/gm.js +2 -1
  18. package/commands/hire.js +189 -0
  19. package/commands/improve.js +142 -8
  20. package/commands/integrations.js +4 -3
  21. package/commands/loops.js +2 -1
  22. package/commands/mail.js +163 -0
  23. package/commands/member.js +47 -9
  24. package/commands/mission.js +4 -3
  25. package/commands/pack-craft.js +2 -2
  26. package/commands/publish.js +2 -2
  27. package/commands/radar.js +3 -2
  28. package/commands/router.js +2 -2
  29. package/commands/stream.js +22 -1
  30. package/commands/task.js +84 -2
  31. package/commands/team.js +355 -67
  32. package/commands/transactions.js +3 -3
  33. package/commands/truth.js +2 -2
  34. package/commands/x-search.js +81 -10
  35. package/commands/youtube.js +2 -1
  36. package/lib/auto-accept-certified.js +83 -9
  37. package/lib/autoland.js +3 -2
  38. package/lib/ax-auto-lane.js +2 -2
  39. package/lib/brief-rules.js +11 -0
  40. package/lib/business-target.js +131 -0
  41. package/lib/chat-log-scan.js +2 -1
  42. package/lib/ci-runner.js +3 -2
  43. package/lib/codex-flight.js +2 -1
  44. package/lib/commerce.js +17 -2
  45. package/lib/deck-history.js +2 -2
  46. package/lib/deck-review.js +3 -3
  47. package/lib/design-api.js +2 -2
  48. package/lib/engine-processes.js +430 -0
  49. package/lib/engine-registry.js +101 -21
  50. package/lib/first-minute.js +2 -2
  51. package/lib/fleet.js +8 -36
  52. package/lib/git-spawn.js +5 -2
  53. package/lib/ignored-cache.js +51 -0
  54. package/lib/known-commands.js +1 -1
  55. package/lib/manifest.js +2 -2
  56. package/lib/map-refs.js +70 -4
  57. package/lib/member-identity.js +56 -0
  58. package/lib/pack-capabilities.js +2 -1
  59. package/lib/permission-grants.js +2 -2
  60. package/lib/pulse.js +5 -5
  61. package/lib/roster-models.js +11 -0
  62. package/lib/roster-runs.js +13 -2
  63. package/lib/roster-stale.js +241 -0
  64. package/lib/runner-command.js +22 -6
  65. package/lib/sandbox-pids.js +113 -0
  66. package/lib/secret-gateway.js +3 -13
  67. package/lib/state-home.js +106 -0
  68. package/lib/task-db.js +4 -3
  69. package/lib/task-prune.js +462 -0
  70. package/lib/team-lineup.js +10 -71
  71. package/lib/team-presence.js +224 -0
  72. package/lib/text-table.js +81 -0
  73. package/lib/x-post-check.js +497 -0
  74. package/package.json +1 -1
  75. package/scripts/det/data/xsearch-answer.json +24 -0
  76. package/scripts/det/data/xsearch-live-2026-09-29.json +93 -0
  77. package/scripts/det/xsearch-bench.js +307 -0
  78. package/scripts/det/ytnotes +61 -15
  79. package/scripts/det/ytrail-bench.js +2 -1
  80. package/utils/auth.js +3 -3
  81. package/utils/update-check.js +2 -2
@@ -0,0 +1,106 @@
1
+ // Where ~/.atris state lives, with a fence for test runs.
2
+ //
3
+ // The node test runner sets NODE_TEST_CONTEXT in every test-file process, and
4
+ // CLI subprocesses spawned by tests inherit it. A test that forgets to point
5
+ // ATRIS_TASKS_DB or HOME at a temp dir used to write the user's real task
6
+ // database. Under the test runner, when HOME is still the real user home, we
7
+ // hand back a throwaway home instead, so forgetting the override is safe.
8
+ //
9
+ // Explicit choices still win: a temp HOME keeps its own .atris, and
10
+ // ATRIS_TEST_REAL_HOME=1 opts a test back into the real home on purpose.
11
+ // Outside the test runner nothing changes.
12
+ const fs = require('fs');
13
+ const os = require('os');
14
+ const path = require('path');
15
+
16
+ function normalizedPath(p) {
17
+ let resolved;
18
+ try { resolved = fs.realpathSync(p); } catch (_) { resolved = path.resolve(p); }
19
+ const foldCase = process.platform === 'darwin' || process.platform === 'win32';
20
+ return foldCase ? resolved.toLowerCase() : resolved;
21
+ }
22
+
23
+ // Same folder under any spelling: symlinks, letter case, and macOS firmlinks
24
+ // (the home folder seen through /System/Volumes/Data), which realpath leaves
25
+ // alone but which share the device and inode of the usual path.
26
+ function samePath(a, b) {
27
+ if (!a || !b) return false;
28
+ if (normalizedPath(a) === normalizedPath(b)) return true;
29
+ try {
30
+ const sa = fs.statSync(a);
31
+ const sb = fs.statSync(b);
32
+ return sa.ino !== 0 && sa.dev === sb.dev && sa.ino === sb.ino;
33
+ } catch (_) {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ // The account's home from the user database, not HOME. Read once per
39
+ // process: it cannot change while we run, and callers resolve paths often.
40
+ let realUserHome;
41
+ function realHomeOnce() {
42
+ if (realUserHome === undefined) {
43
+ try { realUserHome = os.userInfo().homedir || null; } catch (_) { realUserHome = null; }
44
+ }
45
+ return realUserHome;
46
+ }
47
+
48
+ function protectedHomes(env) {
49
+ const homes = [];
50
+ if (realHomeOnce()) homes.push(realHomeOnce());
51
+ // Test seam that can only widen the fence: marks one more folder as a
52
+ // real home, so a test can prove the fence without touching the real one.
53
+ if (env.ATRIS_TEST_PROTECTED_HOME) homes.push(env.ATRIS_TEST_PROTECTED_HOME);
54
+ return homes;
55
+ }
56
+
57
+ realHomeOnce();
58
+
59
+ function underTest(env) {
60
+ return Boolean(env.NODE_TEST_CONTEXT || env.ATRIS_TEST_STATE_DIR);
61
+ }
62
+
63
+ // os.homedir() follows HOME and any test mock of it; a caller-supplied env
64
+ // with its own HOME (like a spawned child's env) takes that instead.
65
+ function currentHome(env) {
66
+ return (env !== process.env && env.HOME) || os.homedir();
67
+ }
68
+
69
+ function testStateFenced(env = process.env) {
70
+ if (env.ATRIS_TEST_REAL_HOME === '1') return false;
71
+ if (!underTest(env)) return false;
72
+ const home = currentHome(env);
73
+ const real = protectedHomes(env);
74
+ // If we cannot tell who the real user is, assume HOME is real.
75
+ if (real.length === 0) return true;
76
+ return real.some((h) => samePath(home, h));
77
+ }
78
+
79
+ // One fence folder per OS user, shared by every test run. Tests that need
80
+ // isolation already set their own paths; tests that did not were sharing the
81
+ // real db, so sharing one throwaway db keeps that behavior without the damage.
82
+ // The choice is exported through the env so children that lose
83
+ // NODE_TEST_CONTEXT still stay fenced.
84
+ function fenceDir(env) {
85
+ if (env.ATRIS_TEST_STATE_DIR) return env.ATRIS_TEST_STATE_DIR;
86
+ let user = typeof process.getuid === 'function' ? process.getuid() : null;
87
+ if (user === null) {
88
+ try { user = os.userInfo().username; } catch (_) { user = 'user'; }
89
+ }
90
+ const dir = path.join(os.tmpdir(), `atris-test-home-${user}`);
91
+ if (env === process.env) process.env.ATRIS_TEST_STATE_DIR = dir;
92
+ return dir;
93
+ }
94
+
95
+ // The folder that holds .atris: the real home normally, a throwaway one when
96
+ // a test run would otherwise reach the real home.
97
+ function stateHome(env = process.env) {
98
+ if (testStateFenced(env)) return fenceDir(env);
99
+ return currentHome(env);
100
+ }
101
+
102
+ function atrisHome(env = process.env) {
103
+ return path.join(stateHome(env), '.atris');
104
+ }
105
+
106
+ module.exports = { stateHome, atrisHome, testStateFenced };
package/lib/task-db.js CHANGED
@@ -24,7 +24,6 @@
24
24
 
25
25
  const fs = require('fs');
26
26
  const path = require('path');
27
- const os = require('os');
28
27
  const crypto = require('crypto');
29
28
  const { DatabaseSync } = require('node:sqlite');
30
29
  const reviewIntegrity = require('./review-integrity');
@@ -35,8 +34,8 @@ const { parseVerifyCommand } = require('./auto-accept-certified');
35
34
  const { taskExplanation } = require('./task-explanation');
36
35
  const { treeHashFor } = require('./tree-hash');
37
36
  const { sleepSync } = require('./sleep-sync');
37
+ const { atrisHome } = require('./state-home');
38
38
 
39
- const DEFAULT_DB_PATH = path.join(os.homedir(), '.atris', 'tasks.db');
40
39
  const TASK_EPISODES_FILE = path.join('.atris', 'state', 'task_episodes.jsonl');
41
40
  const TODO_RENDER_DONE_LIMIT = 8;
42
41
  const TODO_RENDER_FAILED_LIMIT = 12;
@@ -188,8 +187,10 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_tasks_source ON tasks(workspace_root, sourc
188
187
  let _cachedDb = null;
189
188
  let _cachedPath = null;
190
189
 
190
+ // Default is ~/.atris/tasks.db, resolved per call so a test run that forgets
191
+ // ATRIS_TASKS_DB lands in a throwaway db instead of the real one.
191
192
  function getDbPath() {
192
- return process.env.ATRIS_TASKS_DB || DEFAULT_DB_PATH;
193
+ return process.env.ATRIS_TASKS_DB || path.join(atrisHome(), 'tasks.db');
193
194
  }
194
195
 
195
196
  function open(dbPath) {
@@ -0,0 +1,462 @@
1
+ 'use strict';
2
+
3
+ // Remove task rows that test runs wrote into the shared task database.
4
+ // A row is test junk only when its project folder sits under a temp
5
+ // directory (/tmp, /var/folders, os.tmpdir(), and their /private twins) AND
6
+ // that folder is gone from disk. Tests make and remove their temp folders;
7
+ // a live checkout under /tmp still exists, so it is never touched. Neither
8
+ // is any row with a real project path.
9
+ //
10
+ // planPrune reads only. applyPrune writes a consistent backup with
11
+ // VACUUM INTO, checks it, plans from the backup, then deletes in short
12
+ // transactions. Each transaction re-checks every task against the backup
13
+ // (same row, same history count, still idle, folder still gone), so nothing
14
+ // is removed that the backup does not hold.
15
+
16
+ const fs = require('fs');
17
+ const os = require('os');
18
+ const path = require('path');
19
+ const crypto = require('crypto');
20
+ const { DatabaseSync } = require('node:sqlite');
21
+ const { withBusyRetry } = require('./task-db');
22
+ const { sleepSync } = require('./sleep-sync');
23
+
24
+ // A temp task with any activity this recent may belong to a test still running.
25
+ const GRACE_MS = 60 * 60 * 1000;
26
+ const MAX_ROWS_PER_BATCH = 2000;
27
+ const BATCH_PAUSE_MS = 20;
28
+ const BUSY_TIMEOUT_MS = 5000;
29
+
30
+ const NAMED_TEMP_ROOTS = [
31
+ '/tmp', '/private/tmp',
32
+ '/var/tmp', '/private/var/tmp',
33
+ '/var/folders', '/private/var/folders',
34
+ ];
35
+
36
+ // History lookups by task id. Each must use the task-id index; a plan on the
37
+ // workspace/time index rescans unrelated history under the write lock.
38
+ const EVENT_SQL = {
39
+ summary: 'SELECT COUNT(*) AS n, MAX(created_at) AS last_at FROM task_events WHERE task_id = ?',
40
+ roots: 'SELECT DISTINCT workspace_root FROM task_events WHERE task_id = ?',
41
+ remove: 'DELETE FROM task_events WHERE task_id = ?',
42
+ };
43
+ // Same definition lib/task-db.js creates on open.
44
+ const TASK_ID_INDEX = 'CREATE INDEX IF NOT EXISTS idx_task_events_task ON task_events(task_id, version)';
45
+
46
+ const TASK_COLUMNS = 'id, title, status, tag, workspace_root, source_key, claimed_by, claimed_at, created_at, updated_at, done_at, metadata';
47
+
48
+ function normalizeRoot(value) {
49
+ const raw = String(value || '').replace(/\\/g, '/');
50
+ if (!raw.startsWith('/') && !/^[A-Za-z]:\//.test(raw)) return null;
51
+ // `.` and `..` can mean something else once a link is in the path; a
52
+ // folder spelled that way is never treated as temp.
53
+ if (raw.split('/').some(part => part === '.' || part === '..')) return null;
54
+ const normal = path.posix.normalize(raw);
55
+ return normal.length > 1 ? normal.replace(/\/+$/, '') : normal;
56
+ }
57
+
58
+ function realKey(key) {
59
+ try { return normalizeRoot(fs.realpathSync(key)); } catch { return null; }
60
+ }
61
+
62
+ function lstatOrNull(target) {
63
+ try { return fs.lstatSync(target); } catch { return null; }
64
+ }
65
+
66
+ // A link that is a temp root or sits above one (/tmp, /var, a linked TMPDIR)
67
+ // is expected. Any other link in the path means we cannot be sure.
68
+ function isExpectedLink(dir, roots) {
69
+ return roots.some(root => root === dir || root.startsWith(`${dir}/`));
70
+ }
71
+
72
+ // The real path of a folder that may be gone: resolve its nearest existing
73
+ // ancestor (so /var -> /private/var and a symlinked TMPDIR both line up),
74
+ // then put the missing tail back. lstat, not exists: a dangling link is
75
+ // something on disk and stops the walk. Null when any other link is in the
76
+ // existing part of the path.
77
+ function resolvedKey(key, roots) {
78
+ let head = key;
79
+ const tail = [];
80
+ while (head && head !== '/' && !lstatOrNull(head)) {
81
+ tail.unshift(path.posix.basename(head));
82
+ head = path.posix.dirname(head);
83
+ }
84
+ for (let dir = head; dir && dir !== '/'; dir = path.posix.dirname(dir)) {
85
+ const stat = lstatOrNull(dir);
86
+ if (stat && stat.isSymbolicLink() && !isExpectedLink(dir, roots)) return null;
87
+ }
88
+ const real = realKey(head);
89
+ if (!real) return null;
90
+ return tail.length ? path.posix.join(real, ...tail) : real;
91
+ }
92
+
93
+ function safeTmpdir() {
94
+ try { return os.tmpdir(); } catch { return null; }
95
+ }
96
+
97
+ function tempRoots({ tmpdir = safeTmpdir(), named = NAMED_TEMP_ROOTS } = {}) {
98
+ const roots = new Set();
99
+ for (const dir of [...named, tmpdir]) {
100
+ const key = normalizeRoot(dir);
101
+ if (!key || key === '/') continue;
102
+ roots.add(key);
103
+ const real = realKey(key);
104
+ if (real && real !== '/') roots.add(real);
105
+ }
106
+ // Shortest first so a row groups under the broad folder, e.g. /var/folders.
107
+ return [...roots].sort((a, b) => a.length - b.length || a.localeCompare(b));
108
+ }
109
+
110
+ function matchRoot(key, roots) {
111
+ for (const root of roots) {
112
+ if (key === root || key.startsWith(`${root}/`)) return root;
113
+ }
114
+ return null;
115
+ }
116
+
117
+ // fs.mkdtempSync appends six random letters or digits, and the prefixes in
118
+ // test/ end with a dash, so a test folder's last segment ends `-XXXXXX`.
119
+ // Hand-named folders (/tmp/agentxp, /tmp/bench-boot-probe) held real agent
120
+ // work and never match.
121
+ function looksMkdtemp(key) {
122
+ return /-[A-Za-z0-9]{6}$/.test(path.posix.basename(key));
123
+ }
124
+
125
+ // The temp root a project folder sits under, or null for a real project.
126
+ // The spelling alone never decides: the folder's real location (through its
127
+ // nearest existing ancestor) must sit under a temp root, so a missing
128
+ // /tmp/projects/old-repo where /tmp/projects links to a home folder is real.
129
+ // The stored spelling only picks the group label.
130
+ function tempPrefixFor(workspaceRoot, roots = tempRoots()) {
131
+ const key = normalizeRoot(workspaceRoot);
132
+ if (!key) return null;
133
+ const resolved = resolvedKey(key, roots);
134
+ if (!resolved) return null;
135
+ const real = matchRoot(resolved, roots);
136
+ if (!real || resolved === real || roots.includes(key) || !looksMkdtemp(key)) return null;
137
+ return matchRoot(key, roots) || real;
138
+ }
139
+
140
+ // Only a folder that is clearly missing counts as gone. A permission error
141
+ // or anything else odd means we cannot tell, so the row stays.
142
+ function folderGone(dir) {
143
+ try {
144
+ fs.lstatSync(dir);
145
+ return false;
146
+ } catch (err) {
147
+ return Boolean(err && (err.code === 'ENOENT' || err.code === 'ENOTDIR'));
148
+ }
149
+ }
150
+
151
+ function makeJudge(roots) {
152
+ const cache = new Map();
153
+ // 'real' | 'exists' | prefix (temp and gone)
154
+ return (root) => {
155
+ if (cache.has(root)) return cache.get(root);
156
+ const prefix = tempPrefixFor(root, roots);
157
+ const verdict = !prefix ? 'real' : (folderGone(root) ? prefix : 'exists');
158
+ cache.set(root, verdict);
159
+ return verdict;
160
+ };
161
+ }
162
+
163
+ function isJunk(verdict) {
164
+ return verdict !== 'real' && verdict !== 'exists';
165
+ }
166
+
167
+ function fingerprint(row) {
168
+ return crypto.createHash('sha1').update(JSON.stringify(TASK_COLUMNS.split(', ').map(col => row[col]))).digest('hex');
169
+ }
170
+
171
+ // Stream rows where node:sqlite supports it; older builds only have all().
172
+ function eachRow(stmt) {
173
+ return typeof stmt.iterate === 'function' ? stmt.iterate() : stmt.all();
174
+ }
175
+
176
+ function tableExists(db, name) {
177
+ return Boolean(db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name));
178
+ }
179
+
180
+ function dbBytes(dbPath) {
181
+ let total = 0;
182
+ for (const suffix of ['', '-wal']) {
183
+ try { total += fs.statSync(dbPath + suffix).size; } catch { /* missing wal is fine */ }
184
+ }
185
+ return total;
186
+ }
187
+
188
+ function openReadOnly(dbPath) {
189
+ const db = new DatabaseSync(dbPath, { readOnly: true });
190
+ db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
191
+ return db;
192
+ }
193
+
194
+ // Read-only. Works on the live database or on a backup.
195
+ function planPrune(db, { now = Date.now(), graceMs = GRACE_MS, roots = tempRoots() } = {}) {
196
+ const cutoff = now - graceMs;
197
+ const judge = makeJudge(roots);
198
+
199
+ // History per task: row count, newest row, and every folder it names.
200
+ const history = new Map();
201
+ for (const row of eachRow(db.prepare(`
202
+ SELECT task_id, workspace_root, COUNT(*) AS n, MAX(created_at) AS last_at
203
+ FROM task_events GROUP BY task_id, workspace_root
204
+ `))) {
205
+ const entry = history.get(row.task_id) || { n: 0, last_at: 0, roots: [] };
206
+ entry.n += Number(row.n);
207
+ entry.last_at = Math.max(entry.last_at, Number(row.last_at) || 0);
208
+ entry.roots.push(row.workspace_root);
209
+ history.set(row.task_id, entry);
210
+ }
211
+ const allJunk = (entry) => entry.roots.every(root => isJunk(judge(root)));
212
+
213
+ const usesByTask = new Map();
214
+ if (tableExists(db, 'part_uses')) {
215
+ for (const row of db.prepare('SELECT used_by, COUNT(*) AS n FROM part_uses WHERE used_by IS NOT NULL GROUP BY used_by').all()) {
216
+ usesByTask.set(row.used_by, Number(row.n));
217
+ }
218
+ }
219
+
220
+ const groups = new Map();
221
+ const group = (prefix) => {
222
+ if (!groups.has(prefix)) groups.set(prefix, { prefix, tasks: 0, history_rows: 0, stray_history_rows: 0, part_uses: 0 });
223
+ return groups.get(prefix);
224
+ };
225
+ const rows = [];
226
+ const kept = { recent: 0, held: 0, folder_exists: 0, stray_held: 0, stray_recent: 0 };
227
+ let totalTasks = 0;
228
+ for (const row of eachRow(db.prepare(`SELECT ${TASK_COLUMNS} FROM tasks`))) {
229
+ totalTasks += 1;
230
+ const entry = history.get(row.id) || { n: 0, last_at: 0, roots: [] };
231
+ history.delete(row.id);
232
+ const verdict = judge(row.workspace_root);
233
+ if (verdict === 'real') continue;
234
+ if (verdict === 'exists') { kept.folder_exists += 1; continue; }
235
+ if (!allJunk(entry)) { kept.held += 1; continue; }
236
+ if (Math.max(Number(row.updated_at) || 0, entry.last_at) >= cutoff) { kept.recent += 1; continue; }
237
+ const g = group(verdict);
238
+ const uses = usesByTask.get(row.id) || 0;
239
+ g.tasks += 1;
240
+ g.history_rows += entry.n;
241
+ g.part_uses += uses;
242
+ rows.push({ kind: 'task', id: row.id, workspace_root: row.workspace_root, fp: fingerprint(row), events: entry.n, uses });
243
+ }
244
+
245
+ // What is left in history has no task row. Only temp-and-gone history is
246
+ // eligible, and a missing task keeps all of its history if any row names
247
+ // a real project or a folder that still exists.
248
+ for (const [taskId, entry] of history) {
249
+ const verdicts = entry.roots.map(judge);
250
+ if (!verdicts.some(isJunk)) continue;
251
+ if (!verdicts.every(isJunk)) { kept.stray_held += 1; continue; }
252
+ if (entry.last_at >= cutoff) { kept.stray_recent += 1; continue; }
253
+ group(verdicts[0]).stray_history_rows += entry.n;
254
+ rows.push({ kind: 'stray', id: taskId, events: entry.n, uses: 0 });
255
+ }
256
+
257
+ const list = [...groups.values()].sort((a, b) => b.tasks - a.tasks || a.prefix.localeCompare(b.prefix));
258
+ const sum = (field) => list.reduce((acc, g) => acc + g[field], 0);
259
+ return {
260
+ total_tasks: totalTasks,
261
+ grace_hours: graceMs / (60 * 60 * 1000),
262
+ groups: list,
263
+ remove: {
264
+ tasks: sum('tasks'),
265
+ history_rows: sum('history_rows'),
266
+ stray_history_rows: sum('stray_history_rows'),
267
+ part_uses: sum('part_uses'),
268
+ },
269
+ kept,
270
+ rows,
271
+ };
272
+ }
273
+
274
+ function dryRun(dbPath, options = {}) {
275
+ if (!fs.existsSync(dbPath)) return { db_path: dbPath, missing: true };
276
+ const db = openReadOnly(dbPath);
277
+ try {
278
+ const plan = planPrune(db, options);
279
+ return { db_path: dbPath, db_bytes: dbBytes(dbPath), ...plan };
280
+ } finally {
281
+ db.close();
282
+ }
283
+ }
284
+
285
+ function sqlString(value) {
286
+ return `'${String(value).replace(/'/g, "''")}'`;
287
+ }
288
+
289
+ function stamp(now) {
290
+ return new Date(now).toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
291
+ }
292
+
293
+ function countTasks(db) {
294
+ return Number(db.prepare('SELECT COUNT(*) AS n FROM tasks').get().n);
295
+ }
296
+
297
+ function usesTaskIdIndex(db) {
298
+ const plan = db.prepare(`EXPLAIN QUERY PLAN ${EVENT_SQL.summary}`).all('x').map(r => r.detail).join(' ');
299
+ return /USING (COVERING )?INDEX \S+ \(task_id=\?/.test(plan);
300
+ }
301
+
302
+ // Consistent copy while other processes write: VACUUM INTO reads one
303
+ // snapshot. A raw file copy of a live WAL database can tear.
304
+ function writeBackup(db, dbPath, now, roots) {
305
+ let backupPath = `${dbPath}.before-prune-${stamp(now)}`;
306
+ for (let i = 2; fs.existsSync(backupPath); i++) backupPath = `${dbPath}.before-prune-${stamp(now)}-${i}`;
307
+ const countBefore = countTasks(db);
308
+ withBusyRetry(() => db.exec(`VACUUM INTO ${sqlString(backupPath)}`));
309
+ const countAfter = countTasks(db);
310
+ const backup = openReadOnly(backupPath);
311
+ try {
312
+ const check = backup.prepare('PRAGMA quick_check').get();
313
+ const verdict = check && Object.values(check)[0];
314
+ if (verdict !== 'ok') throw new Error(`backup failed its integrity check: ${verdict}`);
315
+ const backupCount = countTasks(backup);
316
+ const low = Math.min(countBefore, countAfter);
317
+ const high = Math.max(countBefore, countAfter);
318
+ if (backupCount < low || backupCount > high) {
319
+ throw new Error(`backup has ${backupCount} tasks, the database had ${countBefore} to ${countAfter}`);
320
+ }
321
+ return { backupPath, backupCount, plan: planPrune(backup, { now, roots }) };
322
+ } finally {
323
+ backup.close();
324
+ }
325
+ }
326
+
327
+ // Group work so each transaction deletes about maxRows rows. A task with more
328
+ // history than that still goes in one transaction of its own.
329
+ function chunkByRows(items, maxRows) {
330
+ const chunks = [];
331
+ let current = [];
332
+ let rows = 0;
333
+ for (const item of items) {
334
+ const cost = item.events + item.uses + (item.kind === 'task' ? 1 : 0);
335
+ if (current.length && rows + cost > maxRows) {
336
+ chunks.push(current);
337
+ current = [];
338
+ rows = 0;
339
+ }
340
+ current.push(item);
341
+ rows += cost;
342
+ }
343
+ if (current.length) chunks.push(current);
344
+ return chunks;
345
+ }
346
+
347
+ function applyPrune(dbPath, options = {}) {
348
+ const {
349
+ now = Date.now(),
350
+ vacuum = false,
351
+ graceMs = GRACE_MS,
352
+ maxRowsPerBatch = MAX_ROWS_PER_BATCH,
353
+ afterBackup = null,
354
+ roots = tempRoots(),
355
+ } = options;
356
+ if (!fs.existsSync(dbPath)) return { db_path: dbPath, missing: true };
357
+ const bytesBefore = dbBytes(dbPath);
358
+ const db = new DatabaseSync(dbPath);
359
+ db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
360
+ try {
361
+ if (!usesTaskIdIndex(db)) withBusyRetry(() => db.exec(TASK_ID_INDEX));
362
+ if (!usesTaskIdIndex(db)) throw new Error('history lookups by task id have no index; nothing was removed');
363
+ const { backupPath, backupCount, plan } = writeBackup(db, dbPath, now, roots);
364
+ if (typeof afterBackup === 'function') afterBackup();
365
+
366
+ const hasUses = tableExists(db, 'part_uses');
367
+ const getTask = db.prepare(`SELECT ${TASK_COLUMNS} FROM tasks WHERE id = ?`);
368
+ const summary = db.prepare(EVENT_SQL.summary);
369
+ const eventRoots = db.prepare(EVENT_SQL.roots);
370
+ const delEvents = db.prepare(EVENT_SQL.remove);
371
+ const delTask = db.prepare('DELETE FROM tasks WHERE id = ?');
372
+ const delUses = hasUses ? db.prepare('DELETE FROM part_uses WHERE used_by = ?') : null;
373
+ // part_uses has no index on used_by, so read a whole batch in one pass.
374
+ const usesFor = hasUses
375
+ ? db.prepare('SELECT used_by, COUNT(*) AS n FROM part_uses WHERE used_by IN (SELECT value FROM json_each(?)) GROUP BY used_by')
376
+ : null;
377
+
378
+ const removed = { tasks: 0, history_rows: 0, stray_history_rows: 0, part_uses: 0 };
379
+ const skipped = { changed_since_backup: 0, held: 0, recent: 0, folder_exists: 0 };
380
+ const chunks = chunkByRows(plan.rows, maxRowsPerBatch);
381
+ let batches = 0;
382
+ for (let c = 0; c < chunks.length; c++) {
383
+ withBusyRetry(() => db.exec('BEGIN IMMEDIATE'));
384
+ try {
385
+ // Fresh answers inside the lock: live clock, live disk, live rows.
386
+ const cutoff = Date.now() - graceMs;
387
+ const judge = makeJudge(roots);
388
+ const liveUses = new Map();
389
+ if (usesFor) {
390
+ const ids = chunks[c].filter(item => item.kind === 'task').map(item => item.id);
391
+ for (const row of usesFor.all(JSON.stringify(ids))) liveUses.set(row.used_by, Number(row.n));
392
+ }
393
+ for (const item of chunks[c]) {
394
+ const live = getTask.get(item.id);
395
+ if (item.kind === 'task' ? (!live || fingerprint(live) !== item.fp) : live) {
396
+ skipped.changed_since_backup += 1;
397
+ continue;
398
+ }
399
+ const ev = summary.get(item.id);
400
+ if (Number(ev.n) !== item.events || (liveUses.get(item.id) || 0) !== item.uses) {
401
+ skipped.changed_since_backup += 1;
402
+ continue;
403
+ }
404
+ const lastAt = Math.max(Number(ev.last_at) || 0, live ? Number(live.updated_at) || 0 : 0);
405
+ if (lastAt >= cutoff) { skipped.recent += 1; continue; }
406
+ if (live && !isJunk(judge(live.workspace_root))) { skipped.folder_exists += 1; continue; }
407
+ if (!eventRoots.all(item.id).every(r => isJunk(judge(r.workspace_root)))) { skipped.held += 1; continue; }
408
+
409
+ const gone = Number(delEvents.run(item.id).changes);
410
+ if (item.kind === 'stray') {
411
+ removed.stray_history_rows += gone;
412
+ continue;
413
+ }
414
+ delTask.run(item.id);
415
+ removed.tasks += 1;
416
+ removed.history_rows += gone;
417
+ if (delUses && item.uses) removed.part_uses += Number(delUses.run(item.id).changes);
418
+ }
419
+ db.exec('COMMIT');
420
+ batches += 1;
421
+ } catch (err) {
422
+ try { db.exec('ROLLBACK'); } catch { /* already rolled back */ }
423
+ throw err;
424
+ }
425
+ if (c + 1 < chunks.length) sleepSync(BATCH_PAUSE_MS);
426
+ }
427
+
428
+ let vacuumed = false;
429
+ if (vacuum) {
430
+ withBusyRetry(() => db.exec('VACUUM'));
431
+ withBusyRetry(() => db.exec('PRAGMA wal_checkpoint(TRUNCATE)'));
432
+ vacuumed = true;
433
+ }
434
+ const { rows, ...planSummary } = plan;
435
+ return {
436
+ db_path: dbPath,
437
+ backup_path: backupPath,
438
+ backup_tasks: backupCount,
439
+ bytes_before: bytesBefore,
440
+ bytes_after: dbBytes(dbPath),
441
+ total_tasks: countTasks(db),
442
+ groups: planSummary.groups,
443
+ planned: planSummary.remove,
444
+ removed,
445
+ skipped,
446
+ kept: planSummary.kept,
447
+ batches,
448
+ vacuumed,
449
+ };
450
+ } finally {
451
+ db.close();
452
+ }
453
+ }
454
+
455
+ module.exports = {
456
+ EVENT_SQL,
457
+ tempRoots,
458
+ tempPrefixFor,
459
+ folderGone,
460
+ dryRun,
461
+ applyPrune,
462
+ };