atris 3.70.0 → 3.71.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 (76) hide show
  1. package/README.md +18 -0
  2. package/atris/skills/README.md +1 -0
  3. package/atris/skills/campaigns/SKILL.md +81 -0
  4. package/atris/skills/meta/SKILL.md +52 -8
  5. package/ax +226 -42
  6. package/bin/atris.js +26 -6
  7. package/commands/autoland.js +176 -7
  8. package/commands/autopilot.js +71 -11
  9. package/commands/campaign.js +759 -0
  10. package/commands/compile.js +19 -8
  11. package/commands/doctor.js +58 -0
  12. package/commands/dream.js +18 -3
  13. package/commands/engine.js +147 -5
  14. package/commands/improve.js +119 -9
  15. package/commands/init.js +18 -1
  16. package/commands/interview.js +15 -3
  17. package/commands/land.js +22 -6
  18. package/commands/link.js +1 -0
  19. package/commands/member.js +359 -94
  20. package/commands/mission.js +249 -36
  21. package/commands/report.js +42 -8
  22. package/commands/run.js +9 -2
  23. package/commands/scout.js +9 -1
  24. package/commands/task.js +71 -17
  25. package/commands/team.js +212 -4
  26. package/commands/unknowns.js +15 -1
  27. package/commands/verify.js +46 -0
  28. package/commands/worktree.js +108 -15
  29. package/commands/write.js +12 -1
  30. package/commands/youtube.js +17 -2
  31. package/lib/accept-verify-gate.js +1 -0
  32. package/lib/auto-accept-certified.js +1 -0
  33. package/lib/autoland.js +13 -4
  34. package/lib/campaign-cadence.js +131 -0
  35. package/lib/campaign-core.js +330 -0
  36. package/lib/campaign-holdout.js +65 -0
  37. package/lib/campaign-loop.js +242 -0
  38. package/lib/campaign-missions.js +237 -0
  39. package/lib/campaign-new.js +251 -0
  40. package/lib/campaign-protect.js +75 -0
  41. package/lib/campaign-reading.js +147 -0
  42. package/lib/campaign-schema.js +196 -0
  43. package/lib/campaign-store.js +380 -0
  44. package/lib/campaign-sync.js +302 -0
  45. package/lib/campaign-trace.js +300 -0
  46. package/lib/conductor-artifacts.js +15 -1
  47. package/lib/daily-log.js +11 -1
  48. package/lib/dispatch-scout.js +18 -0
  49. package/lib/engine-ask.js +33 -3
  50. package/lib/engine-registry.js +2 -0
  51. package/lib/engine-resource.js +518 -0
  52. package/lib/fleet.js +125 -16
  53. package/lib/init-commit.js +80 -0
  54. package/lib/intents.js +8 -0
  55. package/lib/known-commands.js +1 -1
  56. package/lib/land-green.js +58 -7
  57. package/lib/landing-proof.js +352 -0
  58. package/lib/member-autoland.js +395 -18
  59. package/lib/member-board.js +7 -1
  60. package/lib/member-map-fold.js +126 -0
  61. package/lib/member-map-inbox.js +80 -0
  62. package/lib/member-progress.js +48 -1
  63. package/lib/member-report.js +127 -8
  64. package/lib/member-self-next-job.js +97 -0
  65. package/lib/member-task-pick.js +41 -14
  66. package/lib/member-up.js +249 -16
  67. package/lib/roster-prep.js +25 -2
  68. package/lib/runner-command.js +5 -0
  69. package/lib/task-db.js +47 -11
  70. package/lib/task-explanation.js +7 -0
  71. package/lib/task-list-keeper.js +8 -1
  72. package/lib/task-receipt.js +6 -1
  73. package/mcp/atris-mcp/index.mjs +212 -10
  74. package/package.json +2 -1
  75. package/scripts/det/engine-reading.js +37 -0
  76. package/scripts/det/ytnotes +29 -5
@@ -468,14 +468,25 @@ function executeBuild(root, name, options = {}) {
468
468
  const cmd = cmdOverride || buildRunnerCommand({ promptFile: tmpFile, allowedTools: 'Read,Write,Edit,Glob,Grep' });
469
469
  const env = { ...process.env };
470
470
  delete env.CLAUDECODE;
471
- execSync(cmd, {
472
- cwd: root,
473
- encoding: 'utf8',
474
- timeout,
475
- stdio: verbose ? 'inherit' : 'pipe',
476
- maxBuffer: 10 * 1024 * 1024,
477
- env,
478
- });
471
+ // Machine memory and load at both ends of the build, so one killed with
472
+ // SIGKILL says whether the machine was short on memory.
473
+ const endResource = require('../lib/engine-resource').startEngineRun(root, { engine: 'runner', task: `compile ${name}` });
474
+ try {
475
+ execSync(cmd, {
476
+ cwd: root,
477
+ encoding: 'utf8',
478
+ timeout,
479
+ stdio: verbose ? 'inherit' : 'pipe',
480
+ maxBuffer: 10 * 1024 * 1024,
481
+ env,
482
+ });
483
+ } catch (err) {
484
+ // A kill leaves no report of its own, so the error names the likely cause.
485
+ const end = endResource(err);
486
+ if (end && end.cause && err instanceof Error) err.message = `${err.message}; ${end.cause}`;
487
+ throw err;
488
+ }
489
+ endResource();
479
490
  } finally {
480
491
  try { fs.unlinkSync(tmpFile); } catch {}
481
492
  }
@@ -88,6 +88,48 @@ function installWarnings(packageRoot) {
88
88
  return warnings;
89
89
  }
90
90
 
