atris 3.70.0 → 3.71.1

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 (79) 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 +38 -8
  7. package/commands/autoland.js +176 -7
  8. package/commands/autopilot.js +71 -11
  9. package/commands/campaign.js +779 -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/integrations.js +17 -5
  17. package/commands/interview.js +15 -3
  18. package/commands/land.js +22 -6
  19. package/commands/link.js +1 -0
  20. package/commands/member.js +359 -94
  21. package/commands/mission.js +249 -36
  22. package/commands/report.js +42 -8
  23. package/commands/run.js +9 -2
  24. package/commands/scout.js +9 -1
  25. package/commands/task.js +71 -17
  26. package/commands/team.js +212 -4
  27. package/commands/unknowns.js +15 -1
  28. package/commands/verify.js +46 -0
  29. package/commands/worktree.js +108 -15
  30. package/commands/write.js +12 -1
  31. package/commands/youtube.js +17 -2
  32. package/lib/accept-verify-gate.js +1 -0
  33. package/lib/account-bound.js +13 -0
  34. package/lib/auto-accept-certified.js +1 -0
  35. package/lib/autoland.js +13 -4
  36. package/lib/campaign-cadence.js +131 -0
  37. package/lib/campaign-core.js +330 -0
  38. package/lib/campaign-holdout.js +65 -0
  39. package/lib/campaign-loop.js +246 -0
  40. package/lib/campaign-missions.js +237 -0
  41. package/lib/campaign-new.js +251 -0
  42. package/lib/campaign-protect.js +75 -0
  43. package/lib/campaign-reading.js +147 -0
  44. package/lib/campaign-run-lock.js +59 -0
  45. package/lib/campaign-schema.js +196 -0
  46. package/lib/campaign-store.js +380 -0
  47. package/lib/campaign-sync.js +302 -0
  48. package/lib/campaign-trace.js +300 -0
  49. package/lib/conductor-artifacts.js +15 -1
  50. package/lib/daily-log.js +11 -1
  51. package/lib/dispatch-scout.js +18 -0
  52. package/lib/engine-ask.js +33 -3
  53. package/lib/engine-registry.js +2 -0
  54. package/lib/engine-resource.js +518 -0
  55. package/lib/fleet.js +125 -16
  56. package/lib/init-commit.js +80 -0
  57. package/lib/intents.js +8 -0
  58. package/lib/known-commands.js +1 -1
  59. package/lib/land-green.js +58 -7
  60. package/lib/landing-proof.js +352 -0
  61. package/lib/member-autoland.js +395 -18
  62. package/lib/member-board.js +7 -1
  63. package/lib/member-map-fold.js +126 -0
  64. package/lib/member-map-inbox.js +80 -0
  65. package/lib/member-progress.js +48 -1
  66. package/lib/member-report.js +127 -8
  67. package/lib/member-self-next-job.js +97 -0
  68. package/lib/member-task-pick.js +41 -14
  69. package/lib/member-up.js +249 -16
  70. package/lib/roster-prep.js +25 -2
  71. package/lib/runner-command.js +5 -0
  72. package/lib/task-db.js +47 -11
  73. package/lib/task-explanation.js +7 -0
  74. package/lib/task-list-keeper.js +8 -1
  75. package/lib/task-receipt.js +6 -1
  76. package/mcp/atris-mcp/index.mjs +212 -10
  77. package/package.json +2 -1
  78. package/scripts/det/engine-reading.js +37 -0
  79. package/scripts/det/ytnotes +29 -5
package/commands/team.js CHANGED
@@ -175,6 +175,19 @@ function memberIsActive({ awake, activity }) {
175
175
 
176
176
  const ACTIVITY_WINDOW_MS = IDLE_AFTER_DAYS * 24 * 60 * 60 * 1000;
177
177
 
178
+ // A log whose only entry is the note setup writes ("Member initialized") is
179
+ // not work: a fresh project's members have never run.
180
+ const SEED_LOG_MAX_BYTES = 1024;
181
+ function isSeedOnlyLog(file) {
182
+ try {
183
+ if (fs.statSync(file).size > SEED_LOG_MAX_BYTES) return false;
184
+ const headings = fs.readFileSync(file, 'utf8').split(/\r?\n/).filter((line) => line.startsWith('## '));
185
+ return headings.length > 0 && headings.every((line) => /·\s*Member initialized\s*$/.test(line));
186
+ } catch {
187
+ return false;
188
+ }
189
+ }
190
+
178
191
  // Dated file names under the member's logs/ folder and one level below it
179
192
  // (logs/2026/2026-09-28.md). The date in the name, never the file time: a
180
193
  // fresh checkout stamps every file with today.
@@ -191,12 +204,12 @@ function memberLogDays(member, root) {
191
204
  try { inner = fs.readdirSync(path.join(dir, entry.name)); } catch { inner = []; }
192
205
  for (const name of inner) {
193
206
  const day = logFileDay(name);
194
- if (day) days.push(day);
207
+ if (day && !isSeedOnlyLog(path.join(dir, entry.name, name))) days.push(day);
195
208
  }
196
209
  continue;
197
210
  }
198
211
  const day = logFileDay(entry.name);
199
- if (day) days.push(day);
212
+ if (day && !isSeedOnlyLog(path.join(dir, entry.name))) days.push(day);
200
213
  }
201
214
  return days;
202
215
  }
