atris 3.68.1 → 3.68.3

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 (41) hide show
  1. package/atris/skills/engines/SKILL.md +1 -1
  2. package/atris/team/customer-lead/MEMBER.md +1 -1
  3. package/atris/team/executor/MEMBER.md +19 -18
  4. package/atris/team/executor/skills/isolated-slice-builder/SKILL.md +21 -0
  5. package/atris/team/navigator/MEMBER.md +3 -1
  6. package/atris/team/navigator/skills/loophole-proof-planner/SKILL.md +20 -0
  7. package/atris/team/researcher/MEMBER.md +2 -1
  8. package/atris/team/researcher/skills/grounded-evidence-brief/SKILL.md +19 -0
  9. package/atris/team/validator/MEMBER.md +2 -7
  10. package/atris/team/validator/skills/cross-model-referee/SKILL.md +20 -0
  11. package/bin/atris.js +40 -1
  12. package/commands/autoland.js +2 -1
  13. package/commands/done.js +374 -0
  14. package/commands/engine.js +2 -2
  15. package/commands/goal.js +61 -2
  16. package/commands/land.js +34 -7
  17. package/commands/member.js +1542 -41
  18. package/commands/mission.js +506 -18
  19. package/commands/task.js +26 -3
  20. package/commands/workflow.js +1 -1
  21. package/commands/worktree.js +144 -34
  22. package/lib/auto-accept-certified.js +7 -6
  23. package/lib/codex-flight.js +6 -12
  24. package/lib/conductor-artifacts.js +3 -1
  25. package/lib/default-runner.js +42 -7
  26. package/lib/engine-ask.js +8 -1
  27. package/lib/engine-registry.js +17 -0
  28. package/lib/falsifier-probe.js +6 -0
  29. package/lib/first-minute.js +257 -6
  30. package/lib/fleet.js +163 -88
  31. package/lib/known-commands.js +1 -1
  32. package/lib/member-alive.js +1 -1
  33. package/lib/member-approval.js +153 -0
  34. package/lib/mission-task-gate.js +170 -0
  35. package/lib/one-lap-validator.js +2 -1
  36. package/lib/roster-runs.js +28 -0
  37. package/lib/runner-command.js +32 -8
  38. package/lib/task-db.js +25 -1
  39. package/package.json +9 -2
  40. package/scripts/det/ytnotes +49 -10
  41. package/utils/backend-root.js +4 -0