91
+ // Outside coding tools a member can run on. The built-in tool (ax) ships
92
+ // with atris and only needs a sign-in, so it is counted through auth.
93
+ const MEMBER_TOOL_BINS = ['claude', 'codex', 'cursor-agent', 'opencode', 'devin', 'grok', 'agy'];
94
+
95
+ function teamMembers(workspaceRoot) {
96
+ const teamDir = path.join(workspaceRoot, 'atris', 'team');
97
+ let entries = [];
98
+ try { entries = fs.readdirSync(teamDir, { withFileTypes: true }); } catch { return []; }
99
+ return entries
100
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith('_'))
101
+ .filter((entry) => fs.existsSync(path.join(teamDir, entry.name, 'MEMBER.md')))
102
+ .map((entry) => entry.name)
103
+ .sort();
104
+ }
105
+
106
+ // What a member run needs before it can start: someone on the team, git for
107
+ // its own copy of the work, and a coding tool to think with. One fix line
108
+ // names the first missing piece.
109
+ function memberReadiness(workspaceRoot, auth, probe) {
110
+ const members = teamMembers(workspaceRoot);
111
+ const git_installed = probe('git');
112
+ const git_repo = fs.existsSync(path.join(workspaceRoot, '.git'));
113
+ const tools = MEMBER_TOOL_BINS.filter((bin) => probe(bin));
114
+ const built_in_tool = Boolean(auth && auth.signed_in);
115
+ const has_tool = tools.length > 0 || built_in_tool;
116
+ let next = null;
117
+ if (!members.length) next = 'run atris init to add a team to this folder.';
118
+ else if (!git_installed) next = 'install git so members can work on their own copy.';
119
+ else if (!git_repo) next = 'run git init here so members can work on their own copy.';
120
+ else if (!has_tool) next = 'install claude code or codex, or run atris login to use the built-in tool.';
121
+ return {
122
+ ready: next === null,
123
+ members: members.length,
124
+ member_names: members,
125
+ git_installed,
126
+ git_repo,
127
+ tools,
128
+ built_in_tool,
129
+ next,
130
+ };
131
+ }
132
+
91
133
  function collectDoctor(options = {}) {
92
134
  const packageRoot = options.packageRoot || path.join(__dirname, '..');
93
135
  const node = parseNodeVersion(options.nodeVersion || process.version);
@@ -100,6 +142,8 @@ function collectDoctor(options = {}) {
100
142
  const version_line = formatCliVersionLine(version, packageRoot);
101
143
  const install_git = inspectInstallGitState(packageRoot);
102
144
  const warnings = installWarnings(packageRoot);
145
+ const probe = options.binInstalled || require('../lib/engine-registry').binInstalled;
146
+ const members = memberReadiness(workspace_root, auth, probe);
103
147
  return {
104
148
  ok: true,
105
149
  action: 'doctor',
@@ -125,6 +169,7 @@ function collectDoctor(options = {}) {
125
169
  branch: install_git.branch || null,
126
170
  head: install_git.head || null,
127
171
  },
172
+ members,
128
173
  warnings,
129
174
  };
130
175
  }
@@ -140,6 +185,16 @@ function renderDoctor(payload) {
140
185
  lines.push(`workspace: ${payload.workspace_root}`);
141
186
  lines.push(`cli runnable: ${payload.cli_runnable ? 'yes' : 'no'}`);
142
187
  lines.push(`public bin: ${payload.bin.public_bin} (${payload.bin.shim ? `${payload.bin.shim_bytes} byte shim` : 'not a shim'})`);
188
+ const m = payload.members;
189
+ if (m) {
190
+ const toolNames = m.tools.concat(m.built_in_tool ? ['built-in'] : []);
191
+ lines.push('');
192
+ lines.push(`members: ${m.ready ? 'ready' : 'not ready yet'}`);
193
+ lines.push(` team: ${m.members ? `${m.members} member${m.members === 1 ? '' : 's'}` : 'none'}`);
194
+ lines.push(` git: ${!m.git_installed ? 'not installed' : (m.git_repo ? 'yes' : 'installed, but this folder is not a git repo')}`);
195
+ lines.push(` coding tool: ${toolNames.length ? toolNames.join(', ') : 'none'}`);
196
+ if (m.next) lines.push(` next: ${m.next}`);
197
+ }
143
198
  if (payload.warnings && payload.warnings.length) {
144
199
  lines.push('');
145
200
  for (const warning of payload.warnings) lines.push(`warning: ${warning}`);
@@ -156,6 +211,7 @@ function doctorCommand(args = [], options = {}) {
156
211
  console.log('Usage: atris doctor [--json]');
157
212
  console.log('');
158
213
  console.log('Reports node version, task-support (22+), auth, workspace root, and whether the CLI can run.');
214
+ console.log('Also checks what members need: a team, git, and a coding tool.');
159
215
  console.log('Public install entry is bin/atris (tiny POSIX shim) -> bin/atris.js.');
160
216
  return 0;
161
217
  }
@@ -171,4 +227,6 @@ function doctorCommand(args = [], options = {}) {
171
227
 
172
228
  module.exports = {
173
229
  doctorCommand,
230
+ collectDoctor,
231
+ renderDoctor,
174
232
  };
package/commands/dream.js CHANGED
@@ -234,6 +234,20 @@ function dreamModels() {
234
234
  function defaultDreamRunner({ prompt, model, cwd, timeoutMs = DREAM_TIMEOUT_MS }) {
235
235
  return new Promise((resolve) => {
236
236
  const bin = (RUNNER_PROFILE_DEFS.haiku && RUNNER_PROFILE_DEFS.haiku.bin) || 'claude';
237
+ // Machine memory and load at both ends of the launch, so one killed with
238
+ // SIGKILL says whether the machine was short on memory.
239
+ const endResource = require('../lib/engine-resource').startEngineRun(cwd, { engine: path.basename(bin), task: 'dream' });
240
+ // The child does not throw, so a failure is passed in the thrown shape.
241
+ const finish = (result, signal = null) => {
242
+ const failed = !result.ok;
243
+ const end = endResource(failed ? {
244
+ code: timedOut ? 'ETIMEDOUT' : '',
245
+ signal,
246
+ stderr: result.stderr,
247
+ message: result.error || `model exited ${result.exitCode}`,
248
+ } : null);
249
+ resolve({ ...result, ...(signal ? { signal } : {}), ...(end && end.cause && !timedOut ? { cause: end.cause } : {}) });
250
+ };
237
251
  const proc = spawn(bin, ['-p', prompt, '--model', model], {
238
252
  cwd,
239
253
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -247,13 +261,13 @@ function defaultDreamRunner({ prompt, model, cwd, timeoutMs = DREAM_TIMEOUT_MS }
247
261
  }, timeoutMs);
248
262
  proc.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
249
263
  proc.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
250
- proc.on('close', (code) => {
264
+ proc.on('close', (code, signal) => {
251
265
  clearTimeout(timer);
252
- resolve({ ok: code === 0 && !timedOut, stdout, stderr, exitCode: code, timedOut });
266
+ finish({ ok: code === 0 && !timedOut, stdout, stderr, exitCode: code, timedOut }, signal);
253
267
  });
254
268
  proc.on('error', (error) => {
255
269
  clearTimeout(timer);
256
- resolve({ ok: false, error: error.message, stdout, stderr, timedOut });
270
+ finish({ ok: false, error: error.message, stdout, stderr, timedOut });
257
271
  });
258
272
  });
259
273
  }
@@ -262,6 +276,7 @@ function attemptFailureReason(result) {
262
276
  if (result && result.garbled) return 'could not read dream cards';
263
277
  if (result && result.timedOut) return 'model timed out';
264
278
  if (result && result.error) return 'model could not start';
279
+ if (result && result.signal) return `model stopped by ${result.signal}${result.cause ? `; ${result.cause}` : ''}`;
265
280
  if (result && result.exitCode) return 'model stopped early';
266
281
  return 'model did not answer';
267
282
  }
@@ -1891,7 +1891,7 @@ const PROBE_PROMPT = 'Reply with exactly the two characters: OK';
1891
1891
  // answer on 2026-07-02. 30s produced a false FAIL on a healthy engine.
1892
1892
  const PROBE_DEFAULT_TIMEOUT_MS = 120000;
1893
1893
 
1894
- function probeEngine(name, { timeout = PROBE_DEFAULT_TIMEOUT_MS } = {}) {
1894
+ function probeEngine(name, { timeout = PROBE_DEFAULT_TIMEOUT_MS, root = process.cwd() } = {}) {
1895
1895
  const def = RUNNER_PROFILE_DEFS[name];
1896
1896
  if (!binInstalled(def.bin)) {
1897
1897
  return {
@@ -1917,11 +1917,15 @@ function probeEngine(name, { timeout = PROBE_DEFAULT_TIMEOUT_MS } = {}) {
1917
1917
  if (prevProfile === undefined) delete process.env.ATRIS_RUNNER_PROFILE;
1918
1918
  else process.env.ATRIS_RUNNER_PROFILE = prevProfile;
1919
1919
  }
1920
+ // Machine memory and load at both ends of the probe, so one killed with
1921
+ // SIGKILL says whether the machine was short on memory.
1922
+ const endResource = require('../lib/engine-resource').startEngineRun(root, { engine: name, task: 'engine probe' });
1920
1923
  const start = Date.now();
1921
1924
  let res;
1922
1925
  try {
1923
1926
  res = spawnSync('sh', ['-c', cmd], { encoding: 'utf8', timeout });
1924
1927
  } catch (err) {
1928
+ endResource(err);
1925
1929
  fs.rmSync(tmpDir, { recursive: true, force: true });
1926
1930
  return {
1927
1931
  engine: name,
@@ -1935,6 +1939,15 @@ function probeEngine(name, { timeout = PROBE_DEFAULT_TIMEOUT_MS } = {}) {
1935
1939
  };
1936
1940
  }
1937
1941
  const durationMs = Date.now() - start;
1942
+ // spawnSync does not throw, so a failure is passed in the thrown shape.
1943
+ const launchFailed = res.error || res.signal || res.status !== 0;
1944
+ const end = endResource(launchFailed ? {
1945
+ code: res.error && res.error.code,
1946
+ signal: res.signal,
1947
+ stderr: res.stderr,
1948
+ message: res.error ? res.error.message : `exit ${res.status}`,
1949
+ } : null);
1950
+ const cause = end && end.cause ? `; ${end.cause}` : '';
1938
1951
  fs.rmSync(tmpDir, { recursive: true, force: true });
1939
1952
  const stdout = res.stdout || '';
1940
1953
  const stderr = res.stderr || '';
@@ -1954,7 +1967,7 @@ function probeEngine(name, { timeout = PROBE_DEFAULT_TIMEOUT_MS } = {}) {
1954
1967
  reason,
1955
1968
  message: ok
1956
1969
  ? 'responded OK'
1957
- : (timedOut ? `no reply within ${timeout}ms` : `did not reply OK (exit ${res.status}, signal ${res.signal})`),
1970
+ : (timedOut ? `no reply within ${timeout}ms` : `did not reply OK (exit ${res.status}, signal ${res.signal})${cause}`),
1958
1971
  stdout,
1959
1972
  stderr,
1960
1973
  durationMs,
@@ -2032,7 +2045,7 @@ function runBenchCommand(args, root, deps = {}) {
2032
2045
  }
2033
2046
  }
2034
2047
 
2035
- const probeOpts = {};
2048
+ const probeOpts = { root };
2036
2049
  if (timeout) probeOpts.timeout = timeout;
2037
2050
 
2038
2051
  const results = [];
@@ -2154,7 +2167,7 @@ function runEngineTest(targets, { json, root } = {}) {
2154
2167
  return 1;
2155
2168
  }
2156
2169
  }
2157
- const results = enginesToTest.map((name) => probeEngine(name));
2170
+ const results = enginesToTest.map((name) => probeEngine(name, { root }));
2158
2171
  const failures = results.filter((r) => !r.pass);
2159
2172
  const passed = results.length - failures.length;
2160
2173
  // A live probe is the freshest health evidence there is: write it back so
@@ -2314,6 +2327,132 @@ function shorthandModelError(engine, args, root) {
2314
2327
  }
2315
2328
  }
2316
2329
 
2330
+ function formatAgentAge(seconds) {
2331
+ const minutes = Math.floor(seconds / 60);
2332
+ const hours = Math.floor(minutes / 60);
2333
+ const days = Math.floor(hours / 24);
2334
+ if (days) return `${days}d ${hours % 24}h`;
2335
+ if (hours) return `${hours}h ${minutes % 60}m`;
2336
+ return `${minutes}m`;
2337
+ }
2338
+
2339
+ // atris engine sweep: list ssh-agents that engine shells started and left
2340
+ // behind. With --apply it rechecks each one and stops it with SIGTERM, and
2341
+ // writes a parent gone end line for each run whose parent process is gone.
2342
+ function runEngineSweepCommand(args, root, deps = {}) {
2343
+ const { readSshAgents, findLeakedSshAgents, findAgentStartLines, agentStartLineText, stopLeakedSshAgents, closeLostRuns, readResourceHistory, leakGrowthNote, LEAK_MIN_AGE_SEC } = require('../lib/engine-resource');
2344
+ const read = deps.read || readSshAgents;
2345
+ const { agents, referenced, unread = [] } = read();
2346
+ const uid = deps.uid !== undefined ? deps.uid : process.getuid();
2347
+ const leaked = findLeakedSshAgents(agents, { referenced, uid });
2348
+ const apply = args.includes('--apply');
2349
+ const result = apply
2350
+ ? stopLeakedSshAgents(root, leaked, { read, uid, ...(deps.kill ? { kill: deps.kill } : {}), ...(deps.now ? { now: deps.now } : {}) })
2351
+ : { stopped: [], failed: [], skipped: [] };
2352
+ result.unread = [...new Set([...unread, ...(result.unread || [])])];
2353
+ const scanWarning = result.unread.length
2354
+ ? `could not read the ${result.unread.join(' or the ')} for every process, so some ssh-agents could not be checked; the leak count may be incomplete`
2355
+ : null;
2356
+ result.closed_runs = [];
2357
+ if (apply) {
2358
+ // Agents may already be stopped here, so a failed close is reported
2359
+ // beside them rather than thrown away with the whole report.
2360
+ try {
2361
+ result.closed_runs = closeLostRuns(root, deps.now ? { now: deps.now } : {});
2362
+ } catch (err) {
2363
+ result.close_error = String((err && err.message) || err).slice(0, 200);
2364
+ }
2365
+ }
2366
+ // A test run reads only the home it was handed, never the real one.
2367
+ const startLines = leaked.length && (deps.home || !process.env.NODE_TEST_CONTEXT)
2368
+ ? findAgentStartLines(deps.home ? { home: deps.home } : {})
2369
+ : [];
2370
+ const code = result.failed.length || result.close_error ? 1 : 0;
2371
+ if (args.includes('--json')) {
2372
+ console.log(JSON.stringify({ running: agents.length, leaked, unread, ...result, agent_start_lines: startLines, history: readResourceHistory(root) }, null, 2));
2373
+ return code;
2374
+ }
2375
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
2376
+ if (apply) {
2377
+ const lines = [`stopped ${plural(result.stopped.length, 'leaked ssh-agent')}`];
2378
+ const replaced = (result.replaced || []).length;
2379
+ const otherSkipped = result.skipped.length - replaced;
2380
+ if (replaced) lines.push(`${replaced} skipped because ${replaced === 1 ? 'a younger ssh-agent now has' : 'younger ssh-agents now have'} the same process number`);
2381
+ if (otherSkipped) lines.push(`${otherSkipped} skipped because they were in use or gone on the recheck`);
2382
+ for (const { pid, code: reason } of result.failed) lines.push(`could not stop pid ${pid}: ${reason}`);
2383
+ if (result.close_error) lines.push(`could not close lost runs, so unfinished records may remain: ${result.close_error}`);
2384
+ else lines.push(`closed ${plural(result.closed_runs.length, 'run')} whose parent is gone`);
2385
+ if (scanWarning) lines.push(scanWarning);
2386
+ console.log(lines.join('\n'));
2387
+ return code;
2388
+ }
2389
+ const shown = 20;
2390
+ const history = readResourceHistory(root);
2391
+ const lines = [
2392
+ `${plural(agents.length, 'ssh-agent')} running, ${leaked.length} ${leaked.length === 1 ? 'looks' : 'look'} leaked${leakGrowthNote(history.first_day_leak_reading, leaked.length)}`
2393
+ + ` (no parent, older than ${LEAK_MIN_AGE_SEC / 60} min, not in use by any live shell)`,
2394
+ ...leaked.slice(0, shown).map((agent) => ` pid ${String(agent.pid).padEnd(6)} ${formatAgentAge(agent.age_sec)}`),
2395
+ ];
2396
+ if (leaked.length > shown) lines.push(` and ${leaked.length - shown} more`);
2397
+ lines.push(...startLines.map(agentStartLineText));
2398
+ if (scanWarning) lines.push(scanWarning);
2399
+ lines.push(leaked.length ? 'nothing was stopped. run atris engine sweep --apply to stop them.' : 'nothing was stopped.');
2400
+ lines.push('', ...resourceHistoryLines(history));
2401
+ console.log(lines.join('\n'));
2402
+ return 0;
2403
+ }
2404
+
2405
+ function utcMinute(at) {
2406
+ return `${String(at || '').slice(0, 16).replace('T', ' ')} UTC`;
2407
+ }
2408
+
2409
+ // What the resource log says about past runs: the newest sweep, runs with no
2410
+ // end, the newest run that failed without being killed and why, the newest
2411
+ // SIGKILL and its likely cause, and how many SIGKILLs came with low memory.
2412
+ function resourceHistoryLines(history) {
2413
+ const { errorReason } = require('../lib/engine-resource');
2414
+ const lines = [];
2415
+ if (history.read_error) {
2416
+ lines.push(`could not read the engine run log: ${history.read_error}, so past sweeps, kills, and errors are unknown`);
2417
+ return lines;
2418
+ }
2419
+ const sweep = history.last_sweep;
2420
+ lines.push(sweep
2421
+ ? `last sweep: ${utcMinute(sweep.at)}, stopped ${(sweep.stopped || []).length}`
2422
+ : 'no sweep recorded yet');
2423
+ const started = history.last_open;
2424
+ if (started) {
2425
+ const unrecorded = history.open_runs - history.parent_gone - history.still_running;
2426
+ const parts = [
2427
+ history.parent_gone && `${history.parent_gone} whose parent is gone`,
2428
+ history.still_running && `${history.still_running} still running`,
2429
+ unrecorded && `${unrecorded} from before parents were recorded`,
2430
+ ].filter(Boolean);
2431
+ lines.push(`${history.open_runs} run${history.open_runs === 1 ? '' : 's'} started with no end recorded: ${parts.join(', ')};`
2432
+ + ` newest: ${started.engine || 'engine'} on ${started.task || 'an unnamed task'} at ${utcMinute(started.at)}`);
2433
+ }
2434
+ const failed = history.last_error;
2435
+ if (failed) {
2436
+ lines.push(`last engine error: ${failed.engine || 'engine'} on ${failed.task || 'an unnamed task'} at ${utcMinute(failed.at)}: ${errorReason(failed)}`);
2437
+ }
2438
+ const kill = history.last_kill;
2439
+ if (!kill) {
2440
+ lines.push('no engine run has ended in SIGKILL');
2441
+ return lines;
2442
+ }
2443
+ const killLine = (entry) => {
2444
+ const free = Number.isFinite(entry.free_pct) ? `, ${entry.free_pct}% memory free` : '';
2445
+ return `${entry.engine || 'engine'} on ${entry.task || 'an unnamed task'} at ${utcMinute(entry.at)}, ${entry.cause}${free}`;
2446
+ };
2447
+ lines.push(`last SIGKILL: ${killLine(kill)}`);
2448
+ // Timeouts can pile up after a crash; still name the newest kill that was not one.
2449
+ const other = history.last_other_kill;
2450
+ if (other && other !== kill) lines.push(`last SIGKILL not at a time limit: ${killLine(other)}`);
2451
+ const timeLimit = history.time_limit_kills ? `, ${history.time_limit_kills} at their time limit` : '';
2452
+ lines.push(`${history.low_memory_kills} of ${history.kills} SIGKILL${history.kills === 1 ? '' : 's'} ended with machine memory low${timeLimit}`);
2453
+ return lines;
2454
+ }
2455
+
2317
2456
  function engineCommand(args = [], deps = {}) {
2318
2457
  const root = deps.root || process.cwd();
2319
2458
  const dispatch = deps.engineDispatch || runDispatchCommand;
@@ -2323,6 +2462,9 @@ function engineCommand(args = [], deps = {}) {
2323
2462
  if ((args[0] || '').trim() === 'watch') {
2324
2463
  return runEngineWatchCommand(args.slice(1), root, deps.engineWatch || {});
2325
2464
  }
2465
+ if ((args[0] || '').trim() === 'sweep') {
2466
+ return runEngineSweepCommand(args.slice(1), root, deps.engineSweep || {});
2467
+ }
2326
2468
  if ((args[0] || '').trim() === 'validate') {
2327
2469
  return runEngineValidateCommand(args.slice(1), root, deps.engineValidate || {});
2328
2470
  }
@@ -2406,7 +2548,7 @@ function engineCommand(args = [], deps = {}) {
2406
2548
  if (sub === 'help' || args.includes('--help') || args.includes('-h')) {
2407
2549
  console.log('\n atris engine watch [<id>|latest] [--no-follow]\n follow one live transcript or list running engine work');
2408
2550
  console.log('\n long read-only asks: atris engine ask "..." --engine agy --timeout <seconds>\n quick asks default to 120 seconds; explicit jobs allow up to 3600\n follow live work with atris engine watch latest');
2409
- console.log('\n atris engine roster + current default\n atris engine roster [--json] show every job and its workers in order: search, build, review, and your own\n flags a model with a newer version on this machine, or a pin not proven in 30 days\n (a landed run, or "verified 2026-09-30" on its line, proves it)\n atris engine roster confirm renew every dated worker for 30 days\n atris engine roster session [clear]\n show or drop the roster changes for this shell only\n atris engine roster --available\n tools on this machine and the models each one offers\n atris engine roster --runs [job]\n the last 20 runs, newest first, with tool, model, time, and outcome\n atris engine assign <job> <tool> [--like <kind>] [--model <m>] [--effort <level>] [--max "20 min"] [--prep <job>] [--backup "<tool> [model]"] [--days <n>] [--add] [--session | --everywhere]\n atris engine assign <job> --promote "<tool> [model]" | --remove <tool> | --clear [--session | --everywhere]\n sets the lead worker of the job; --promote moves one of its workers to the top as written;\n --add puts one more at the end; jobs: search, build, review,\n or your own like "small build" (--like search|build|review when the name does not say);\n --prep search has that job read the task first and hand this worker a short brief;\n --everywhere is for all projects; --session is this shell only (set ATRIS_ROSTER_SESSION=<name>)\n atris engines --chart show the fleet as an org chart\n atris engine list --json [--all] full registry: default + engines with tier, roles, fallback, health (--all includes hidden engines)\n atris engine set <name> --duty leader|errands|learning [--models "a, b"]\n arrange the fleet and save its model policy\n atris engine resolve <role> or <job> [--json]\n choose the best ready engine for navigator|executor|validator or a roster job;\n notes always answers: its roster pick, else agy then haiku\n atris engine health <name> --set ready|not_installed|credit_out\n flip runtime health, for example when credits run out\n atris engine doctor [--json]\n probe which engine CLIs are installed here and sync that into health policy\n atris engine <name> make that engine the default here\n atris engine test [name] preflight: run the engine CLI headless, report pass/fail\n atris engine bench [names...] [--runs N]\n ranked latency scoreboard of engine passes\n atris engine ask "<question>" --engine <name> [--engine <name> ...]\n ask several engines in parallel without allowing edits\n atris engine ask --jobs <jobs.json>\n ask different read-only questions in parallel\n atris engine validate <receipt-path|latest> [--engine <name>]\n check ask answers with a different read-only referee\n atris engine validate scoreboard\n show pass rates by worker engine\n atris engine dispatch <task-id> [<task-id> ...] --engine cursor|codex [--prompt-file <f>] [--yolo]\n one-command claim, worktree, build, verify, ship, ready\n atris engine login <provider> --yes\n upload a local provider CLI login to the backend vault\n atris engine login <provider> --computer [--seat <name>]\n atris engine login <provider> --business <id> [--seat <name>]\n sign in on an Atris computer by device flow\n atris engine login --list | --remove <provider>\n list or remove vaulted provider logins\n atris engine seats show which named accounts are ready to work\n atris engine seed <provider> --business <id>|--user\n push a vaulted login onto an Atris computer\n atris engine reset back to the house default\n --engine <name> one run on that engine (mission run / autopilot / run)\n');
2551
+ console.log('\n atris engine roster + current default\n atris engine roster [--json] show every job and its workers in order: search, build, review, and your own\n flags a model with a newer version on this machine, or a pin not proven in 30 days\n (a landed run, or "verified 2026-09-30" on its line, proves it)\n atris engine roster confirm renew every dated worker for 30 days\n atris engine roster session [clear]\n show or drop the roster changes for this shell only\n atris engine roster --available\n tools on this machine and the models each one offers\n atris engine roster --runs [job]\n the last 20 runs, newest first, with tool, model, time, and outcome\n atris engine assign <job> <tool> [--like <kind>] [--model <m>] [--effort <level>] [--max "20 min"] [--prep <job>] [--backup "<tool> [model]"] [--days <n>] [--add] [--session | --everywhere]\n atris engine assign <job> --promote "<tool> [model]" | --remove <tool> | --clear [--session | --everywhere]\n sets the lead worker of the job; --promote moves one of its workers to the top as written;\n --add puts one more at the end; jobs: search, build, review,\n or your own like "small build" (--like search|build|review when the name does not say);\n --prep search has that job read the task first and hand this worker a short brief;\n --everywhere is for all projects; --session is this shell only (set ATRIS_ROSTER_SESSION=<name>)\n atris engines --chart show the fleet as an org chart\n atris engine list --json [--all] full registry: default + engines with tier, roles, fallback, health (--all includes hidden engines)\n atris engine set <name> --duty leader|errands|learning [--models "a, b"]\n arrange the fleet and save its model policy\n atris engine resolve <role> or <job> [--json]\n choose the best ready engine for navigator|executor|validator or a roster job;\n notes always answers: its roster pick, else agy then haiku\n atris engine health <name> --set ready|not_installed|credit_out\n flip runtime health, for example when credits run out\n atris engine doctor [--json]\n probe which engine CLIs are installed here and sync that into health policy\n atris engine <name> make that engine the default here\n atris engine test [name] preflight: run the engine CLI headless, report pass/fail\n atris engine bench [names...] [--runs N]\n ranked latency scoreboard of engine passes\n atris engine ask "<question>" --engine <name> [--engine <name> ...]\n ask several engines in parallel without allowing edits\n atris engine ask --jobs <jobs.json>\n ask different read-only questions in parallel\n atris engine validate <receipt-path|latest> [--engine <name>]\n check ask answers with a different read-only referee\n atris engine validate scoreboard\n show pass rates by worker engine\n atris engine sweep [--apply] [--json]\n list ssh-agents that engine shells left running; --apply rechecks and stops them\n atris engine dispatch <task-id> [<task-id> ...] --engine cursor|codex [--prompt-file <f>] [--yolo]\n one-command claim, worktree, build, verify, ship, ready\n atris engine login <provider> --yes\n upload a local provider CLI login to the backend vault\n atris engine login <provider> --computer [--seat <name>]\n atris engine login <provider> --business <id> [--seat <name>]\n sign in on an Atris computer by device flow\n atris engine login --list | --remove <provider>\n list or remove vaulted provider logins\n atris engine seats show which named accounts are ready to work\n atris engine seed <provider> --business <id>|--user\n push a vaulted login onto an Atris computer\n atris engine reset back to the house default\n --engine <name> one run on that engine (mission run / autopilot / run)\n');
2410
2552
  return 0;
2411
2553
  }
2412
2554
  if (isFreshWorkspace(root)) {
@@ -535,13 +535,17 @@ function collectImproveVitals(options = {}, deps = {}) {
535
535
  const collect = deps.collectRevisionSignals || collectRevisionSignals;
536
536
  const rev = collect(root, { days: 14, now: nowMs });
537
537
  const landingsPhrase = `${countWord(rev.landings)} landing${rev.landings === 1 ? '' : 's'} this fortnight`;
538
- const fixPhrase = `${countWord(rev.revised)} needed a human fix`;
538
+ const fixed = Number.isFinite(rev.fixed) ? rev.fixed : rev.revised;
539
+ const fixPhrase = `${countWord(fixed)} needed a human fix`;
540
+ const coachPhrase = rev.coached ? `, ${countWord(rev.coached)} got your coaching` : '';
539
541
  guarantee = {
540
542
  days: rev.days,
541
543
  landings: rev.landings,
542
544
  revised: rev.revised,
545
+ fixed,
546
+ coached: rev.coached || 0,
543
547
  rate: rev.rate,
544
- sentence: plainSentence(`${landingsPhrase}, ${fixPhrase}.`),
548
+ sentence: plainSentence(`${landingsPhrase}, ${fixPhrase}${coachPhrase}.`),
545
549
  };
546
550
  } catch {
547
551
  guarantee = null;
@@ -1025,7 +1029,7 @@ function formatImproveReport(result = {}) {
1025
1029
  // those trailer lines. commits with no trailer still count as human, so the
1026
1030
  // metric overcounts revisions; accepted on purpose.
1027
1031
  //
1028
- // if a human commit touches any of the same files within 72 hours, that
1032
+ // if a human commit changes lines the landing wrote within 72 hours, that
1029
1033
  // landing failed the guarantee.
1030
1034
  //
1031
1035
  // renames are NOT followed: `git log --follow` is per-file and would cost one
@@ -1038,8 +1042,13 @@ const REVISION_WINDOW_HOURS = 72;
1038
1042
  // matter of routine (daily logs, now.md, run receipts), so overlap there says
1039
1043
  // nothing about the quality of a landing. On 2026-08-21 half the "revisions"
1040
1044
  // in a live reading were journal commits following journal commits. Only
1041
- // product-file overlap counts.
1045
+ // product-file overlap counts. The same holds for shared index files: on
1046
+ // 2026-10-15 one human commit read as 14 of 17 revisions because it moved a
1047
+ // few map-note lines that nearly every landing also adds to.
1042
1048
  const REVISION_IGNORED_FILES = [
1049
+ /^atris\/MAP\.md$/,
1050
+ /^atris\/refs\/MAP-NOTES\.md$/,
1051
+ /^test\/fast-tests\.txt$/,
1043
1052
  /^atris\/logs\//,
1044
1053
  /^atris\/runs\//,
1045
1054
  /^atris\/team\/[^/]+\/(?:logs|now\.md)/,
@@ -1052,6 +1061,23 @@ const REVISION_IGNORED_FILES = [
1052
1061
  function isRevisionSignalFile(file) {
1053
1062
  return !REVISION_IGNORED_FILES.some((re) => re.test(String(file || '')));
1054
1063
  }
1064
+
1065
+ // A person editing a member's role, goals, context, or the team roster after a
1066
+ // landing is coaching the team, not fixing broken work. Live 2026-10-15: most
1067
+ // of 51 "revisions" in atris-desktop-v2 were goals.json and MEMBER.md edits,
1068
+ // so the boot line told the operator they fixed work they had only steered.
1069
+ const COACHING_FILES = [
1070
+ /^atris\/team\/[^/]+\/(?:MEMBER\.md|MISSION\.md|WAKE\.md|lessons\.md|goals\.(?:json|md))$/,
1071
+ /^atris\/team\/[^/]+\/(?:context|skills)\//,
1072
+ /^atris\/team\/ROSTER\.(?:json|md)$/,
1073
+ /^atris\/ROSTER\.md$/,
1074
+ /^atris\/(?:PERSONA|STANDARD)\.md$/,
1075
+ /^atris\/policies\//,
1076
+ ];
1077
+
1078
+ function isCoachingFile(file) {
1079
+ return COACHING_FILES.some((re) => re.test(String(file || '')));
1080
+ }
1055
1081
  const REVISION_WINDOW_MS = REVISION_WINDOW_HOURS * 60 * 60 * 1000;
1056
1082
  const AGENT_TRAILER_MARKERS = [
1057
1083
  'atris-builder[bot]',
@@ -1213,7 +1239,7 @@ function loadRevisionFilesCache(root) {
1213
1239
 
1214
1240
  function saveRevisionFilesCache(cache) {
1215
1241
  if (!cache.writable || !cache.dirty) return;
1216
- const entries = { ...cache.used };
1242
+ const entries = Object.fromEntries(Object.entries(cache.used).filter(([, files]) => Array.isArray(files)));
1217
1243
  let count = Object.keys(entries).length;
1218
1244
  for (const [hash, files] of Object.entries(cache.entries)) {
1219
1245
  if (count >= REVISION_FILES_CACHE_MAX) break;
@@ -1224,9 +1250,61 @@ function saveRevisionFilesCache(cache) {
1224
1250
  ignoredCache.writeCacheJson(cache.root, REVISION_FILES_CACHE, { version: 1, shallow: cache.shallow, entries });
1225
1251
  }
1226
1252
 
1253
+ /**
1254
+ * Which mainline commit brought each commit onto the line HEAD follows: a
1255
+ * mainline commit brings itself, a branch commit arrives with the oldest
1256
+ * merge that reaches it. Built from the history already read, so no extra git
1257
+ * calls. Commits outside the window stay unknown.
1258
+ */
1259
+ function mainlineArrivals(commits) {
1260
+ const byHash = new Map(commits.map((commit) => [commit.hash, commit]));
1261
+ const mainline = [];
1262
+ for (let c = commits[0]; c && !mainline.includes(c); c = byHash.get(c.parents[0])) mainline.push(c);
1263
+ const arrival = new Map(mainline.map((m) => [m.hash, m.hash]));
1264
+ for (const m of mainline.slice().reverse()) {
1265
+ const stack = m.parents.slice(1);
1266
+ while (stack.length) {
1267
+ const hash = stack.pop();
1268
+ if (arrival.has(hash) || !byHash.has(hash)) continue;
1269
+ arrival.set(hash, m.hash);
1270
+ stack.push(...byHash.get(hash).parents);
1271
+ }
1272
+ }
1273
+ return arrival;
1274
+ }
1275
+
1276
+ /**
1277
+ * Commits that wrote the lines a plain commit changed in one file, read with
1278
+ * blame on its parent. A pure insertion counts the line it went in under, so
1279
+ * adding a missing line to someone's block still touches that block. null
1280
+ * when git cannot say (binary, new file, blame error); the caller then falls
1281
+ * back to same-file overlap.
1282
+ */
1283
+ function linesAuthoredBefore(cwdRoot, commit, file) {
1284
+ const parent = commit.parents[0];
1285
+ if (!parent) return null;
1286
+ try {
1287
+ const diff = gitLines(cwdRoot, [...NAME_ARGS, 'diff', '-U0', '--no-color', '--no-ext-diff', '--no-textconv', '--no-renames', parent, commit.hash, '--', file]);
1288
+ const ranges = [];
1289
+ for (const m of diff.matchAll(/^@@ -(\d+)(?:,(\d+))? /gm)) {
1290
+ const start = Number(m[1]);
1291
+ const count = m[2] === undefined ? 1 : Number(m[2]);
1292
+ if (count > 0) ranges.push('-L', `${start},${start + count - 1}`);
1293
+ else ranges.push('-L', `${Math.max(start, 1)},${Math.max(start, 1)}`);
1294
+ }
1295
+ if (!ranges.length) return null;
1296
+ const blame = gitLines(cwdRoot, [...NAME_ARGS, 'blame', '--porcelain', ...ranges, parent, '--', file]);
1297
+ const authors = new Set();
1298
+ for (const m of blame.matchAll(/^([0-9a-f]{40,64}) \d+ \d+/gm)) authors.add(m[1]);
1299
+ return [...authors];
1300
+ } catch {
1301
+ return null;
1302
+ }
1303
+ }
1304
+
1227
1305
  /**
1228
1306
  * Read the last N days of history and pair every agent landing with the
1229
- * human commits that touched the same files within the 72-hour window.
1307
+ * human commits that changed its lines within the 72-hour window.
1230
1308
  */
1231
1309
  function collectRevisionSignals(root, options = {}) {
1232
1310
  const days = Number.isFinite(options.days) && options.days > 0 ? Math.round(options.days) : DEFAULT_REVISIONS_DAYS;
@@ -1288,25 +1366,48 @@ function collectRevisionSignals(root, options = {}) {
1288
1366
  }
1289
1367
  return cache.used[commit.hash];
1290
1368
  };
1369
+ // Saved beside the file lists under a key no commit hash can take.
1370
+ const authorsOf = (commit, file) => {
1371
+ const key = `lines:${commit.hash}:${file}`;
1372
+ if (!(key in cache.used)) {
1373
+ const hit = cache.entries[key];
1374
+ cache.used[key] = Array.isArray(hit) ? hit : linesAuthoredBefore(root, commit, file);
1375
+ if (!Array.isArray(hit) && cache.used[key]) cache.dirty = true;
1376
+ }
1377
+ return cache.used[key];
1378
+ };
1291
1379
 
1380
+ // A commit that reached the mainline in the same merge as the landing came
1381
+ // in with it, before anything had landed for a person to fix.
1382
+ const arrival = mainlineArrivals(commits);
1292
1383
  const revisions = [];
1293
1384
  for (const landing of landings) {
1294
1385
  const landedFiles = new Set(filesOf(landing).filter(isRevisionSignalFile));
1295
1386
  if (!landedFiles.size) continue;
1387
+ const landedWith = arrival.get(landing.hash);
1296
1388
  const revisedBy = [];
1297
1389
  const touched = new Set();
1298
1390
  for (const human of humans) {
1299
1391
  if (human.ms <= landing.ms || human.ms > landing.ms + REVISION_WINDOW_MS) continue;
1300
- const overlap = filesOf(human).filter((f) => landedFiles.has(f));
1392
+ if (landedWith && arrival.get(human.hash) === landedWith) continue;
1393
+ // Same file is not enough: the commit has to change lines that came in
1394
+ // with the landing. Live 2026-10-15: a follow-up to one change read as
1395
+ // a fix of four older landings that only shared its files.
1396
+ const overlap = filesOf(human).filter((f) => landedFiles.has(f)).filter((f) => {
1397
+ const authors = authorsOf(human, f);
1398
+ return !authors || authors.some((h) => h === landing.hash || (landedWith && arrival.get(h) === landedWith));
1399
+ });
1301
1400
  if (!overlap.length) continue;
1302
1401
  revisedBy.push({ hash: human.hash, subject: human.subject, at: human.at });
1303
1402
  overlap.forEach((f) => touched.add(f));
1304
1403
  }
1305
1404
  if (revisedBy.length) {
1405
+ const files = [...touched].sort();
1306
1406
  revisions.push({
1307
1407
  landing: { hash: landing.hash, subject: landing.subject, at: landing.at },
1308
1408
  revised_by: revisedBy,
1309
- files: [...touched].sort(),
1409
+ files,
1410
+ kind: files.every(isCoachingFile) ? 'coaching' : 'fix',
1310
1411
  });
1311
1412
  }
1312
1413
  }
@@ -1320,6 +1421,9 @@ function collectRevisionSignals(root, options = {}) {
1320
1421
  landings: landings.length,
1321
1422
  revised: revisions.length,
1322
1423
  rate: landings.length ? revisions.length / landings.length : 0,
1424
+ fixed: revisions.filter((r) => r.kind === 'fix').length,
1425
+ coached: revisions.filter((r) => r.kind === 'coaching').length,
1426
+ fix_rate: landings.length ? revisions.filter((r) => r.kind === 'fix').length / landings.length : 0,
1323
1427
  revisions,
1324
1428
  };
1325
1429
  }
@@ -1339,9 +1443,15 @@ function formatRevisionsReport(summary = {}) {
1339
1443
  lines.push(`agent landings in the last ${plural(days, 'day')}: ${summary.landings}.`);
1340
1444
  lines.push(`landings a human then revised: ${summary.revised}.`);
1341
1445
  lines.push(`revision rate: ${Math.round((summary.rate || 0) * 100)} percent. the target is zero.`);
1446
+ if (Number.isFinite(summary.fixed)) {
1447
+ lines.push(`real fixes: ${summary.fixed}. coaching (roles, goals, context): ${summary.coached || 0}.`);
1448
+ }
1342
1449
  for (const item of Array.isArray(summary.revisions) ? summary.revisions : []) {
1343
1450
  lines.push('');
1344
- lines.push(`an agent landed "${plainSentence(item.landing.subject)}". a human then changed ${listFilesPhrase(item.files)} within ${REVISION_WINDOW_HOURS} hours.`);
1451
+ const after = item.kind === 'coaching'
1452
+ ? `a human then coached the team in ${listFilesPhrase(item.files)} within ${REVISION_WINDOW_HOURS} hours.`
1453
+ : `a human then changed ${listFilesPhrase(item.files)} within ${REVISION_WINDOW_HOURS} hours.`;
1454
+ lines.push(`an agent landed "${plainSentence(item.landing.subject)}". ${after}`);
1345
1455
  }
1346
1456
  return lines.join('\n');
1347
1457
  }