@@ -432,6 +445,63 @@ function liveRunJson(run) {
432
445
  };
433
446
  }
434
447
 
448
+ function realFolder(folder) {
449
+ try { return fs.realpathSync(folder); } catch { return path.resolve(folder); }
450
+ }
451
+
452
+ // Each run's working folder in one lookup: /proc on Linux, lsof elsewhere.
453
+ // A folder that cannot be read stays unknown.
454
+ function liveRunFolders(pids, deps = {}) {
455
+ if (typeof deps.runFolders === 'function') return deps.runFolders(pids);
456
+ const folders = new Map();
457
+ if (!pids.length) return folders;
458
+ if (process.platform === 'linux') {
459
+ for (const pid of pids) {
460
+ try { folders.set(pid, fs.readlinkSync(`/proc/${pid}/cwd`)); } catch { /* unknown */ }
461
+ }
462
+ return folders;
463
+ }
464
+ let result;
465
+ try {
466
+ result = spawnSync('lsof', ['-a', '-p', pids.join(','), '-d', 'cwd', '-Fpn'], { encoding: 'utf8', timeout: 3000 });
467
+ } catch {
468
+ return folders;
469
+ }
470
+ let pid = null;
471
+ for (const line of String(result?.stdout || '').split(/\r?\n/)) {
472
+ if (line.startsWith('p')) pid = Number(line.slice(1));
473
+ else if (line.startsWith('n') && pid) folders.set(pid, line.slice(1));
474
+ }
475
+ return folders;
476
+ }
477
+
478
+ // This project's folders: the checkout and every work copy git knows for it.
479
+ function projectFolders(root, deps = {}) {
480
+ if (Array.isArray(deps.projectFolders)) return deps.projectFolders.map(realFolder);
481
+ const folders = [realFolder(root)];
482
+ const result = spawnSync('git', ['worktree', 'list', '--porcelain'], { cwd: root, encoding: 'utf8', timeout: 3000 });
483
+ if (result && result.status === 0) {
484
+ for (const line of String(result.stdout || '').split(/\r?\n/)) {
485
+ if (line.startsWith('worktree ')) folders.push(realFolder(line.slice('worktree '.length)));
486
+ }
487
+ }
488
+ return [...new Set(folders)];
489
+ }
490
+
491
+ // Live runs on this machine that work in this project. A run in another
492
+ // project is not this team working; a run whose folder cannot be read stays.
493
+ function liveRunsInProject(runs, root, deps = {}) {
494
+ if (!runs.length) return runs;
495
+ const folders = projectFolders(root, deps);
496
+ const where = liveRunFolders(runs.map((run) => run.pid), deps);
497
+ return runs.filter((run) => {
498
+ const folder = where.get(run.pid);
499
+ if (!folder) return true;
500
+ const real = realFolder(folder);
501
+ return folders.some((base) => real === base || real.startsWith(base + path.sep));
502
+ });
503
+ }
504
+
435
505
  // The table's rows, in order, for text and json alike. Parked members are
436
506
  // left out unless `all` (or unless they are working right now).