@@ -0,0 +1,374 @@
1
+ 'use strict';
2
+
3
+ // `atris done`: the one command a person runs after their coding agent built
4
+ // the thing. It names what was built, lands it through the real task path
5
+ // (`task ready --verify` then `task accept`, same gates agents face), and ends
6
+ // with a short credits roll and the file to open.
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const { spawnSync } = require('child_process');
11
+ const { argsWantHelp } = require('../lib/noninteractive');
12
+ const {
13
+ buildFirstMinute,
14
+ firstTalkBuildCommand,
15
+ firstTalkBuilder,
16
+ folderName,
17
+ humanStarterTask,
18
+ isFreshWorkspace,
19
+ listBuiltSince,
20
+ loadLocalTasks,
21
+ mainBuiltFile,
22
+ orderBuilt,
23
+ personName,
24
+ speakFirstMinute,
25
+ spokenBuiltList,
26
+ taskStartedAt,
27
+ } = require('../lib/first-minute');
28
+
29
+ const ATRIS_BIN = path.join(__dirname, '..', 'bin', 'atris.js');
30
+ const DONE_SCHEMA = 'atris.one_lap.v1';
31
+
32
+ // What a page has, in plain words, for the first tag found.
33
+ const HTML_MARKS = [
34
+ ['<form', 'the form'],
35
+ ['<h1', 'the heading'],
36
+ ['<title', 'the page title'],
37
+ ['<main', 'the main section'],
38
+ ['<body', 'the page body'],
39
+ ['<html', 'the page'],
40
+ ];
41
+
42
+ function doneUsage() {
43
+ return 'Usage: atris done [<task>] [--json]';
44
+ }
45
+
46
+ function shQuote(value) {
47
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
48
+ }
49
+
50
+ function spokenTitle(task) {
51
+ return String((task && task.title) || 'this').replace(/\s+/g, ' ').trim().replace(/[.,;:!?]+$/g, '');
52
+ }
53
+
54
+ function readHead(root, file) {
55
+ try {
56
+ const buf = fs.readFileSync(path.join(root, file));
57
+ return buf.subarray(0, 65536);
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ // A check that fails when the built file is gone or no longer holds what was
64
+ // built, so it clears the weak-verifier and empty-folder gates honestly.
65
+ function verifierFor(root, files) {
66
+ const ordered = orderBuilt(files);
67
+ for (const file of ordered) {
68
+ const head = readHead(root, file);
69
+ if (!head || !head.length || head.includes(0)) continue;
70
+ const text = head.toString('utf8');
71
+ if (/\.html?$/i.test(file)) {
72
+ const lower = text.toLowerCase();
73
+ const mark = HTML_MARKS.find(([tag]) => lower.includes(tag));
74
+ if (mark) {
75
+ return {
76
+ command: `grep -qi ${shQuote(mark[0])} ${shQuote(file)}`,
77
+ plain: `${file} has ${mark[1]}`,
78
+ file,
79
+ };
80
+ }
81
+ }
82
+ const word = (text.match(/[A-Za-z][A-Za-z0-9_-]{3,}/) || [])[0];
83
+ if (word) {
84
+ return {
85
+ command: `grep -qF ${shQuote(word)} ${shQuote(file)}`,
86
+ plain: `${file} says "${word}"`,
87
+ file,
88
+ };
89
+ }
90
+ }
91
+ const file = ordered.find((name) => {
92
+ const head = readHead(root, name);
93
+ return head && head.length;
94
+ });
95
+ if (!file) return null;
96
+ return {
97
+ command: `grep -qa . ${shQuote(file)}`,
98
+ plain: `${file} is there and not empty`,
99
+ file,
100
+ };
101
+ }
102
+
103
+ // One sentence the ready gate accepts: what the person can do now and why.
104
+ function resultSentence(task) {
105
+ const { explainResult } = require('../lib/autoland');
106
+ const title = spokenTitle(task).replace(/[.!?]/g, '').slice(0, 100).trim();
107
+ const candidates = [
108
+ `You can now open ${title} and use it yourself, so it is real instead of only a plan.`,
109
+ 'You can now open what you asked for here and use it yourself, so it is real instead of only a plan.',
110
+ ];
111
+ return candidates.find((line) => explainResult(line, { allowCommandMention: true }).ok) || candidates[1];
112
+ }
113
+
114
+ function openCommand(file) {
115
+ const target = /^[\w./-]+$/.test(file) ? file : shQuote(file);
116
+ if (process.platform === 'darwin') return `open ${target}`;
117
+ if (process.platform === 'win32') return `start ${target}`;
118
+ return `xdg-open ${target}`;
119
+ }
120
+
121
+ function buildCommandFor(task, builder) {
122
+ const pick = builder !== undefined ? builder : firstTalkBuilder();
123
+ // Test runners skip the machine probe; name the default builder so the line stays runnable.
124
+ return firstTalkBuildCommand(pick === undefined ? 'claude' : pick, spokenTitle(task));
125
+ }
126
+
127
+ function taskRefOf(task) {
128
+ return String((task && (task.display_id || task.legacy_ref || task.id)) || '').trim();
129
+ }
130
+
131
+ function findTask(tasks, ref) {
132
+ const want = String(ref || '').trim().toLowerCase();
133
+ if (!want) return null;
134
+ return tasks.find((task) => [task.display_id, task.legacy_ref, task.id]
135
+ .some((value) => value && String(value).toLowerCase() === want))
136
+ || tasks.find((task) => task.id && String(task.id).toLowerCase().startsWith(want))
137
+ || null;
138
+ }
139
+
140
+ function pickTask(tasks, person) {
141
+ const starter = humanStarterTask(tasks, person);
142
+ if (starter) return starter;
143
+ const who = String(person || '').trim();
144
+ const mine = tasks
145
+ .filter((task) => (task.status === 'claimed' || task.status === 'review')
146
+ && (!who || String(task.claimed_by || '') === who))
147
+ .sort((a, b) => Number(b.updated_at || 0) - Number(a.updated_at || 0));
148
+ return mine[0] || null;
149
+ }
150
+
151
+ function runAtris(root, args) {
152
+ const result = spawnSync(process.execPath, [ATRIS_BIN, ...args], {
153
+ cwd: root,
154
+ env: process.env,
155
+ encoding: 'utf8',
156
+ timeout: 240000,
157
+ });
158
+ let json = null;
159
+ try {
160
+ json = JSON.parse(String(result.stdout || '').trim());
161
+ } catch {
162
+ json = null;
163
+ }
164
+ return {
165
+ ok: result.status === 0,
166
+ status: result.status,
167
+ stdout: String(result.stdout || ''),
168
+ stderr: String(result.stderr || ''),
169
+ json,
170
+ };
171
+ }
172
+
173
+ function failureLine(step) {
174
+ const text = `${step.stderr}\n${step.stdout}`;
175
+ if (step.json && (step.json.detail || step.json.reason)) {
176
+ return String(step.json.detail || step.json.reason).split('\n')[0];
177
+ }
178
+ const line = text.split('\n').map((s) => s.trim()).find(Boolean);
179
+ return line || `exit ${step.status}`;
180
+ }
181
+
182
+ function liveTask(root, id) {
183
+ try {
184
+ const taskDb = require('../lib/task-db');
185
+ const db = taskDb.open();
186
+ return taskDb.getTask(db, id);
187
+ } catch {
188
+ return null;
189
+ }
190
+ }
191
+
192
+ function receiptFromProof(proof) {
193
+ const match = String(proof || '').match(/Receipt:\s*(\S+\.json)/);
194
+ return match ? match[1] : '';
195
+ }
196
+
197
+ // The receipt is the record. Say on it that the person who owned this work
198
+ // accepted it themselves, through `atris done`.
199
+ function stampOwnerAccept(root, receiptPath, { owner, actor }) {
200
+ if (!receiptPath) return false;
201
+ const file = path.resolve(root, receiptPath);
202
+ try {
203
+ const receipt = JSON.parse(fs.readFileSync(file, 'utf8'));
204
+ receipt.accepted = {
205
+ by: actor || owner || null,
206
+ owner: owner || null,
207
+ self_accepted: true,
208
+ via: 'atris done',
209
+ note: 'the human owner accepted their own work',
210
+ at: new Date().toISOString(),
211
+ };
212
+ fs.writeFileSync(file, `${JSON.stringify(receipt, null, 2)}\n`, 'utf8');
213
+ return true;
214
+ } catch {
215
+ return false;
216
+ }
217
+ }
218
+
219
+ function renderCredits({ title, built, checked, receipt, next }) {
220
+ return [
221
+ '',
222
+ ` ${title}`,
223
+ '',
224
+ ` built ${spokenBuiltList(built)}`,
225
+ ` checked ${checked}, passed`,
226
+ ` signed ${receipt || 'the task record'}`,
227
+ '',
228
+ `next: ${next}`,
229
+ ].join('\n');
230
+ }
231
+
232
+ function emit(asJson, log, payload, text) {
233
+ if (asJson) log(JSON.stringify(payload, null, 2));
234
+ else log(text);
235
+ }
236
+
237
+ function doneAtris(args = process.argv.slice(3), {
238
+ root = process.cwd(),
239
+ log = console.log,
240
+ builder,
241
+ } = {}) {
242
+ const list = Array.isArray(args) ? args : [];
243
+ if (argsWantHelp(list)) {
244
+ log(doneUsage());
245
+ return 0;
246
+ }
247
+ const asJson = list.includes('--json');
248
+ const ref = list.find((arg) => !String(arg).startsWith('-')) || '';
249
+
250
+ if (isFreshWorkspace(root)) {
251
+ return speakFirstMinute({ root, fresh: true, asJson, log });
252
+ }
253
+
254
+ const person = personName();
255
+ const tasks = loadLocalTasks(root);
256
+ const task = ref ? findTask(tasks, ref) : pickTask(tasks, person);
257
+
258
+ if (!task) {
259
+ const minute = buildFirstMinute({ root, builder });
260
+ const reason = ref ? `nothing here is called ${ref}` : 'nothing of yours is in progress here';
261
+ // Already shipped: say so, the same way bare atris does.
262
+ const text = !ref && minute.shipped ? `\n${minute.text}` : `\n${reason}.\n\nnext: ${minute.nextCommand}`;
263
+ emit(asJson, log, {
264
+ schema: DONE_SCHEMA,
265
+ ok: false,
266
+ status: 'stuck',
267
+ reason,
268
+ next_action: minute.nextCommand,
269
+ }, text);
270
+ return asJson ? 2 : 0;
271
+ }
272
+
273
+ const title = spokenTitle(task);
274
+ if (task.status === 'done') {
275
+ emit(asJson, log, {
276
+ schema: DONE_SCHEMA,
277
+ ok: true,
278
+ status: 'already_done',
279
+ reason: `${title} is already done`,
280
+ next_action: buildFirstMinute({ root, builder }).nextCommand,
281
+ }, `\n${title} is already done.\n\nnext: ${buildFirstMinute({ root, builder }).nextCommand}`);
282
+ return 0;
283
+ }
284
+ if (!['open', 'claimed', 'review'].includes(task.status)) {
285
+ emit(asJson, log, {
286
+ schema: DONE_SCHEMA,
287
+ ok: false,
288
+ status: 'stuck',
289
+ reason: `${title} is ${task.status}`,
290
+ next_action: `atris task show ${taskRefOf(task)}`,
291
+ }, `\n${title} is ${task.status}.\n\nnext: atris task show ${taskRefOf(task)}`);
292
+ return asJson ? 2 : 0;
293
+ }
294
+
295
+ const built = orderBuilt(listBuiltSince(root, taskStartedAt(task)));
296
+ if (!built.length) {
297
+ const next = buildCommandFor(task, builder);
298
+ emit(asJson, log, {
299
+ schema: DONE_SCHEMA,
300
+ ok: true,
301
+ status: 'nothing_new',
302
+ reason: `nothing new here since you started ${title}`,
303
+ task: { display_id: task.display_id || null, title: task.title },
304
+ built: [],
305
+ next_action: next,
306
+ }, `\nnothing new here since you started ${title}.\n\nnext: ${next}`);
307
+ return 0;
308
+ }
309
+
310
+ const verify = verifierFor(root, built);
311
+ const fail = (reason, next) => {
312
+ emit(asJson, log, {
313
+ schema: DONE_SCHEMA,
314
+ ok: false,
315
+ status: 'stuck',
316
+ reason,
317
+ task: { display_id: task.display_id || null, title: task.title },
318
+ built,
319
+ next_action: next,
320
+ }, `\n${title} has new work, but ${reason}.\n\nnext: ${next}`);
321
+ return 1;
322
+ };
323
+ if (!verify) return fail('nothing built has content to check yet', buildCommandFor(task, builder));
324
+
325
+ let current = liveTask(root, task.id) || task;
326
+ if (current.status !== 'review') {
327
+ const ready = runAtris(root, [
328
+ 'task', 'ready', task.id,
329
+ '--verify', verify.command,
330
+ '--result', resultSentence(task),
331
+ '--json',
332
+ ]);
333
+ if (!ready.ok) {
334
+ return fail(`the check did not pass (${failureLine(ready)})`, `atris task show ${taskRefOf(task)}`);
335
+ }
336
+ current = liveTask(root, task.id) || current;
337
+ }
338
+ const receipt = receiptFromProof(current && current.metadata && current.metadata.latest_agent_proof);
339
+
340
+ const accept = runAtris(root, ['task', 'accept', task.id, '--json']);
341
+ if (!accept.ok) {
342
+ const why = failureLine(accept);
343
+ const waiting = /agent_proof_only|cannot accept/i.test(`${why} ${accept.stderr} ${accept.stdout}`);
344
+ return fail(
345
+ waiting ? 'it is checked and waiting on a person to accept it' : `accept did not go through (${why})`,
346
+ `atris task accept ${taskRefOf(task)}`,
347
+ );
348
+ }
349
+ const actor = (accept.json && accept.json.episode && (accept.json.episode.actor || accept.json.episode.reviewer)) || '';
350
+ const stamped = stampOwnerAccept(root, receipt, { owner: task.claimed_by || person, actor });
351
+
352
+ const main = mainBuiltFile(built);
353
+ const next = openCommand(main);
354
+ emit(asJson, log, {
355
+ schema: DONE_SCHEMA,
356
+ ok: true,
357
+ status: 'done',
358
+ reason: `${title} is done`,
359
+ task: { display_id: task.display_id || null, title: task.title },
360
+ built,
361
+ checked: { command: verify.command, plain: verify.plain, passed: true },
362
+ receipt: receipt || null,
363
+ owner_accepted: stamped,
364
+ next_action: next,
365
+ }, renderCredits({ title, built, checked: verify.plain, receipt, next }));
366
+ return 0;
367
+ }
368
+
369
+ module.exports = {
370
+ doneAtris,
371
+ resultSentence,
372
+ verifierFor,
373
+ openCommand,
374
+ };
@@ -38,7 +38,7 @@ const { teamRosterView } = require('../lib/member-engine');
38
38
  const { engineModelText, engineRunsView, availableModels, modelLabel } = require('../lib/roster-models');
39
39
  const { renderTable } = require('../lib/text-table');
40
40
  const { TEAM_FOOTER } = require('../lib/team-lineup');
41
- const { readRosterRuns, workerRuns, summarizeRuns, recentRuns, renderRunLine, DEFAULT_DAYS: RUN_DAYS } = require('../lib/roster-runs');
41
+ const { readRosterRuns, workerRuns, summarizeRuns, recentRuns, renderRunLine, runView, DEFAULT_DAYS: RUN_DAYS } = require('../lib/roster-runs');
42
42
  const { attachSuggestions } = require('../lib/roster-suggest');
43
43
  const { PIN_MAX_AGE_DAYS: PIN_STALE_DAYS, attachStaleModels, modelCatalog } = require('../lib/roster-stale');
44
44
  const {
@@ -1507,7 +1507,7 @@ const RUNS_LIST_LIMIT = 20;
1507
1507
  function runRosterRunsCommand(job, json, root, now) {
1508
1508
  const runs = recentRuns(readRosterRuns(root, { now, days: RUNS_LIST_DAYS }), { job, limit: RUNS_LIST_LIMIT });
1509
1509
  if (json) {
1510
- console.log(JSON.stringify({ job: job || null, days: RUNS_LIST_DAYS, runs }, null, 2));
1510
+ console.log(JSON.stringify({ job: job || null, days: RUNS_LIST_DAYS, runs: runs.map(runView) }, null, 2));
1511
1511
  return 0;
1512
1512
  }
1513
1513
  if (!runs.length) {
package/commands/goal.js CHANGED
@@ -168,9 +168,65 @@ function renderGoalView(view) {
168
168
  return lines.join('\n');
169
169
  }
170
170
 
171
+ // A goal that never becomes a mission run is a wish. `goal set` therefore also
172
+ // files the sentence through the normal wish capture path (capture only: no
173
+ // mission, no driver), so the goal -> wish -> mission -> run chain starts here.
174
+ // Wish capture is best-effort: it must never fail or undo the goal itself.
175
+ function wishNextStep(wish, goal) {
176
+ return wish.status === 'needs_input'
177
+ ? 'atris wish answer "your words"'
178
+ : `atris wish again ${wish.id} "${goal}"`;
179
+ }
180
+
181
+ function captureGoalWish(sentence, root) {
182
+ const goal = String(sentence || '').replace(/\s+/g, ' ').trim();
183
+ const { isFreshWorkspace } = require('../lib/first-minute');
184
+ if (isFreshWorkspace(root)) {
185
+ console.error('note: wish not filed, no atris/ workspace here. run: atris init');
186
+ return null;
187
+ }
188
+ const previous = process.cwd();
189
+ let wish = null;
190
+ try {
191
+ if (previous !== root) process.chdir(root);
192
+ // Re-setting the same goal must not stack duplicate wishes.
193
+ const { readWishes } = require('../lib/wish-store');
194
+ const open = readWishes(root).find((w) => (
195
+ normalizeText(w.text) === normalizeText(goal)
196
+ && ['captured', 'captured_no_mission', 'needs_input'].includes(w.status)
197
+ ));
198
+ if (open) {
199
+ console.log(`wish already filed: ${open.id}`);
200
+ console.log(`next ${wishNextStep(open, goal)}`);
201
+ return open;
202
+ }
203
+ const { runCapturedWish } = require('../lib/wish-delegate');
204
+ runCapturedWish(goal, root, {
205
+ noMission: true,
206
+ afterCapture: (captured) => { wish = captured; },
207
+ });
208
+ } catch (error) {
209
+ console.error(`warning: goal set, but wish capture failed: ${normalizeText(error?.message || error)}`);
210
+ return null;
211
+ } finally {
212
+ if (process.cwd() !== previous) process.chdir(previous);
213
+ }
214
+ if (!wish) return null;
215
+ let latest = wish;
216
+ try {
217
+ const { readWishes } = require('../lib/wish-store');
218
+ latest = readWishes(root).find((w) => w.id === wish.id) || wish;
219
+ } catch {
220
+ latest = wish;
221
+ }
222
+ console.log(`wish id ${wish.id}`);
223
+ console.log(`next ${wishNextStep(latest, goal)}`);
224
+ return latest;
225
+ }
226
+
171
227
  function printHelp() {
172
228
  console.log('usage: atris goal [--json]');
173
- console.log(' atris goal set "<sentence>"');
229
+ console.log(' atris goal set "<sentence>" [--no-wish]');
174
230
  console.log(' atris goal metric <name> <value>');
175
231
  console.log(' atris goal metric <name> --rm');
176
232
  }
@@ -187,7 +243,9 @@ function run(args = [], context = {}) {
187
243
  }
188
244
 
189
245
  if (command === 'set') {
190
- const goal = normalizeText(args.slice(1).join(' '));
246
+ const noWish = args.includes('--no-wish');
247
+ const sentence = args.slice(1).filter((arg) => arg !== '--no-wish').join(' ');
248
+ const goal = normalizeText(sentence);
191
249
  if (!goal) {
192
250
  console.error('error: goal sentence is required');
193
251
  return 2;
@@ -195,6 +253,7 @@ function run(args = [], context = {}) {
195
253
  const scoreboard = readScoreboard(root);
196
254
  writeScoreboard({ ...scoreboard, goal }, root);
197
255
  console.log('goal set.');
256
+ if (!noWish) captureGoalWish(sentence, root);
198
257
  return 0;
199
258
  }
200
259
 
package/commands/land.js CHANGED
@@ -2,7 +2,7 @@ const fs = require('fs');
2
2
  const path = require('path');
3
3
  const gitSpawn = require('../lib/git-spawn');
4
4
  const { compactErrorPayload, printCliJson } = require('../lib/cli-json');
5
- const { listWorktrees, statusCounts } = require('./worktree');
5
+ const { listWorktrees, statusCounts, defaultMainlineBase, listActiveProcessCwds, worktreeHasActiveProcess, recordWorktreeRemoval } = require('./worktree');
6
6
 
7
7
  const DEFAULT_TTL_DAYS = 7;
8
8
  const DEFAULT_STALE_HOURS = 48;
@@ -38,10 +38,11 @@ function refExists(root, ref) {
38
38
 
39
39
  function baseBranch(root, override = '') {
40
40
  if (override && refExists(root, override)) return override;
41
- if (refExists(root, 'origin/master')) return 'origin/master';
42
- if (refExists(root, 'origin/main')) return 'origin/main';
43
- if (refExists(root, 'master')) return 'master';
44
- if (refExists(root, 'main')) return 'main';
41
+ const mainline = defaultMainlineBase(root);
42
+ if (mainline !== 'HEAD') return mainline;
43
+ for (const candidate of ['main', 'master']) {
44
+ if (refExists(root, candidate)) return candidate;
45
+ }
45
46
  return '';
46
47
  }
47
48
 
@@ -309,7 +310,7 @@ function collectBoard(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_S
309
310
  const cherryCache = loadCherryCache(root);
310
311
  const baseSha = resolveSha(root, base);
311
312
  for (const branch of listBranches(root, base)) {
312
- if (PROTECTED_BRANCHES.has(branch.name)) continue;
313
+ if (PROTECTED_BRANCHES.has(branch.name) || branch.name === base.replace(/^origin\//, '')) continue;
313
314
  const lastCommitMs = Number(branch.lastCommitUnix) * 1000;
314
315
  if (Number.isFinite(lastCommitMs)) lastCommitMsByBranch.set(branch.name, lastCommitMs);
315
316
  const ahead = branch.ahead !== undefined ? branch.ahead : aheadCount(root, base, branch.name);
@@ -527,6 +528,15 @@ function salvageWorktree(w, dir, receipt) {
527
528
  }
528
529
  }
529
530
 
531
+ function reapReason(w, board, ttlDays, force) {
532
+ const state = (board.branches.find((b) => b.name === w.branch) || {}).state;
533
+ if (state === 'landed') return 'branch_landed';
534
+ if (state === 'due') return 'branch_past_ttl';
535
+ if (w.state === 'detached') return 'detached_head';
536
+ if (typeof w.ageDays === 'number' && w.ageDays > ttlDays) return 'worktree_past_ttl';
537
+ return force ? 'forced' : 'unlanded_commits';
538
+ }
539
+
530
540
  // Reap: salvage then delete everything landed (residue) or past TTL.
531
541
  // Salvage first, always: unlanded commits go into a git bundle, dirty
532
542
  // worktrees into patch files + untracked-file copies, so a reap is never
@@ -534,7 +544,9 @@ function salvageWorktree(w, dir, receipt) {
534
544
  // includeDetached: detached-HEAD worktrees have no branch, so the salvage
535
545
  // bundle cannot cover their commits, unattended reaps (autoland) pass false
536
546
  // and leave them for a human reap.
537
- function reap(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_STALE_HOURS, base: baseOverride = '', dryRun = false, remote = true, includeDetached = true, protectCurrent = true, force = false, now = Date.now() } = {}) {
547
+ function reap(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_STALE_HOURS, base: baseOverride = '', dryRun = false, remote = true, includeDetached = true, protectCurrent = true, force = false, now = Date.now(), activeCwds: activeCwdsOverride, by = 'atris land --reap' } = {}) {
548
+ const readActiveCwds = () => (Array.isArray(activeCwdsOverride) ? activeCwdsOverride : listActiveProcessCwds());
549
+ let activeCwds = null;
538
550
  const board = collectBoard(root, { ttlDays, staleHours, base: baseOverride, now });
539
551
  const targets = board.branches.filter((b) => b.state === 'landed' || b.state === 'due');
540
552
  const targetNames = new Set(targets.map((b) => b.name));
@@ -554,6 +566,13 @@ function reap(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_STALE_HOU
554
566
  protectedWorktrees.push({ ...w, reason: 'current_checkout' });
555
567
  continue;
556
568
  }
569
+ // A run working inside the folder outranks --force: removing it pulls
570
+ // the floor out from under a live engine.
571
+ if (!activeCwds) activeCwds = readActiveCwds();
572
+ if (worktreeHasActiveProcess(w.path, activeCwds)) {
573
+ protectedWorktrees.push({ ...w, reason: 'active_process' });
574
+ continue;
575
+ }
557
576
  if (worktreeWithinReapGrace(w.path, now)) {
558
577
  protectedWorktrees.push({ ...w, reason: 'fresh_worktree_grace' });
559
578
  continue;
@@ -642,7 +661,14 @@ function reap(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_STALE_HOU
642
661
  const survivingWorktreeTargets = worktreeTargets.filter(
643
662
  (w) => targetNames.has(w.branch) || (includeDetached && w.state === 'detached')
644
663
  );
664
+ const activeAtRemoval = survivingWorktreeTargets.length ? readActiveCwds() : [];
645
665
  for (const w of survivingWorktreeTargets) {
666
+ // A run can start in the folder while the bundle is built; look again.
667
+ if (worktreeHasActiveProcess(w.path, activeAtRemoval)) {
668
+ receipt.keptWorktrees.push(`${w.path} (active_process)`);
669
+ if (w.branch) targetNames.delete(w.branch);
670
+ continue;
671
+ }
646
672
  // The grace check ran at scan time, and a bundle build can sit between it
647
673
  // and removal — an engine that booted mid-sweep refreshes the directory
648
674
  // mtime in that gap. Stat once more at removal time, the cheapest place
@@ -665,6 +691,7 @@ function reap(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_STALE_HOU
665
691
  const removed = runGit(['worktree', 'remove', '--force', w.path], { cwd: root, check: false });
666
692
  if (removed.status === 0) {
667
693
  receipt.removedWorktrees.push(w.path);
694
+ recordWorktreeRemoval(root, { path: w.path, branch: w.branch, by, reason: reapReason(w, board, ttlDays, force) });
668
695
  } else {
669
696
  // A locked worktree fails here and, unreported, reap re-salvages the
670
697
  // same patches every pass forever. Say what blocked it so a human can