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
package/commands/task.js CHANGED
@@ -25,6 +25,7 @@ const {
25
25
  evaluateAutoAccept,
26
26
  isAgentCertified,
27
27
  isAutoCertifyVerifyCommandAllowed,
28
+ parseVerifyChain,
28
29
  parseVerifyCommand,
29
30
  runVerifyCommand,
30
31
  runVerifyCommandCached,
@@ -2204,6 +2205,7 @@ function taskReviewCleanEvidenceCommand(command) {
2204
2205
  /\s+\(?(?:passed|ok|clean|failed|errored|timed out|succeeded|succeeds|successful|confirmed)\)?$/i,
2205
2206
  /\s+\(?\d+\/\d+(?:\s+(?:tests?|checks?|passed|pass|ok|clean|failed|failures?))?\)?$/i,
2206
2207
  /\s+\d+\/\d+(?:\s+(?:tests?|checks?|passed|pass|ok|clean|failed|failures?))?$/i,
2208
+ /\s+(?:passed|exited\s+0)(?:\s*\(\s*exit\s+0\s*\))?(?:\.(?:\s.*)?)?$/i,
2207
2209
  ];
2208
2210
  for (const pattern of tailPatterns) {
2209
2211
  clean = taskReviewTrimOutsideQuotedTail(clean, pattern);
@@ -2254,7 +2256,7 @@ function taskReviewCommandIsDescribedSubject(clause, commandIndex) {
2254
2256
  function taskReviewEvidenceCommands(text, limit = 8) {
2255
2257
  const source = String(text || '').trim();
2256
2258
  if (!source) return [];
2257
- const commandWord = '(?:npm|node|git|atris|npx|pnpm|yarn|python3?|pytest|bash|sh|tsc|vitest|curl|gh|rg)';
2259
+ const commandWord = '(?:npm|node|git|atris|npx|pnpm|yarn|python3?|pytest|bash|sh|tsc|vitest|curl|gh|rg|test(?=\\s+-[sf]\\b))';
2258
2260
  const envPrefix = '(?:(?:[A-Z_][A-Z0-9_]*=[^\\s,;|]+)\\s+)*';
2259
2261
  const prosePrefix = '(?:(?:rechecked|reran|re-run|run|verified|validated|passed|validation(?:\\s+passed)?|verification(?:\\s+passed)?|focused|live|scoped|installed|direct|full|current|fresh|then|and|commands?|checks?)[:\\s]+)*';
2260
2262
  const commandStart = `${prosePrefix}${envPrefix}${commandWord}\\b`;
@@ -10460,16 +10462,35 @@ function certifyVerifyCandidate(task) {
10460
10462
  const review = task.review || {};
10461
10463
  const recorded = typeof metadata.verify === 'string' && metadata.verify.trim() ? [metadata.verify.trim()] : [];
10462
10464
  const proof = String(review.proof || metadata.latest_agent_proof || '');
10463
- for (const raw of [...recorded, ...taskReviewEvidenceCommands(proof)]) {
10465
+ // The extractor splits on &&, so rebuild chains the proof actually wrote
10466
+ // ("a && b") and try each one before its pieces.
10467
+ const flatProof = proof.replace(/`/g, '').replace(/\s+/g, ' ');
10468
+ const pieces = taskReviewEvidenceCommands(proof);
10469
+ const ordered = [];
10470
+ for (let i = 0; i < pieces.length; i += 1) {
10471
+ for (let j = pieces.length; j > i + 1; j -= 1) {
10472
+ const chain = pieces.slice(i, j).join(' && ');
10473
+ if (flatProof.includes(chain)) ordered.push(chain);
10474
+ }
10475
+ ordered.push(pieces[i]);
10476
+ }
10477
+ for (const raw of [...recorded, ...ordered]) {
10464
10478
  // The evidence extractor can keep trailing prose ("git diff --check. Evidence
10465
10479
  // inspected: ..."); try the full clause, then the first sentence of it.
10466
10480
  for (const candidate of [raw, raw.split(/\.(?:\s|$)/)[0]].map((c) => String(c || '').trim())) {
10467
- if (candidate && parseVerifyCommand(candidate).ok) return candidate;
10481
+ if (candidate && isAutoCertifyVerifyCommandAllowed(candidate) && !verifyIsOnlyFileChecks(candidate)) return candidate;
10468
10482
  }
10469
10483
  }
10470
10484
  return null;
10471
10485
  }
10472
10486
 
10487
+ // "File exists" is not certification: a verify made only of test -s / test -f
10488
+ // steps does not qualify on its own.
10489
+ function verifyIsOnlyFileChecks(verify) {
10490
+ const chain = parseVerifyChain(verify);
10491
+ return chain.ok && chain.steps.every((step) => step.argv[0] === 'test' && /^-[sf]$/.test(step.argv[1] || ''));
10492
+ }
10493
+
10473
10494
  function stampCertifyVerifyMetadata(taskDb, db, taskId, actor, verify) {
10474
10495
  const row = taskDb.getTask(db, taskId);
10475
10496
  if (!row) return;
@@ -13573,4 +13594,6 @@ module.exports = {
13573
13594
  taskApprovalFor,
13574
13595
  taskPageContract,
13575
13596
  taskDescriptionForCloud,
13597
+ taskReviewCleanEvidenceCommand,
13598
+ certifyVerifyCandidate,
13576
13599
  };
@@ -83,7 +83,7 @@ function reviewSoftTitle(title, maxWords = 5) {
83
83
 
84
84
  function isHumanDeskNext(command) {
85
85
  const text = String(command || '');
86
- return /^atris do\b/.test(text) || /^atris task (?:claim|show|ready|accept)\b/.test(text);
86
+ return /^atris do\b/.test(text) || /^atris done\b/.test(text) || /^atris task (?:claim|show|ready|accept)\b/.test(text);
87
87
  }
88
88
 
89
89
  function loadReviewTasks(root = process.cwd()) {
@@ -7,6 +7,7 @@ const { hasFlag, readFlag } = require('../lib/arg-parser');
7
7
  const { stampLatestOpenBriefForWorktree } = require('../lib/brief-ledger');
8
8
  const { isConductorArtifact } = require('../lib/conductor-artifacts');
9
9
  const { fixMapDocs } = require('../lib/map-refs');
10
+ const { sleepSync } = require('../lib/sleep-sync');
10
11
  const close = require('./close');
11
12
 
12
13
  const REGEN_ADAPTER_FILES = ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md'];
@@ -16,15 +17,48 @@ const ONE_LAP_PROOF_REF_PATTERN = /^refs\/atris\/one-lap\/([0-9a-f]{40,64})$/;
16
17
  const DAY_MS = 24 * 60 * 60 * 1000;
17
18
  const SHIP_HEALTH_SOURCE = 'ship-health:experiments-daily';
18
19
 
20
+ function gitNetworkEnvironment(source = process.env, cwd = process.cwd()) {
21
+ const env = { ...source, GIT_TERMINAL_PROMPT: '0' };
22
+ // Custom commands may be wrappers that do not accept OpenSSH flags.
23
+ // Leave Git's own command/config/program precedence intact.
24
+ if (source.GIT_SSH_COMMAND || source.GIT_SSH) return env;
25
+ const configured = spawnSync('git', ['config', '--get', 'core.sshCommand'], {
26
+ cwd, encoding: 'utf8', env: source, timeout: 5000, killSignal: 'SIGKILL',
27
+ });
28
+ if (configured.status === 0 && String(configured.stdout || '').trim()) return env;
29
+ env.GIT_SSH_COMMAND = 'ssh -o BatchMode=yes -o ConnectTimeout=10 -o ServerAliveInterval=15';
30
+ return env;
31
+ }
32
+
33
+ // A killed network call leaves stderr empty; name the timeout instead.
34
+ function gitFailureDetail(result) {
35
+ if (result && result.error && result.error.code === 'ETIMEDOUT') return 'timed out';
36
+ return String((result && (result.stderr || result.stdout)) || '').trim();
37
+ }
38
+
19
39
  function runGit(args, { cwd = process.cwd(), check = true, timeout } = {}) {
20
- const result = spawnSync('git', args, { cwd, encoding: 'utf8', timeout });
40
+ const network = ['fetch', 'push', 'ls-remote'].includes(args[0]);
41
+ // Only network calls need the SSH probe; local calls skip its extra git spawn.
42
+ const options = { cwd, encoding: 'utf8', timeout: timeout ?? (network ? 30000 : undefined),
43
+ killSignal: 'SIGKILL', env: network ? gitNetworkEnvironment(process.env, cwd) : undefined };
44
+ let result = spawnSync('git', args, options);
45
+ // Linked checkouts share config. A concurrent start must not silently lose
46
+ // its owner/base metadata, but real config errors still stop immediately.
47
+ for (let retry = 1; args[0] === 'config' && retry < 3 && configLocked(result); retry += 1) {
48
+ sleepSync(100 * retry);
49
+ result = spawnSync('git', args, options);
50
+ }
21
51
  if (check && result.status !== 0) {
22
- const msg = (result.stderr || result.stdout || `git ${args.join(' ')} failed`).trim();
23
- throw new Error(msg);
52
+ const detail = gitFailureDetail(result);
53
+ throw new Error(detail === 'timed out' ? `git ${args[0]} timed out` : (detail || `git ${args.join(' ')} failed`));
24
54
  }
25
55
  return result;
26
56
  }
27
57
 
58
+ function configLocked(result) {
59
+ return result.status !== 0 && /could not lock config file/i.test(`${result.stderr || ''}${result.stdout || ''}`);
60
+ }
61
+
28
62
  function runCommand(command, { cwd = process.cwd(), check = true } = {}) {
29
63
  const result = spawnSync(command, {
30
64
  cwd,
@@ -285,6 +319,23 @@ function currentUpstream(root) {
285
319
  }
286
320
 
287
321
  function remoteHead(root) {
322
+ // The saved origin/HEAD survives branch renames. Ask the remote before
323
+ // trusting it, keeping the offline fallback when the remote is unavailable.
324
+ const remote = runGit(['remote', 'get-url', 'origin'], { cwd: root, check: false });
325
+ if (remote.status === 0) {
326
+ const live = runGit(['ls-remote', '--symref', 'origin', 'HEAD'], { cwd: root, check: false, timeout: 5000 });
327
+ const match = live.status === 0 && String(live.stdout || '').match(/^ref: refs\/heads\/(.+)\s+HEAD$/m);
328
+ if (match) {
329
+ const head = `origin/${match[1]}`;
330
+ if (!refExists(root, head)) {
331
+ const fetched = runGit(['fetch', 'origin', `refs/heads/${match[1]}:refs/remotes/${head}`], { cwd: root, check: false, timeout: 5000 });
332
+ if (fetched.status !== 0 || !refExists(root, head)) {
333
+ throw new Error('could not fetch the remote default branch; stop and retry when the remote is available');
334
+ }
335
+ }
336
+ return head;
337
+ }
338
+ }
288
339
  const result = runGit(['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], { cwd: root, check: false });
289
340
  return result.status === 0 ? result.stdout.trim() : '';
290
341
  }
@@ -311,7 +362,7 @@ function normalizeTargetRef(root, target) {
311
362
  function defaultMainlineBase(root) {
312
363
  const head = remoteHead(root);
313
364
  if (head && refExists(root, head)) return head;
314
- for (const candidate of ['origin/master', 'origin/main']) {
365
+ for (const candidate of ['origin/main', 'origin/master']) {
315
366
  if (refExists(root, candidate)) return candidate;
316
367
  }
317
368
  return 'HEAD';
@@ -342,7 +393,7 @@ function defaultShipTarget(root) {
342
393
  }
343
394
  const head = remoteHead(root);
344
395
  if (head && refExists(root, head)) return head;
345
- for (const candidate of ['origin/master', 'origin/main']) {
396
+ for (const candidate of ['origin/main', 'origin/master']) {
346
397
  if (refExists(root, candidate)) return candidate;
347
398
  }
348
399
  return 'HEAD';
@@ -351,7 +402,8 @@ function defaultShipTarget(root) {
351
402
  function refreshRemoteRef(root, ref) {
352
403
  if (!ref.startsWith('origin/')) return;
353
404
  const remoteBranch = ref.slice('origin/'.length);
354
- runGit(['fetch', 'origin', remoteBranch], { cwd: root, check: false });
405
+ const fetched = runGit(['fetch', 'origin', remoteBranch], { cwd: root, check: false, timeout: 5000 });
406
+ return fetched.status === 0 ? '' : (gitFailureDetail(fetched) || 'fetch failed');
355
407
  }
356
408
 
357
409
  function baseBranchName(ref) {
@@ -456,7 +508,7 @@ function printStatus() {
456
508
  // worktree ship` targets, recorded as branch.<branch>.atris-base) are kept
457
509
  // separate on purpose. checkoutBase defaults to the launcher's own
458
510
  // upstream/default remote base, so the agent starts from current work.
459
- // shipBase defaults to origin/master for agent/member worktrees regardless of
511
+ // shipBase defaults to the remote mainline for agent/member worktrees regardless of
460
512
  // what branch the launcher happened to have checked out. recording the
461
513
  // launcher's own feature branch as the ship target false-landed two PRs on
462
514
  // 2026-07-04 (ship merged into the launcher's branch, never reached master).
@@ -465,48 +517,85 @@ function printStatus() {
465
517
  function createAgentWorktree({ root = repoRoot(), member = '', agent = '', task, branch: branchOverride, path: pathOverride, base: baseOverride, now = new Date() } = {}) {
466
518
  const owner = member || agent;
467
519
  if (!owner || !task) throw new Error('createAgentWorktree: owner (member/agent) and task required');
520
+ // Pin the caller's flight base, or resolve it once for this new flight.
521
+ const mainline = baseOverride || defaultMainlineBase(root);
468
522
  // Every new flight pays down completed checkout debt before adding another
469
523
  // full repo copy. Branches and commits survive worktree removal, while real
470
524
  // uncommitted changes remain protected by cleanupWorktrees.
471
525
  let reapedBeforeStart = [];
472
526
  let cachePrunedBeforeStart = [];
473
527
  try {
474
- const cleaned = cleanupWorktrees({ root, base: defaultMainlineBase(root), apply: true });
528
+ const cleaned = cleanupWorktrees({ root, base: mainline, apply: true, by: 'atris worktree start' });
475
529
  reapedBeforeStart = cleaned.removed;
476
530
  cachePrunedBeforeStart = cleaned.cachePruned;
477
531
  } catch {}
478
532
  const branch = branchOverride || branchName(owner, task, now);
479
533
  const target = path.resolve(pathOverride || defaultWorktreePath(root, owner, task, now));
480
- const explicitBase = Boolean(baseOverride);
481
534
  // Agent/member worktrees always cut from the mainline: honoring the
482
535
  // launcher checkout's upstream let a dispatch inherit another session's
483
536
  // in-progress feature branch (2026-07-09: an auth fix arrived carrying 13
484
537
  // unpushed commits from a parallel session and could not merge).
485
- const checkoutBase = normalizeTargetRef(root, baseOverride || defaultMainlineBase(root));
486
- const shipBase = explicitBase ? checkoutBase : normalizeTargetRef(root, 'origin/master');
538
+ const checkoutBase = normalizeTargetRef(root, mainline);
539
+ const shipBase = checkoutBase;
487
540
  if (fs.existsSync(target)) throw new Error(`worktree path already exists: ${target}`);
488
541
  fs.mkdirSync(path.dirname(target), { recursive: true });
489
- refreshRemoteRef(root, checkoutBase);
490
- runGit(['worktree', 'add', '-b', branch, target, checkoutBase], { cwd: root });
491
- runGit(['config', `branch.${branch}.atris-base`, shipBase], { cwd: target, check: false });
492
- runGit(['config', `branch.${branch}.atris-owner`, owner], { cwd: target, check: false });
493
- runGit(['config', `branch.${branch}.atris-task`, task], { cwd: target, check: false });
494
- fs.mkdirSync(path.join(target, '.atris'), { recursive: true });
495
- fs.writeFileSync(
496
- path.join(target, '.atris', 'agent-worktree.json'),
497
- JSON.stringify({
498
- agent: agent || null,
499
- member: member || null,
500
- owner,
501
- task,
502
- branch,
503
- base: shipBase,
504
- checkout_base: checkoutBase,
505
- workspace_root: root,
506
- created_at: now.toISOString(),
507
- }, null, 2) + '\n',
508
- 'utf8'
509
- );
542
+ const staleBase = refreshRemoteRef(root, checkoutBase);
543
+ const ref = `refs/heads/${branch}`;
544
+ const existed = runGit(['show-ref', '--verify', '--quiet', ref], { cwd: root, check: false }).status !== 1;
545
+ const baseCommit = runGit(['rev-parse', '--verify', `${checkoutBase}^{commit}`], { cwd: root, check: false });
546
+ const startCommit = baseCommit.status === 0 ? baseCommit.stdout.trim() : '';
547
+ // With no local copy the start fails on its own; only a real cut gets the warning.
548
+ if (staleBase && startCommit) {
549
+ console.error(`warning: could not refresh ${checkoutBase} (${staleBase.split('\n')[0]}); the new worktree starts from the local copy, which may be behind`);
550
+ }
551
+ const discardFailedStart = () => {
552
+ if (existed || !startCommit) return;
553
+ const registered = listWorktrees(root).find((wt) => wt.branch === branch);
554
+ if (registered) {
555
+ if (!fs.existsSync(target) || fs.realpathSync(registered.path) !== fs.realpathSync(target)
556
+ || registered.head !== startCommit) return;
557
+ const dirt = statusCounts(target, { ignoreUntracked: isConductorArtifact });
558
+ if (!dirt || dirt.staged || dirt.unstaged || dirt.untracked) return;
559
+ // No engine has started. Normal removal still refuses unexpected dirt.
560
+ runGit(['worktree', 'remove', target], { cwd: root });
561
+ }
562
+ if (!fs.existsSync(target)) runGit(['update-ref', '-d', ref, startCommit], { cwd: root });
563
+ };
564
+ for (let attempt = 0; attempt < 3; attempt += 1) {
565
+ const added = runGit(['worktree', 'add', '-b', branch, target, checkoutBase], { cwd: root, check: false });
566
+ if (added.status === 0) break;
567
+ // Git can create the branch before failing to write its tracking config.
568
+ // Drop only that untouched ref, never an existing branch or a checkout.
569
+ if (!/already exists|already used by worktree/i.test(added.stderr || '')) discardFailedStart();
570
+ if (!configLocked(added) || attempt === 2) {
571
+ throw new Error(String(added.stderr || added.stdout || 'worktree start failed').trim());
572
+ }
573
+ sleepSync(100 * (attempt + 1));
574
+ }
575
+ try {
576
+ runGit(['config', `branch.${branch}.atris-base`, shipBase], { cwd: target });
577
+ runGit(['config', `branch.${branch}.atris-owner`, owner], { cwd: target });
578
+ runGit(['config', `branch.${branch}.atris-task`, task], { cwd: target });
579
+ fs.mkdirSync(path.join(target, '.atris'), { recursive: true });
580
+ fs.writeFileSync(
581
+ path.join(target, '.atris', 'agent-worktree.json'),
582
+ JSON.stringify({
583
+ agent: agent || null,
584
+ member: member || null,
585
+ owner,
586
+ task,
587
+ branch,
588
+ base: shipBase,
589
+ checkout_base: checkoutBase,
590
+ workspace_root: root,
591
+ created_at: now.toISOString(),
592
+ }, null, 2) + '\n',
593
+ 'utf8'
594
+ );
595
+ } catch (error) {
596
+ discardFailedStart();
597
+ throw error;
598
+ }
510
599
  return { path: target, branch, base: shipBase, checkoutBase, owner, reapedBeforeStart, cachePrunedBeforeStart };
511
600
  }
512
601
 
@@ -903,7 +992,7 @@ function shipWorktree(args) {
903
992
  throw new Error((switched.stderr || switched.stdout || `git switch ${targetBranch} failed`).trim());
904
993
  }
905
994
  }
906
- const primaryCounts = statusCounts(primary) || { staged: 0, unstaged: 0, untracked: 0 };
995
+ const primaryCounts = statusCounts(primary, { ignoreUntracked: isConductorArtifact }) || { staged: 0, unstaged: 0, untracked: 0 };
907
996
  if (primaryCounts.staged || primaryCounts.unstaged || primaryCounts.untracked) {
908
997
  throw new Error(
909
998
  `primary checkout dirty staged=${primaryCounts.staged} ` +
@@ -1058,6 +1147,20 @@ function worktreeHasActiveProcess(worktreePath, activeCwds) {
1058
1147
  });
1059
1148
  }
1060
1149
 
1150
+ // Every work-folder removal leaves one line naming who removed it and why, in
1151
+ // the main checkout's state so the line outlives the folder it describes.
1152
+ function recordWorktreeRemoval(root, { path: removedPath, branch = '', by, reason }) {
1153
+ try {
1154
+ const common = runGit(['rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd: root, check: false });
1155
+ const main = common.status === 0 ? path.dirname(common.stdout.trim()) : root;
1156
+ const file = path.join(main, '.atris', 'state', 'worktree_removals.jsonl');
1157
+ fs.mkdirSync(path.dirname(file), { recursive: true });
1158
+ fs.appendFileSync(file, `${JSON.stringify({
1159
+ at: new Date().toISOString(), path: removedPath, branch: branch || null, by, reason, pid: process.pid,
1160
+ })}\n`);
1161
+ } catch {}
1162
+ }
1163
+
1061
1164
  function pruneGeneratedWorktreeCaches(worktreePath, { apply = false } = {}) {
1062
1165
  const candidates = [];
1063
1166
  const pruned = [];
@@ -1079,7 +1182,7 @@ function pruneGeneratedWorktreeCaches(worktreePath, { apply = false } = {}) {
1079
1182
  return { candidates, pruned };
1080
1183
  }
1081
1184
 
1082
- function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply = false, activeCwds: activeCwdsOverride } = {}) {
1185
+ function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply = false, activeCwds: activeCwdsOverride, by = 'atris worktree cleanup' } = {}) {
1083
1186
  const worktrees = listWorktrees(root);
1084
1187
  const primary = worktrees[0]?.path ? path.resolve(worktrees[0].path) : '';
1085
1188
  const current = path.resolve(root);
@@ -1167,6 +1270,7 @@ function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply =
1167
1270
  const removedResult = runGit(removeArgs, { cwd: root, check: false });
1168
1271
  if (removedResult.status === 0) {
1169
1272
  removed.push(candidate);
1273
+ recordWorktreeRemoval(root, { path: wt.path, branch: wt.branch, by, reason: candidate.reason });
1170
1274
  } else {
1171
1275
  kept.push({ ...item, reason: 'remove_failed', error: (removedResult.stderr || removedResult.stdout || '').trim() });
1172
1276
  }
@@ -1185,6 +1289,7 @@ function cleanupWorktrees({ root = repoRoot(), base: baseOverride = '', apply =
1185
1289
  const removedResult = runGit(removeArgs, { cwd: root, check: false });
1186
1290
  if (removedResult.status === 0) {
1187
1291
  removed.push(candidate);
1292
+ recordWorktreeRemoval(root, { path: wt.path, branch: wt.branch, by, reason: candidate.reason });
1188
1293
  } else {
1189
1294
  kept.push({ ...item, reason: 'remove_failed', error: (removedResult.stderr || removedResult.stdout || '').trim() });
1190
1295
  }
@@ -1302,12 +1407,16 @@ module.exports = {
1302
1407
  cleanupWorktrees,
1303
1408
  defaultMainlineBase,
1304
1409
  defaultStartBase,
1410
+ gitFailureDetail,
1411
+ gitNetworkEnvironment,
1305
1412
  describeFlightAge,
1306
1413
  flightStampMs,
1414
+ listActiveProcessCwds,
1307
1415
  listWorktrees,
1308
1416
  parseAgentFlightName,
1309
1417
  parseWorktrees,
1310
1418
  normalizeTargetRef,
1419
+ recordWorktreeRemoval,
1311
1420
  refreshMovedMapRefs,
1312
1421
  slugify,
1313
1422
  taskTokens,
@@ -1315,4 +1424,5 @@ module.exports = {
1315
1424
  swarloClaim,
1316
1425
  runShipHealthCheck,
1317
1426
  worktreeCommand,
1427
+ worktreeHasActiveProcess,
1318
1428
  };
@@ -578,7 +578,8 @@ function runVerifyStep(parsed, workspaceRoot) {
578
578
  if (!gitPathCheck.ok) return gitPathCheck;
579
579
  const pythonPathCheck = validatePythonBinaryPath(parsed.argv, workspaceRoot, cwdCheck.cwd);
580
580
  if (!pythonPathCheck.ok) return pythonPathCheck;
581
- const result = spawnSync(parsed.argv[0], parsed.argv.slice(1), {
581
+ const bin = parsed.argv[0] === 'node' ? process.execPath : parsed.argv[0];
582
+ const result = spawnSync(bin, parsed.argv.slice(1), {
582
583
  cwd: cwdCheck.cwd,
583
584
  env: verifyCommandEnv(parsed.env),
584
585
  shell: false,
@@ -620,11 +621,10 @@ function runVerifyStep(parsed, workspaceRoot) {
620
621
  function verifyCommandEnv(extraEnv) {
621
622
  const base = extraEnv ? { ...process.env, ...extraEnv } : { ...process.env };
622
623
  const binDir = path.dirname(process.execPath);
623
- const current = String(base.PATH || '');
624
- const alreadyPresent = current.split(path.delimiter).includes(binDir);
625
- if (binDir && !alreadyPresent) {
626
- base.PATH = current ? `${binDir}${path.delimiter}${current}` : binDir;
627
- }
624
+ // Caller PATH order wins (a test may put a fake binary first); the node bin
625
+ // dir and Homebrew only fill in at the end when missing.
626
+ const currentParts = String(base.PATH || '').split(path.delimiter).filter(Boolean);
627
+ base.PATH = [...new Set([...currentParts, binDir, '/opt/homebrew/bin'].filter(Boolean))].join(path.delimiter);
628
628
  base[VERIFY_IN_PROGRESS_ENV] = '1';
629
629
  // A verifier that inherits a test-runner context joins the outer runner as
630
630
  // a child and reports instant exit 0 without running anything. Strip it so
@@ -1409,4 +1409,5 @@ module.exports = {
1409
1409
  repoHygieneGate,
1410
1410
  runVerifyCommand,
1411
1411
  runVerifyCommandCached,
1412
+ verifyCommandEnv,
1412
1413
  };
@@ -4,6 +4,7 @@ const fs = require('fs');
4
4
  const os = require('os');
5
5
  const path = require('path');
6
6
  const { spawn, spawnSync } = require('child_process');
7
+ const { defaultMainlineBase } = require('../commands/worktree');
7
8
  const { SHARED_BRIEF_RULES } = require('./brief-rules');
8
9
 
9
10
  // Worktree flights need full file access to edit and ship the requested repo.
@@ -78,15 +79,10 @@ function renderCommand(argv) {
78
79
  function runWorktreeAdd(repoPath, branch, worktreePath) {
79
80
  fs.mkdirSync(path.dirname(worktreePath), { recursive: true });
80
81
  const baseArgs = ['-C', repoPath, 'worktree', 'add', '-b', branch, worktreePath];
81
- const master = spawnSync('git', [...baseArgs, 'origin/master'], { encoding: 'utf8' });
82
- if (master.status === 0) return { baseRef: 'origin/master' };
83
- const main = spawnSync('git', [...baseArgs, 'origin/main'], { encoding: 'utf8' });
84
- if (main.status === 0) return { baseRef: 'origin/main' };
85
- const detail = [
86
- `origin/master: ${(master.stderr || master.stdout || '').trim()}`,
87
- `origin/main: ${(main.stderr || main.stdout || '').trim()}`,
88
- ].join('\n');
89
- throw new Error(`git worktree add failed for ${worktreePath}\n${detail}`);
82
+ const baseRef = defaultMainlineBase(repoPath);
83
+ const result = spawnSync('git', [...baseArgs, baseRef], { encoding: 'utf8' });
84
+ if (result.status === 0) return { baseRef };
85
+ throw new Error(`git worktree add failed for ${worktreePath}\n${baseRef}: ${(result.stderr || result.stdout || '').trim()}`);
90
86
  }
91
87
 
92
88
  function parseTaskId(stdout) {
@@ -152,12 +148,10 @@ async function dispatchCodexFlight({ repoPath, slug, brief, verifyCmd, dryRun =
152
148
  const branch = `codex/${flightSlug}-${flightStamp}`;
153
149
  const worktreePath = path.join(repo.arenaRoot, '.agent-worktrees', repo.repoName, `codex-${flightSlug}-${flightStamp}`);
154
150
  const prompt = buildPrompt({ worktreePath, branch, brief, verifyCmd });
155
- const worktreeCommand = ['git', '-C', repo.repoPath, 'worktree', 'add', '-b', branch, worktreePath, 'origin/master'];
156
- const fallbackCommand = ['git', '-C', repo.repoPath, 'worktree', 'add', '-b', branch, worktreePath, 'origin/main'];
151
+ const worktreeCommand = ['git', '-C', repo.repoPath, 'worktree', 'add', '-b', branch, worktreePath, defaultMainlineBase(repo.repoPath)];
157
152
 
158
153
  if (dryRun) {
159
154
  console.log(`Worktree command: ${renderCommand(worktreeCommand)}`);
160
- console.log(`Fallback command: ${renderCommand(fallbackCommand)}`);
161
155
  console.log(`Codex cwd: ${worktreePath}`);
162
156
  console.log('Prompt:');
163
157
  console.log(prompt);
@@ -8,10 +8,12 @@
8
8
  const CONDUCTOR_UNTRACKED_PATTERN =
9
9
  /^\.atris\/(?:agent-worktree\.json|fleet-prompt-[^/]+\.md|codex-watchdog\.js|codex-watchdog-[^/]+\.json|runtime-tmp(?:\/.*)?|state\/briefs\.jsonl)$/;
10
10
  const CONDUCTOR_ROOT_OUTPUT_PATTERN = /^\.codex-last-message\.txt$/;
11
+ const CONDUCTOR_RUN_OUTPUT_PATTERN = /^atris\/runs\/[^/]+\.(?:json|live\.log)$/;
11
12
 
12
13
  function isConductorArtifact(file) {
13
14
  const value = String(file || '');
14
- return CONDUCTOR_UNTRACKED_PATTERN.test(value) || CONDUCTOR_ROOT_OUTPUT_PATTERN.test(value);
15
+ return CONDUCTOR_UNTRACKED_PATTERN.test(value) || CONDUCTOR_ROOT_OUTPUT_PATTERN.test(value)
16
+ || CONDUCTOR_RUN_OUTPUT_PATTERN.test(value);
15
17
  }
16
18
 
17
19
  // Accepts a `git status --porcelain` line ("?? .atris/fleet-prompt-BCK-1.md").
@@ -19,27 +19,62 @@ function hasFlag(args, name) {
19
19
  return list.some((token) => String(token) === name || String(token).startsWith(`${name}=`));
20
20
  }
21
21
 
22
+ // Codex exports CODEX_THREAD_ID (and CODEX_SESSION_ID on some builds) into
23
+ // every shell command its agent runs, so a CLI started by a codex session can
24
+ // see it. ATRIS_CALLER_RUNTIME names the caller outright and wins over the
25
+ // sniffing: codex, agent (claude code, cursor, devin), or terminal.
26
+ const CODEX_SESSION_ENV = Object.freeze(['CODEX_THREAD_ID', 'CODEX_SESSION_ID']);
27
+ const AGENT_SESSION_ENV = Object.freeze(['CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT', 'CURSOR_AGENT', 'DEVIN_SESSION_ID']);
28
+
29
+ function envSet(env, name) {
30
+ return String((env && env[name]) || '').trim() !== '';
31
+ }
32
+
33
+ function declaredCallerRuntime(env = process.env) {
34
+ const named = String((env && env.ATRIS_CALLER_RUNTIME) || '').trim().toLowerCase();
35
+ return ['codex', 'agent', 'terminal'].includes(named) ? named : '';
36
+ }
37
+
38
+ function codexSessionFromEnv(env = process.env) {
39
+ const named = declaredCallerRuntime(env);
40
+ if (named) return named === 'codex';
41
+ return CODEX_SESSION_ENV.some((name) => envSet(env, name));
42
+ }
43
+
22
44
  // A live codex session drives a run by passing its native goal slot state
23
- // (--native-goal-* / --visible-goal-*) or by explicitly claiming the slot
24
- // (--take-goal-slot). Absent those, nothing is on the other end.
25
- function runHasLiveCodexSession(args) {
45
+ // (--native-goal-* / --visible-goal-*), by explicitly claiming the slot
46
+ // (--take-goal-slot), or by being the process around this CLI (its session
47
+ // env). Absent those, nothing is on the other end.
48
+ function runHasLiveCodexSession(args, env = process.env) {
26
49
  return Boolean(
27
50
  flagValue(args, '--native-goal-status')
28
51
  || flagValue(args, '--visible-goal-status')
29
52
  || flagValue(args, '--native-goal-objective')
30
53
  || flagValue(args, '--visible-goal-objective')
31
- || hasFlag(args, '--take-goal-slot'),
54
+ || hasFlag(args, '--take-goal-slot')
55
+ || codexSessionFromEnv(env),
32
56
  );
33
57
  }
34
58
 
59
+ // Who started this CLI: 'codex' (a codex session that can start a native
60
+ // goal), 'agent' (another agent session that does the work itself), or
61
+ // 'terminal' (a plain shell, cron, or launchd, where nothing else will do the
62
+ // work unless Atris starts an engine).
63
+ function callerRuntime(args = [], env = process.env) {
64
+ if (runHasLiveCodexSession(args, env)) return 'codex';
65
+ const named = declaredCallerRuntime(env);
66
+ if (named) return named;
67
+ return AGENT_SESSION_ENV.some((name) => envSet(env, name)) ? 'agent' : 'terminal';
68
+ }
69
+
35
70
  // Default runner for the run-objective path (`atris mission run "<objective>"`,
36
71
  // `member run`). codex_goal hands the mission to a live codex session's native
37
72
  // goal slot; with no such session (cron, fleet, a plain shell) it stalls forever
38
73
  // waiting for a native goal start that never comes (proven footgun 2026-07-16).
39
74
  // So codex_goal only when a live codex session is driving; otherwise claude,
40
75
  // which drives itself headless. An explicit --runner always wins over this.
41
- function defaultObjectiveRunner(args) {
42
- return runHasLiveCodexSession(args) ? 'codex_goal' : 'claude';
76
+ function defaultObjectiveRunner(args, env = process.env) {
77
+ return runHasLiveCodexSession(args, env) ? 'codex_goal' : 'claude';
43
78
  }
44
79
 
45
- module.exports = { runHasLiveCodexSession, defaultObjectiveRunner };
80
+ module.exports = { callerRuntime, runHasLiveCodexSession, defaultObjectiveRunner };
package/lib/engine-ask.js CHANGED
@@ -11,6 +11,7 @@ const {
11
11
  } = require('./runner-command');
12
12
  const {
13
13
  canonicalEngineName,
14
+ claudeModelId,
14
15
  currentClaudeModel,
15
16
  engineFailureHealthStatus,
16
17
  setEngineHealth,
@@ -242,7 +243,13 @@ function assertAskModelSupported(engine, model) {
242
243
 
243
244
  function resolveAskModel(engine, modelName = '') {
244
245
  const requested = String(modelName || '').trim();
245
- if (requested) return currentClaudeModel(requested);
246
+ if (requested) {
247
+ // A display name ("opus 5.5") becomes an id only for the claude cli; every
248
+ // other engine gets its model name as typed.
249
+ return engine === 'claude' || engine === 'fable' || engine === 'haiku'
250
+ ? claudeModelId(requested)
251
+ : currentClaudeModel(requested);
252
+ }
246
253
  if (engine !== 'claude' && engine !== 'fable' && engine !== 'haiku') return '';
247
254
  return RUNNER_PROFILE_DEFS[engine]?.model || DEFAULT_CLAUDE_RUNNER_MODEL;
248
255
  }
@@ -592,6 +592,7 @@ const CLAUDE_MODEL_ALIASES = Object.freeze(['opus', 'sonnet', 'haiku', 'fable',
592
592
  // A retired model runs as its replacement, so a saved pick keeps working
593
593
  // after the upgrade. Retiring the next one is one line here.
594
594
  const RETIRED_CLAUDE_MODELS = Object.freeze({
595
+ 'claude-fable-5': 'claude-fable-5-1',
595
596
  'claude-haiku-4-5': 'claude-haiku-5-5',
596
597
  'claude-haiku-4-5-20251001': 'claude-haiku-5-5',
597
598
  });
@@ -619,6 +620,21 @@ function currentClaudeModel(value) {
619
620
  return next ? `${next}${suffix}` : raw;
620
621
  }
621
622
 
623
+ // The id the claude cli accepts for a display name typed with a space:
624
+ // "opus 5.5" is claude-opus-5-5, "fable 5.1" is claude-fable-5-1. A full id,
625
+ // a bare alias ("opus"), a "[1m]" tail and anything else pass through as given
626
+ // (a retired id still becomes its replacement).
627
+ const CLAUDE_DISPLAY_NAME = /^(opus|sonnet|haiku|fable)\s+(\d+)(?:\.(\d+))?$/i;
628
+
629
+ function claudeModelId(value) {
630
+ const raw = String(value || '').trim();
631
+ const suffix = CLAUDE_LONG_CONTEXT.test(raw) ? '[1m]' : '';
632
+ const base = raw.replace(CLAUDE_LONG_CONTEXT, '').trim();
633
+ const match = CLAUDE_DISPLAY_NAME.exec(base);
634
+ if (!match) return currentClaudeModel(raw);
635
+ return currentClaudeModel(`claude-${match[1].toLowerCase()}-${match[2]}${match[3] ? `-${match[3]}` : ''}${suffix}`);
636
+ }
637
+
622
638
  // The retired models a roster file's text still names, by id or spelled
623
639
  // out, each once, so the roster can say they were upgraded.
624
640
  function retiredModelsNamed(text) {
@@ -2386,6 +2402,7 @@ module.exports = {
2386
2402
  confirmRoster,
2387
2403
  normalizeRosterModel,
2388
2404
  currentClaudeModel,
2405
+ claudeModelId,
2389
2406
  retiredModelsNamed,
2390
2407
  RETIRED_CLAUDE_MODELS,
2391
2408
  isClaudeFamilyModel,
@@ -47,10 +47,16 @@ function probeVerifierCanFail({ command, runner, tmpRoot } = {}) {
47
47
  const dir = fs.mkdtempSync(path.join(tmpRoot || os.tmpdir(), 'atris-falsifier-probe-'));
48
48
  try {
49
49
  const run = runner || spawnSync;
50
+ // A probe that inherits a test-runner context joins the outer runner as a
51
+ // child and exits 0 without running anything, so every check would look
52
+ // like it cannot fail. Same strip as verifyCommandEnv.
53
+ const env = { ...process.env };
54
+ delete env.NODE_TEST_CONTEXT;
50
55
  const result = run('bash', ['-lc', cmd], {
51
56
  cwd: dir,
52
57
  encoding: 'utf8',
53
58
  timeout: PROBE_TIMEOUT_MS,
59
+ env,
54
60
  });
55
61
  if (result.error) {
56
62
  const timedOut = /ETIMEDOUT/i.test(String(result.error.code || result.error.message || ''));