437
507
  function teamTableRows(rosterRows, { lineup = null, liveRuns = [], all = false } = {}) {
@@ -537,6 +607,139 @@ function teamJsonRows(allRows, deps = {}) {
537
607
  return [...members, ...others];
538
608
  }
539
609
 
610
+ // The first paragraph under the title of a MEMBER.md, for files written
611
+ // without a header block. A leading "role:" label is dropped.
612
+ function memberFileOpening(member) {
613
+ let text = '';
614
+ try { text = fs.readFileSync(member.path, 'utf8'); } catch { return ''; }
615
+ const body = text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '');
616
+ const paragraph = [];
617
+ for (const line of body.split(/\r?\n/)) {
618
+ const trimmed = line.trim();
619
+ if (trimmed.startsWith('#') || !trimmed) {
620
+ if (paragraph.length) break;
621
+ continue;
622
+ }
623
+ paragraph.push(trimmed);
624
+ }
625
+ return paragraph.join(' ').replace(/^role:\s*/i, '');
626
+ }
627
+
628
+ const MEMBER_DOES_MAX = 140;
629
+
630
+ // What a member does, in its own words: the description, else the role,
631
+ // else the opening of its file. Only the first sentence, so the column
632
+ // stays one idea per member.
633
+ function memberDoes(member) {
634
+ const role = String(member?.role || '').trim();
635
+ const candidates = [member?.description, role === '(no role)' ? '' : role, memberFileOpening(member)];
636
+ const name = String(member?.name || '').trim().toLowerCase();
637
+ for (const candidate of candidates) {
638
+ const sentence = String(candidate || '').trim().split(/(?<=\.)\s/)[0]
639
+ .replace(/[\u2013\u2014]/g, '-')
640
+ .replace(/\s+/g, ' ')
641
+ .toLowerCase()
642
+ .replace(/[.\s]+$/, '');
643
+ const text = name && sentence.startsWith(name) ? sentence.slice(name.length).replace(/^[\s:,-]+/, '') : sentence;
644
+ if (!text) continue;
645
+ if (text.length <= MEMBER_DOES_MAX) return text;
646
+ const cut = text.slice(0, MEMBER_DOES_MAX);
647
+ return `${cut.slice(0, cut.lastIndexOf(' ')).replace(/[\s,;:-]+$/, '')}…`;
648
+ }
649
+ return 'no role written yet';
650
+ }
651
+
652
+ // `atris member roster`: one line per member with the full name, working or
653
+ // idle, the engine it really runs on, and what it does. Same facts as the
654
+ // team table: a live run in this project means working, the lineup names
655
+ // the engine. Parked members leave the list unless `all`.
656
+ function memberRosterRows(members, { lineup = null, liveRuns = [], parked = new Set(), all = false } = {}) {
657
+ const runsByMember = new Map();
658
+ for (const run of Array.isArray(liveRuns) ? liveRuns : []) {
659
+ const key = String(run.member || '').trim().toLowerCase();
660
+ if (key && !runsByMember.has(key)) runsByMember.set(key, run);
661
+ }
662
+ const rows = members.map((member) => {
663
+ const key = String(member.name || '').trim().toLowerCase();
664
+ const run = runsByMember.get(key);
665
+ const place = memberLineup(lineup, key);
666
+ const isParked = parked.has(member.name) || isParkedFrontmatter(member.frontmatter);
667
+ return {
668
+ name: member.name,
669
+ state: run ? 'working' : isParked ? 'parked' : 'idle',
670
+ engine: run ? liveRunEngineText(run) : place && place.engine ? placedEngineText(place) : 'not set',
671
+ does: memberDoes(member),
672
+ parked: isParked,
673
+ };
674
+ });
675
+ const order = { working: 0, idle: 1, parked: 2 };
676
+ return rows
677
+ .filter((row) => all || !row.parked || row.state === 'working')
678
+ .sort((a, b) => order[a.state] - order[b.state] || a.name.localeCompare(b.name));
679
+ }
680
+
681
+ const MEMBER_ROSTER_COLUMNS = Object.freeze([
682
+ { header: 'MEMBER' },
683
+ { header: 'STATUS' },
684
+ { header: 'ENGINE' },
685
+ { header: 'WHAT IT DOES', wrap: true, min: 20 },
686
+ ]);
687
+
688
+ function renderMemberRoster(members, deps = {}) {
689
+ if (!members.length) return 'no team members yet. create one with: atris member create <name> --role="..."';
690
+ const width = terminalWidth(deps.termWidth);
691
+ const rows = memberRosterRows(members, deps);
692
+ const lines = [renderTable(MEMBER_ROSTER_COLUMNS, rows.map((row) => [row.name, row.state, row.engine, row.does]), { width })];
693
+ const working = rows.filter((row) => row.state === 'working').length;
694
+ lines.push('', `${rows.length} ${rows.length === 1 ? 'member' : 'members'}, ${working} working now.`);
695
+ const shown = new Set(rows.map((row) => row.name));
696
+ const hidden = members.filter((member) => !shown.has(member.name)).map((member) => member.name);
697
+ if (hidden.length) {
698
+ const names = [...hidden];
699
+ names[0] = `parked (${hidden.length}): ${names[0]}`;
700
+ names[names.length - 1] = `${names[names.length - 1]} · atris member roster --all to show them`;
701
+ lines.push(wrapCommaNames(names, width));
702
+ }
703
+ return lines.join('\n');
704
+ }
705
+
706
+ function memberRosterCommand(args = [], deps = {}) {
707
+ const root = deps.root || process.cwd();
708
+ const members = (deps.members || collectMembers(root, deps)).filter((member) => !isTemplateMember(member));
709
+ const parked = new Set(members.filter((member) => isMemberParked(root, member.name)).map((member) => member.name));
710
+ const lineup = deps.lineup !== undefined ? deps.lineup : readLineupSafe(root, deps.lineupNow || new Date());
711
+ const liveRuns = Array.isArray(deps.liveRuns) ? deps.liveRuns : liveRunsInProject(listLiveEngineRuns(deps), root, deps);
712
+ const view = { lineup: lineup && lineup.ok ? lineup : null, liveRuns, parked, all: args.includes('--all') };
713
+ const write = deps.write || ((text) => process.stdout.write(text));
714
+ if (args.includes('--json')) {
715
+ const byName = new Map(memberRosterRows(members, { ...view, all: true }).map((row) => [row.name, row]));
716
+ write(`${JSON.stringify({
717
+ ok: true,
718
+ count: members.length,
719
+ members: members.map((member) => {
720
+ const row = byName.get(member.name);
721
+ return {
722
+ name: member.name,
723
+ ...(row.parked ? { parked: true } : {}),
724
+ state: row.state,
725
+ engine: row.engine,
726
+ does: row.does,
727
+ role: member.role,
728
+ format: member.format,
729
+ skills: member.format === 'directory' ? member.skillCount : null,
730
+ context: member.format === 'directory' ? member.contextCount : null,
731
+ version: member.version || null,
732
+ };
733
+ }),
734
+ }, null, 2)}\n`);
735
+ return 0;
736
+ }
737
+ const output = renderMemberRoster(members, { ...view, termWidth: deps.termWidth });
738
+ if (deps.lineup === undefined && lineup && !lineup.ok) write(`${output}\n\n${LINEUP_UNREADABLE}\n`);
739
+ else write(`${output}\n`);
740
+ return 0;
741
+ }
742
+
540
743
  function renderTeamRosterHtml(allRows, meta = {}) {
541
744
  const rosterRows = withParkedLabels(allRows, Boolean(meta.all)).rows;
542
745
  const activeRows = rosterRows.filter((entry) => entry.active);
@@ -907,8 +1110,10 @@ function teamCommand(args = [], deps = {}) {
907
1110
  const lineup = deps.lineup !== undefined
908
1111
  ? deps.lineup
909
1112
  : readLineupSafe(deps.root || repoRoot(deps.cwd || process.cwd()), deps.lineupNow || new Date());
910
- // Who is working this minute: live engine runs on this machine.
911
- const liveRuns = Array.isArray(deps.liveRuns) ? deps.liveRuns : listLiveEngineRuns(deps);
1113
+ // Who is working this minute: live engine runs in this project.
1114
+ const liveRuns = Array.isArray(deps.liveRuns)
1115
+ ? deps.liveRuns
1116
+ : liveRunsInProject(listLiveEngineRuns(deps), deps.root || repoRoot(deps.cwd || process.cwd()), deps);
912
1117
  const output = json
913
1118
  ? JSON.stringify(teamJsonRows(roster, { lineup, liveRuns }), null, 2)
914
1119
  : renderTeamRoster(roster, { ...deps, lineup, liveRuns, all });
@@ -933,6 +1138,9 @@ module.exports = {
933
1138
  collectTeamPrune,
934
1139
  recordMemberActivity,
935
1140
  collectTeamRoster,
1141
+ memberRosterCommand,
1142
+ memberRosterRows,
1143
+ renderMemberRoster,
936
1144
  renderTeamPrune,
937
1145
  renderTeamRoster,
938
1146
  teamCommand,
@@ -261,6 +261,9 @@ function runRunnerPrompt(root, prompt) {
261
261
  const cmd = buildRunnerCommand({ promptFile });
262
262
  const env = { ...process.env };
263
263
  delete env.CLAUDECODE;
264
+ // Machine memory and load at both ends of the launch, so one killed with
265
+ // SIGKILL says whether the machine was short on memory.
266
+ const endResource = require('../lib/engine-resource').startEngineRun(root, { engine: 'runner', task: 'unknowns' });
264
267
  const result = spawnSync(cmd, [], {
265
268
  cwd: root,
266
269
  encoding: 'utf8',
@@ -270,7 +273,17 @@ function runRunnerPrompt(root, prompt) {
270
273
  maxBuffer: 10 * 1024 * 1024,
271
274
  env,
272
275
  });
273
- if (result.error) return { ok: false, reason: result.error.message || 'runner failed' };
276
+ // spawnSync does not throw, so a failure is passed in the thrown shape.
277
+ const failed = result.error || result.signal || result.status !== 0;
278
+ const end = endResource(failed ? {
279
+ code: result.error && result.error.code,
280
+ signal: result.signal,
281
+ stderr: result.stderr,
282
+ message: result.error ? result.error.message : `runner exited ${result.status}`,
283
+ } : null);
284
+ const cause = end && end.cause ? `; ${end.cause}` : '';
285
+ if (result.error) return { ok: false, reason: `${result.error.message || 'runner failed'}${cause}` };
286
+ if (result.signal) return { ok: false, reason: `runner stopped by ${result.signal}${cause}` };
274
287
  if (result.status !== 0) {
275
288
  return { ok: false, reason: clipLine(result.stderr || result.stdout || `runner exited ${result.status}`, 300) };
276
289
  }
@@ -618,5 +631,6 @@ module.exports = {
618
631
  unknownsCommand,
619
632
  showHelp,
620
633
  fallbackPayload,
634
+ runRunnerPrompt,
621
635
  gatherTerritoryContext,
622
636
  };
@@ -581,6 +581,45 @@ function checkMapForFiles(atrisDir, changes) {
581
581
  return { documented: documented >= fileChanges.length / 2 };
582
582
  }
583
583
 
584
+ const NPM_MUTATING_SUBCOMMANDS = new Set([
585
+ 'i', 'install', 'add',
586
+ 'un', 'uninstall', 'rm', 'r', 'remove', 'unlink',
587
+ 'up', 'update', 'upgrade',
588
+ ]);
589
+ const NPM_GLOBAL_FLAG_RE = /^(?:-g|--global(?:=true)?|--location=global)$/;
590
+ const NPM_GLOBAL_ENV_RE = /\bnpm_config_(?:global=(?:true|1)\b|location=global\b)/i;
591
+
592
+ /**
593
+ * Return the first line of a rubric script that installs, removes, or links
594
+ * a package globally, or null. A rubric once ran `npm i -g atris@<old>` and
595
+ * swapped the CLI under every agent on the machine; proofs stay read-only.
596
+ */
597
+ function findGlobalInstall(script) {
598
+ const text = String(script || '').replace(/\\\n/g, ' ');
599
+ // An exported or inline npm_config_global turns any later install global.
600
+ const globalEnv = NPM_GLOBAL_ENV_RE.test(text.replace(/(^|\s)#.*$/gm, ''));
601
+ for (const rawLine of text.split('\n')) {
602
+ const line = rawLine.replace(/(^|\s)#.*$/, '').trim();
603
+ if (!line) continue;
604
+ for (const segment of line.split(/&&|\|\||[;|&]/)) {
605
+ const tokens = segment.trim().split(/\s+/).filter(Boolean);
606
+ while (tokens.length && (tokens[0] === 'sudo' || /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0]))) tokens.shift();
607
+ const tool = tokens[0];
608
+ if (tool === 'yarn' && tokens[1] === 'global' && tokens[2] !== 'list' && tokens[2] !== 'bin' && tokens[2] !== 'dir') {
609
+ return rawLine.trim();
610
+ }
611
+ if (tool !== 'npm' && tool !== 'pnpm') continue;
612
+ const args = tokens.slice(1);
613
+ const sub = args.find((token, i) => !token.startsWith('-') && args[i - 1] !== '--location');
614
+ if (sub === 'link' || sub === 'ln') return rawLine.trim();
615
+ const isGlobal = globalEnv || args.some((token, i) =>
616
+ NPM_GLOBAL_FLAG_RE.test(token) || (token === '--location' && args[i + 1] === 'global'));
617
+ if (isGlobal && NPM_MUTATING_SUBCOMMANDS.has(sub)) return rawLine.trim();
618
+ }
619
+ }
620
+ return null;
621
+ }
622
+
584
623
  /**
585
624
  * atris verify <slug> --section <name>
586
625
  *
@@ -617,6 +656,13 @@ function verifyRubric(slug, section, opts = {}) {
617
656
  return 2;
618
657
  }
619
658
  const script = match[1];
659
+ const globalInstall = findGlobalInstall(script);
660
+ if (globalInstall) {
661
+ console.error(`✗ Refusing to run "## ${section}": it changes globally installed packages.`);
662
+ console.error(` ${globalInstall}`);
663
+ console.error(' Proofs must be read-only. Move install steps to prose or a non-bash fence.');
664
+ return 2;
665
+ }
620
666
  const os = require('os');
621
667
  const tmpFile = path.join(os.tmpdir(), `atris-verify-${Date.now()}-${Math.floor(Math.random() * 1e6)}.sh`);
622
668
  fs.writeFileSync(tmpFile, `#!/usr/bin/env bash\nset -e\n${script}\n`);
@@ -5,12 +5,16 @@ const path = require('path');
5
5
  const { spawnSync } = require('child_process');
6
6
  const { hasFlag, readFlag } = require('../lib/arg-parser');
7
7
  const { stampLatestOpenBriefForWorktree } = require('../lib/brief-ledger');
8
- const { isConductorArtifact } = require('../lib/conductor-artifacts');
8
+ const { isConductorArtifact, isConductorUntracked } = require('../lib/conductor-artifacts');
9
9
  const { fixMapDocs } = require('../lib/map-refs');
10
10
  const { sleepSync } = require('../lib/sleep-sync');
11
11
  const close = require('./close');
12
12
 
13
- const REGEN_ADAPTER_FILES = ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md'];
13
+ // Files atris regenerates on its own. Unstaged churn in them is restored
14
+ // before ship commits, unless the commit message names the file. The task
15
+ // projection is rewritten by any task command run in a work folder, and two
16
+ // parallel landings that each carry a copy always clash.
17
+ const REGEN_ADAPTER_FILES = ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md', '.atris/state/tasks.projection.json'];
14
18
  const COMMAND_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
15
19
  const GIT_OID_PATTERN = /^[0-9a-f]{40,64}$/;
16
20
  const ONE_LAP_PROOF_REF_PATTERN = /^refs\/atris\/one-lap\/([0-9a-f]{40,64})$/;
@@ -179,6 +183,14 @@ function lastRunBranch(root, owner, task, base, skip) {
179
183
  return cherry.stdout.split('\n').some((line) => line.startsWith('+')) ? newest : null;
180
184
  }
181
185
 
186
+ // True only when git proves the branch no longer merges cleanly with the base.
187
+ // merge-tree --write-tree exits 1 on conflict; an error or old git keeps the
188
+ // resume as before.
189
+ function conflictsWithBase(root, base, branch) {
190
+ const result = runGit(['merge-tree', '--write-tree', '--name-only', base, branch], { cwd: root, check: false });
191
+ return result.status === 1;
192
+ }
193
+
182
194
  function defaultWorktreePath(root, member, task, now = new Date()) {
183
195
  const name = `${slugify(member, 'member', 24)}-${slugify(task, 'task', 36)}-${stamp(now)}`;
184
196
  return path.join(path.dirname(root), '.agent-worktrees', path.basename(root), name);
@@ -466,10 +478,30 @@ function statusCounts(root, {
466
478
 
467
479
  // Remove conductor plumbing from the index after a broad `git add -A`, so the commit
468
480
  // records engine output only. Paths that were never staged are simply not listed.
481
+ // GitHub refuses a merge with "Base branch was modified" when another PR
482
+ // merged into the same base a moment earlier; the same merge succeeds on a
483
+ // second try. On 2026-10-15 five parallel dispatches lost three landings to
484
+ // it. Retry only that message, a few times, with a short wait.
485
+ const BASE_MODIFIED_PATTERN = /Base branch was modified/i;
486
+ function mergePrWithRetry(mergeArgs, root, { run = spawnSync, wait = sleepSync, attempts = 4 } = {}) {
487
+ let merged = run('gh', mergeArgs, { cwd: root, encoding: 'utf8' });
488
+ for (let attempt = 1; attempt < attempts && merged.status !== 0
489
+ && BASE_MODIFIED_PATTERN.test(`${merged.stderr || ''}${merged.stdout || ''}`); attempt += 1) {
490
+ console.log(`merge: base branch moved, retrying (${attempt + 1}/${attempts})`);
491
+ wait(1500 * attempt);
492
+ merged = run('gh', mergeArgs, { cwd: root, encoding: 'utf8' });
493
+ }
494
+ return merged;
495
+ }
496
+
469
497
  function unstageConductorArtifacts(root) {
470
498
  const staged = runGit(['diff', '--cached', '--name-only'], { cwd: root, check: false });
471
499
  if (staged.status !== 0) return [];
472
- const drop = String(staged.stdout || '').split(/\r?\n/).filter(Boolean).filter(isConductorArtifact);
500
+ const added = runGit(['diff', '--cached', '--name-only', '--diff-filter=A'], { cwd: root, check: false });
501
+ const newFiles = new Set(String(added.stdout || '').split(/\r?\n/).filter(Boolean));
502
+ // A file this commit would add under .atris/ is the tool's own state, never work.
503
+ const drop = String(staged.stdout || '').split(/\r?\n/).filter(Boolean)
504
+ .filter((file) => isConductorArtifact(file) || (newFiles.has(file) && isConductorUntracked(file)));
473
505
  if (drop.length) runGit(['reset', '-q', 'HEAD', '--', ...drop], { cwd: root, check: false });
474
506
  return drop;
475
507
  }
@@ -572,9 +604,18 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
572
604
  const staleBase = refreshRemoteRef(root, checkoutBase);
573
605
  // A retry of the same job starts from the last run's unlanded commits; it
574
606
  // still ships to the mainline.
575
- const resumedFrom = (!pathOverride && !branchOverride)
607
+ let resumedFrom = (!pathOverride && !branchOverride)
576
608
  ? lastRunBranch(root, owner, task, checkoutBase, branch)
577
609
  : null;
610
+ // A last run that now conflicts with the mainline would hand its conflict
611
+ // to the retry, which then cannot land on the first try. Start fresh; the
612
+ // old branch keeps its commits.
613
+ let skippedResume = null;
614
+ if (resumedFrom && conflictsWithBase(root, checkoutBase, resumedFrom)) {
615
+ skippedResume = resumedFrom;
616
+ resumedFrom = null;
617
+ console.error(`note: the last run's branch ${skippedResume} no longer merges cleanly with ${checkoutBase}, so this run starts fresh; that branch keeps its commits`);
618
+ }
578
619
  const startPoint = resumedFrom || checkoutBase;
579
620
  const ref = `refs/heads/${branch}`;
580
621
  const existed = runGit(['show-ref', '--verify', '--quiet', ref], { cwd: root, check: false }).status !== 1;
@@ -590,7 +631,7 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
590
631
  if (registered) {
591
632
  if (!fs.existsSync(target) || fs.realpathSync(registered.path) !== fs.realpathSync(target)
592
633
  || registered.head !== startCommit) return;
593
- const dirt = statusCounts(target, { ignoreUntracked: isConductorArtifact });
634
+ const dirt = statusCounts(target, { ignoreUntracked: isConductorUntracked });
594
635
  if (!dirt || dirt.staged || dirt.unstaged || dirt.untracked) return;
595
636
  // No engine has started. Normal removal still refuses unexpected dirt.
596
637
  runGit(['worktree', 'remove', target], { cwd: root });
@@ -608,6 +649,15 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
608
649
  }
609
650
  sleepSync(100 * (attempt + 1));
610
651
  }
652
+ // A resumed branch takes in what the mainline gained since, or a job that
653
+ // keeps resuming drifts further behind every run until it clashes
654
+ // (2026-10-15: five long jobs sat 103 to 129 commits behind). The clash
655
+ // check above proved this merge is clean; if git still refuses, the run
656
+ // keeps the old start instead of failing.
657
+ if (resumedFrom) {
658
+ const caughtUp = runGit(['merge', '--no-edit', '-m', `Merge ${checkoutBase} into ${branch}`, checkoutBase], { cwd: target, check: false });
659
+ if (caughtUp.status !== 0) runGit(['merge', '--abort'], { cwd: target, check: false });
660
+ }
611
661
  try {
612
662
  runGit(['config', `branch.${branch}.atris-base`, shipBase], { cwd: target });
613
663
  runGit(['config', `branch.${branch}.atris-owner`, owner], { cwd: target });
@@ -624,6 +674,7 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
624
674
  base: shipBase,
625
675
  checkout_base: checkoutBase,
626
676
  resumed_from: resumedFrom,
677
+ skipped_resume: skippedResume,
627
678
  workspace_root: root,
628
679
  created_at: now.toISOString(),
629
680
  }, null, 2) + '\n',
@@ -633,7 +684,7 @@ function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task,
633
684
  discardFailedStart();
634
685
  throw error;
635
686
  }
636
- return { path: target, branch, base: shipBase, checkoutBase, resumedFrom, owner, reapedBeforeStart, cachePrunedBeforeStart };
687
+ return { path: target, branch, base: shipBase, checkoutBase, resumedFrom, skippedResume, owner, reapedBeforeStart, cachePrunedBeforeStart };
637
688
  }
638
689
 
639
690
  function startWorktree(args) {
@@ -949,7 +1000,7 @@ function shipWorktree(args) {
949
1000
  const ignoredUnstagedFiles = dryRun ? new Set(skippedAdapters) : new Set();
950
1001
  // Conductor plumbing must not make the checkout look dirty, or a flight where the
951
1002
  // engine wrote nothing commits the fleet's own prompt file and calls it work.
952
- const counts = statusCounts(root, { ignoredUnstagedFiles, ignoreUntracked: isConductorArtifact })
1003
+ const counts = statusCounts(root, { ignoredUnstagedFiles, ignoreUntracked: isConductorUntracked })
953
1004
  || { staged: 0, unstaged: 0, untracked: 0 };
954
1005
  const dirty = counts.staged || counts.unstaged || counts.untracked;
955
1006
  if (dirty && !message) {
@@ -989,7 +1040,7 @@ function shipWorktree(args) {
989
1040
  // already present before the verifier ran, so counting it here blocks a clean ship.
990
1041
  const afterVerify = dryRun
991
1042
  ? { staged: 0, unstaged: 0, untracked: 0 }
992
- : statusCounts(root, { ignoreUntracked: isConductorArtifact })
1043
+ : statusCounts(root, { ignoreUntracked: isConductorUntracked })
993
1044
  || { staged: 0, unstaged: 0, untracked: 0 };
994
1045
  if (!dryRun && (afterVerify.staged || afterVerify.unstaged || afterVerify.untracked)) {
995
1046
  console.error(
@@ -1029,7 +1080,7 @@ function shipWorktree(args) {
1029
1080
  throw new Error((switched.stderr || switched.stdout || `git switch ${targetBranch} failed`).trim());
1030
1081
  }
1031
1082
  }
1032
- const primaryCounts = statusCounts(primary, { ignoreUntracked: isConductorArtifact }) || { staged: 0, unstaged: 0, untracked: 0 };
1083
+ const primaryCounts = statusCounts(primary, { ignoreUntracked: isConductorUntracked }) || { staged: 0, unstaged: 0, untracked: 0 };
1033
1084
  if (primaryCounts.staged || primaryCounts.unstaged || primaryCounts.untracked) {
1034
1085
  throw new Error(
1035
1086
  `primary checkout dirty staged=${primaryCounts.staged} ` +
@@ -1060,7 +1111,7 @@ function shipWorktree(args) {
1060
1111
  const mergeArgs = ['pr', 'merge'];
1061
1112
  if (mergeRef) mergeArgs.push(mergeRef);
1062
1113
  mergeArgs.push('--merge');
1063
- const merged = spawnSync('gh', mergeArgs, { cwd: root, encoding: 'utf8' });
1114
+ const merged = mergePrWithRetry(mergeArgs, root);
1064
1115
  if (merged.status !== 0) throw new Error((merged.stderr || merged.stdout || 'gh pr merge failed').trim());
1065
1116
  // `gh pr merge` on an already-merged PR exits 0 with a warning
1066
1117
  // instead of failing, which would otherwise report a false landing.
@@ -1104,7 +1155,7 @@ function guard(args) {
1104
1155
  // Conductor plumbing (worktree metadata, the fleet prompt written for this very
1105
1156
  // engine, runtime scratch, the brief ledger) must not count as dirt, otherwise the
1106
1157
  // harness blocks the engine with the harness's own files.
1107
- const counts = statusCounts(root, { ignoreUntracked: isConductorArtifact });
1158
+ const counts = statusCounts(root, { ignoreUntracked: isConductorUntracked });
1108
1159
  if (counts && (counts.staged || counts.unstaged || counts.untracked) && !hasFlag(args, '--allow-dirty')) {
1109
1160
  console.error(`blocked: checkout is dirty staged=${counts.staged} unstaged=${counts.unstaged} untracked=${counts.untracked}`);
1110
1161
  return 3;
@@ -1162,12 +1213,14 @@ function canonicalPath(value) {
1162
1213
  }
1163
1214
  }
1164
1215
 
1216
+ // null means the scan failed: we cannot tell who is inside, so callers keep
1217
+ // every folder. An empty list would read as "nobody is here" and delete.
1165
1218
  function listActiveProcessCwds() {
1166
1219
  const result = spawnSync('lsof', ['-n', '-a', '-d', 'cwd', '-F', 'n'], {
1167
1220
  encoding: 'utf8',
1168
1221
  maxBuffer: COMMAND_MAX_BUFFER_BYTES,
1169
1222
  });
1170
- if (result.status !== 0) return [];
1223
+ if (result.error || result.status !== 0) return null;
1171
1224
  return String(result.stdout || '')
1172
1225
  .split(/\r?\n/)
1173
1226
  .filter((line) => line.startsWith('n'))
@@ -1175,7 +1228,45 @@ function listActiveProcessCwds() {
1175
1228
  .filter(Boolean);
1176
1229
  }
1177
1230
 
1231
+ // Absolute paths named on each program's command line, including flag values
1232
+ // like --require=/x.js. A program can run a script from a work folder while
1233
+ // its current folder is elsewhere; the folder scan alone misses it. The list
1234
+ // prints arguments unquoted, so a path with a space is also kept as the rest
1235
+ // of the line from where it starts; the folder match is a prefix check.
1236
+ function parseProcessArgPaths(psOutput) {
1237
+ const paths = new Set();
1238
+ const starts = /(?:^|\s)(?:[^\s=]*=)?(?=\/)/g;
1239
+ for (const raw of String(psOutput || '').split(/\r?\n/)) {
1240
+ const line = raw.trim();
1241
+ for (const match of line.matchAll(starts)) {
1242
+ const rest = line.slice(match.index + match[0].length);
1243
+ const token = rest.split(/\s/, 1)[0];
1244
+ if (token.length > 1) paths.add(token);
1245
+ if (rest.length > token.length) paths.add(rest);
1246
+ }
1247
+ }
1248
+ return [...paths];
1249
+ }
1250
+
1251
+ function listActiveProcessArgPaths() {
1252
+ const result = spawnSync('ps', ['-axww', '-o', 'args='], {
1253
+ encoding: 'utf8',
1254
+ maxBuffer: COMMAND_MAX_BUFFER_BYTES,
1255
+ });
1256
+ if (result.error || result.status !== 0) return [];
1257
+ return parseProcessArgPaths(result.stdout);
1258
+ }
1259
+
1260
+ // Everything a running program is anchored to: its current folder plus any
1261
+ // script path on its command line.
1262
+ function listActiveProcessPaths() {
1263
+ const cwds = listActiveProcessCwds();
1264
+ if (!Array.isArray(cwds)) return cwds;
1265
+ return cwds.concat(listActiveProcessArgPaths());
1266
+ }
1267
+
1178
1268
  function worktreeHasActiveProcess(worktreePath, activeCwds) {
1269
+ if (!Array.isArray(activeCwds)) return true;
1179
1270
  const resolved = canonicalPath(worktreePath);
1180
1271
  const prefix = `${resolved}${path.sep}`;
1181
1272
  return activeCwds.some((cwd) => {
@@ -1223,7 +1314,7 @@ function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply =
1223
1314
  const worktrees = listWorktrees(root);
1224
1315
  const primary = worktrees[0]?.path ? path.resolve(worktrees[0].path) : '';
1225
1316
  const current = path.resolve(root);
1226
- const activeCwds = Array.isArray(activeCwdsOverride) ? activeCwdsOverride.map(canonicalPath) : listActiveProcessCwds();
1317
+ const activeCwds = Array.isArray(activeCwdsOverride) ? activeCwdsOverride.map(canonicalPath) : listActiveProcessPaths();
1227
1318
  const base = normalizeTargetRef(root, baseOverride || defaultShipTarget(root));
1228
1319
  refreshRemoteRef(root, base);
1229
1320
  const candidates = [];
@@ -1258,7 +1349,7 @@ function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply =
1258
1349
  continue;
1259
1350
  }
1260
1351
  if (worktreeHasActiveProcess(wtPath, activeCwds)) {
1261
- kept.push({ ...item, reason: 'active_process' });
1352
+ kept.push({ ...item, reason: activeCwds ? 'active_process' : 'process_scan_failed' });
1262
1353
  continue;
1263
1354
  }
1264
1355
  const rawCounts = statusCounts(wt.path);
@@ -1438,6 +1529,7 @@ function worktreeCommand(args = []) {
1438
1529
  module.exports = {
1439
1530
  branchName,
1440
1531
  collidingFlights,
1532
+ mergePrWithRetry,
1441
1533
  createAgentWorktree,
1442
1534
  deniedLaneForShip,
1443
1535
  createOrFindPr,
@@ -1448,9 +1540,10 @@ module.exports = {
1448
1540
  gitNetworkEnvironment,
1449
1541
  describeFlightAge,
1450
1542
  flightStampMs,
1451
- listActiveProcessCwds,
1543
+ listActiveProcessPaths,
1452
1544
  listWorktrees,
1453
1545
  parseAgentFlightName,
1546
+ parseProcessArgPaths,
1454
1547
  parseWorktrees,
1455
1548
  normalizeTargetRef,
1456
1549
  recordWorktreeRemoval,
package/commands/write.js CHANGED
@@ -341,7 +341,17 @@ function coach(argv, root = process.cwd()) {
341
341
 
342
342
  if (!offline && claudeAvailable()) {
343
343
  const prompt = coachPrompt({ topic, beats, counts, findings, planText, draftText, currentBeat: current.title });
344
+ // Machine memory and load at both ends of the launch, so one killed with
345
+ // SIGKILL says whether the machine was short on memory.
346
+ const endResource = require('../lib/engine-resource').startEngineRun(root, { engine: 'claude', task: 'write coach' });
344
347
  const res = spawnSync('claude', ['-p', prompt], { encoding: 'utf8', timeout: 120000, maxBuffer: 1024 * 1024 });
348
+ // spawnSync does not throw, so a failure is passed in the thrown shape.
349
+ const end = endResource(res.error || res.signal || res.status !== 0 ? {
350
+ code: res.error && res.error.code,
351
+ signal: res.signal,
352
+ stderr: res.stderr,
353
+ message: res.error ? res.error.message : `claude exited ${res.status}`,
354
+ } : null);
345
355
  const out = (res.stdout || '').trim();
346
356
  if (res.status === 0 && /QUESTION:/.test(out)) {
347
357
  console.log(out.split('\n').map((l) => ` ${l}`).join('\n'));
@@ -350,7 +360,8 @@ function coach(argv, root = process.cwd()) {
350
360
  console.log(`\n your move: answer under "## ${current.title}" in draft.md, then \`atris write status\`\n`);
351
361
  return 0;
352
362
  }
353
- console.log(' (claude coach unavailable, falling back to the offline coach)\n');
363
+ const stopped = end && end.cause ? `claude coach stopped by ${res.signal}; ${end.cause}` : 'claude coach unavailable';
364
+ console.log(` (${stopped}, falling back to the offline coach)\n`);
354
365
  }
355
366
 
356
367
  // Offline coach: still proactive, still gets you going. Seeds + one question.