atris 3.37.0 → 3.38.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 (139) hide show
  1. package/README.md +2 -0
  2. package/atris/PERSONA.md +3 -7
  3. package/atris.md +10 -2
  4. package/ax +158 -33
  5. package/bin/atris.js +49 -2
  6. package/commands/activate.js +16 -3
  7. package/commands/agent-spawn.js +0 -7
  8. package/commands/autoland.js +92 -14
  9. package/commands/autopilot-front.js +0 -4
  10. package/commands/autopilot.js +0 -12
  11. package/commands/brain.js +0 -7
  12. package/commands/brief.js +0 -2
  13. package/commands/business-sync.js +0 -1
  14. package/commands/business.js +107 -12
  15. package/commands/clean.js +31 -2
  16. package/commands/close.js +46 -11
  17. package/commands/cloud.js +0 -1
  18. package/commands/codex-goal.js +0 -2
  19. package/commands/computer.js +6 -5
  20. package/commands/console.js +46 -9
  21. package/commands/deck.js +29 -8
  22. package/commands/dream.js +0 -7
  23. package/commands/drill.js +0 -1
  24. package/commands/engine.js +10 -18
  25. package/commands/experiments.js +0 -4
  26. package/commands/fleet-report.js +12 -4
  27. package/commands/game.js +261 -0
  28. package/commands/gm.js +10 -2
  29. package/commands/goal.js +0 -6
  30. package/commands/harvest.js +0 -1
  31. package/commands/improve.js +29 -8
  32. package/commands/init.js +18 -34
  33. package/commands/integrations.js +0 -7
  34. package/commands/land.js +71 -8
  35. package/commands/launchpad.js +0 -2
  36. package/commands/lesson.js +17 -7
  37. package/commands/loop-front.js +0 -2
  38. package/commands/loops.js +6 -5
  39. package/commands/meet.js +0 -2
  40. package/commands/member.js +68 -3
  41. package/commands/mission.js +359 -102
  42. package/commands/next.js +0 -1
  43. package/commands/now.js +0 -12
  44. package/commands/one-lap.js +8 -8
  45. package/commands/orb.js +0 -4
  46. package/commands/pack-craft.js +0 -1
  47. package/commands/pack.js +670 -77
  48. package/commands/play.js +0 -2
  49. package/commands/probe.js +10 -4
  50. package/commands/pulse.js +73 -48
  51. package/commands/push.js +0 -4
  52. package/commands/radar.js +14 -11
  53. package/commands/recover.js +340 -0
  54. package/commands/report.js +0 -1
  55. package/commands/router.js +319 -0
  56. package/commands/run-front.js +0 -2
  57. package/commands/search.js +0 -3
  58. package/commands/skill.js +7 -2
  59. package/commands/slop.js +225 -7
  60. package/commands/status.js +6 -0
  61. package/commands/stream.js +0 -2
  62. package/commands/study.js +0 -3
  63. package/commands/sync-checkout.js +51 -0
  64. package/commands/sync.js +11 -40
  65. package/commands/task.js +125 -39
  66. package/commands/teach.js +86 -0
  67. package/commands/unknowns.js +0 -10
  68. package/commands/update.js +0 -1
  69. package/commands/visualize.js +0 -5
  70. package/commands/watch.js +0 -6
  71. package/commands/wiki.js +41 -11
  72. package/commands/wish.js +0 -1
  73. package/commands/worktree.js +87 -14
  74. package/commands/xp.js +1 -6
  75. package/commands/youtube.js +0 -2
  76. package/lib/accept-verify-gate.js +122 -0
  77. package/lib/activity-stream.js +0 -2
  78. package/lib/auto-accept-certified.js +145 -2
  79. package/lib/autoland.js +0 -3
  80. package/lib/ax-auto-lane.js +94 -10
  81. package/lib/bench/context.js +0 -2
  82. package/lib/bench/report.js +0 -1
  83. package/lib/bench/runner.js +0 -11
  84. package/lib/brief-ledger.js +0 -6
  85. package/lib/business-simulate.js +382 -0
  86. package/lib/chat-log-scan.js +0 -5
  87. package/lib/checkout-sync.js +52 -0
  88. package/lib/claude-boot-block.js +48 -0
  89. package/lib/cloud-mission.js +0 -6
  90. package/lib/codex-flight.js +0 -1
  91. package/lib/conductor-artifacts.js +25 -0
  92. package/lib/context-gatherer.js +0 -1
  93. package/lib/deck-compose.js +0 -1
  94. package/lib/deck-history.js +0 -1
  95. package/lib/deck-layout.js +0 -5
  96. package/lib/deck-review.js +0 -7
  97. package/lib/deck-schema.js +0 -1
  98. package/lib/default-verifier.js +0 -2
  99. package/lib/engine-registry.js +16 -8
  100. package/lib/experiments/daily.js +0 -6
  101. package/lib/fleet.js +210 -27
  102. package/lib/functional-owner.js +0 -3
  103. package/lib/inspect-fields.js +0 -1
  104. package/lib/known-commands.js +2 -2
  105. package/lib/learnings.js +0 -2
  106. package/lib/loop-doctor.js +11 -17
  107. package/lib/manifest.js +0 -2
  108. package/lib/member-alive.js +0 -1
  109. package/lib/member-switches.js +0 -5
  110. package/lib/mission-artifact.js +0 -1
  111. package/lib/mission-protected-lane.js +293 -0
  112. package/lib/mission-room.js +0 -2
  113. package/lib/mission-runtime-loop.js +0 -1
  114. package/lib/next-moves.js +0 -18
  115. package/lib/official-cli-integration.js +0 -4
  116. package/lib/orb-context.js +6 -3
  117. package/lib/orb-scorecard.js +0 -1
  118. package/lib/outbound-send-gate.js +0 -6
  119. package/lib/permission-grants.js +0 -6
  120. package/lib/policy-lessons.js +0 -2
  121. package/lib/pulse.js +68 -15
  122. package/lib/review-integrity.js +0 -2
  123. package/lib/router-brain.js +0 -2
  124. package/lib/runner-command.js +0 -4
  125. package/lib/runtime-bootstrap.js +0 -1
  126. package/lib/scorecard.js +0 -1
  127. package/lib/self-drive.js +1 -1
  128. package/lib/state-detection.js +0 -6
  129. package/lib/task-db.js +0 -4
  130. package/lib/team-presence.js +0 -1
  131. package/lib/voice-gate.js +63 -2
  132. package/lib/wiki.js +152 -9
  133. package/lib/wish-audit.js +20 -6
  134. package/lib/wish-delegate.js +26 -20
  135. package/lib/wish-stats.js +0 -5
  136. package/lib/wish-store.js +0 -1
  137. package/lib/workspace-safety.js +0 -2
  138. package/lib/zip.js +0 -1
  139. package/package.json +1 -1
@@ -0,0 +1,48 @@
1
+ 'use strict';
2
+
3
+ const ATRIS_START_MARKER = '<!-- ATRIS:START - Auto-generated, do not edit -->';
4
+ const ATRIS_END_MARKER = '<!-- ATRIS:END -->';
5
+
6
+ function renderAtrisClaudeBootBlock() {
7
+ return `${ATRIS_START_MARKER}
8
+ ## Atris System
9
+
10
+ You are now running inside Atris, an AI workspace operating system.
11
+
12
+ **ON EVERY CONVERSATION START:** If a SessionStart hook already displayed the Atris status block in this session, do not run it again. Otherwise, before your first response, execute \`atris atris.md\` and display the full output. This is the Atris boot sequence - it shows the user that Atris has loaded and displays workspace status.
13
+
14
+ After displaying the boot output, respond to the user naturally.
15
+ ${ATRIS_END_MARKER}
16
+ `;
17
+ }
18
+
19
+ function upsertAtrisClaudeBootBlock(source = '') {
20
+ const atrisBlock = renderAtrisClaudeBootBlock();
21
+ const startIdx = source.indexOf('<!-- ATRIS:START');
22
+ const prependBlock = (preserved) => {
23
+ const remainder = preserved.replace(/^\n+/, '');
24
+ return remainder ? `${atrisBlock}\n${remainder}` : atrisBlock;
25
+ };
26
+
27
+ if (startIdx === -1) {
28
+ return { content: prependBlock(source), action: source ? 'prepended' : 'created' };
29
+ }
30
+
31
+ const prefix = source.slice(0, startIdx);
32
+ const endRaw = source.indexOf(ATRIS_END_MARKER, startIdx);
33
+ if (endRaw === -1) {
34
+ return {
35
+ content: prependBlock(prefix),
36
+ action: 'repaired',
37
+ };
38
+ }
39
+
40
+ const endIdx = endRaw + ATRIS_END_MARKER.length;
41
+ const suffix = source.slice(endIdx).replace(/^\n+/, '');
42
+ const content = prependBlock(prefix + suffix);
43
+ return { content, action: content === source ? 'unchanged' : 'updated' };
44
+ }
45
+
46
+ module.exports = {
47
+ upsertAtrisClaudeBootBlock,
48
+ };
@@ -244,16 +244,10 @@ async function statusCloudMissionCommand(args, options = {}) {
244
244
 
245
245
  module.exports = {
246
246
  VALID_CLOUD_LANES,
247
- TERMINAL_CLOUD_STATUSES,
248
247
  RECEIPT_VOICE_CONTRACT,
249
248
  withReceiptVoice,
250
249
  parseCloudRunArgs,
251
- parseCloudStatusArgs,
252
250
  enqueueCloudMission,
253
- fetchCloudMissionStatus,
254
- appendCloudMissionReceipt,
255
- cloudMissionSummary,
256
- cloudStatusLines,
257
251
  runCloudMissionCommand,
258
252
  statusCloudMissionCommand,
259
253
  };
@@ -150,5 +150,4 @@ async function dispatchCodexFlight({ repoPath, slug, brief, verifyCmd, dryRun =
150
150
  module.exports = {
151
151
  dispatchCodexFlight,
152
152
  buildPrompt,
153
- parseTaskId,
154
153
  };
@@ -0,0 +1,25 @@
1
+ 'use strict';
2
+
3
+ // Files the conductor itself writes into an agent worktree before the engine runs.
4
+ // They are harness plumbing, not work product, so no dirty-checkout check may
5
+ // count them as dirt. Both the worktree guard and the sealed-review import read
6
+ // this list; keeping it in one place is what stops an engine from being blocked
7
+ // by its own prompt file.
8
+ const CONDUCTOR_UNTRACKED_PATTERN =
9
+ /^\.atris\/(?:agent-worktree\.json|fleet-prompt-[^/]+\.md|runtime-tmp(?:\/.*)?|state\/briefs\.jsonl)$/;
10
+
11
+ function isConductorArtifact(file) {
12
+ return CONDUCTOR_UNTRACKED_PATTERN.test(String(file || ''));
13
+ }
14
+
15
+ // Accepts a `git status --porcelain` line ("?? .atris/fleet-prompt-BCK-1.md").
16
+ function isConductorStatusLine(line) {
17
+ const text = String(line || '');
18
+ if (!text.startsWith('?? ')) return false;
19
+ return isConductorArtifact(text.slice(3));
20
+ }
21
+
22
+ module.exports = {
23
+ isConductorArtifact,
24
+ isConductorStatusLine,
25
+ };
@@ -173,6 +173,5 @@ module.exports = {
173
173
  renderPrompt,
174
174
  starterTaskTitle,
175
175
  inferDomain,
176
- normalizeQuestionText,
177
176
  isAtrisMetaQuestion,
178
177
  };
@@ -137,7 +137,6 @@ function composeSpec(text, opts = {}) {
137
137
 
138
138
  module.exports = {
139
139
  parseBlocks,
140
- blocksToSections,
141
140
  classify,
142
141
  toItem,
143
142
  splitQuote,
@@ -56,7 +56,6 @@ function historyFor(query, historyPath = DEFAULT_HISTORY_PATH) {
56
56
  }
57
57
 
58
58
  module.exports = {
59
- DEFAULT_HISTORY_PATH,
60
59
  specHash,
61
60
  recordBuild,
62
61
  readHistory,
@@ -159,11 +159,6 @@ function planLayout(sections, opts = {}) {
159
159
  }
160
160
 
161
161
  module.exports = {
162
- BOXED,
163
- ONCE,
164
- BOXED_BUDGET,
165
- DOWNGRADE,
166
162
  preferredType,
167
- mapToSlide,
168
163
  planLayout,
169
164
  };
@@ -414,18 +414,11 @@ function confirmReview(presentationId, note = 'visual review passed', outRoot =
414
414
 
415
415
  module.exports = {
416
416
  SCHEMA,
417
- RECEIPT_SCHEMA,
418
417
  DEFAULT_OUT_ROOT,
419
- DEFAULT_RECEIPTS_PATH,
420
- AGENT_CHECKLIST,
421
418
  CLIP_LIMITS,
422
- buildReceipt,
423
419
  extractPresentationId,
424
- summarizeSlide,
425
420
  lintSpec,
426
- downloadUrl,
427
421
  autoReviewSlides,
428
422
  reviewDeck,
429
- loadReviewManifest,
430
423
  confirmReview,
431
424
  };
@@ -143,7 +143,6 @@ function checkSpec(spec, lintSpec) {
143
143
  }
144
144
 
145
145
  module.exports = {
146
- KNOWN_THEMES,
147
146
  SLIDE_SCHEMA,
148
147
  validateSpec,
149
148
  sortFindings,
@@ -62,8 +62,6 @@ function verifierBudgetWarning(command, env = process.env) {
62
62
  }
63
63
 
64
64
  module.exports = {
65
- DEFAULT_MISSION_VERIFIER_TIMEOUT_MS,
66
- broadSuiteVerifier,
67
65
  missionVerifierTimeoutMs,
68
66
  resolveDefaultVerifier,
69
67
  verifierBudgetWarning,
@@ -144,7 +144,7 @@ function resolveRegisteredEngine(name, root = process.cwd()) {
144
144
  return engine;
145
145
  }
146
146
 
147
- function resolveEngineForRole(role, root = process.cwd(), options = {}) {
147
+ function resolveEngineForRoleRanked(role, root = process.cwd(), options = {}) {
148
148
  const normalizedRole = String(role || '').trim().toLowerCase();
149
149
  if (!ENGINE_ROLES.includes(normalizedRole)) {
150
150
  throw new Error(`Unknown role "${role}". Known roles: ${ENGINE_ROLES.join(', ')}`);
@@ -160,11 +160,21 @@ function resolveEngineForRole(role, root = process.cwd(), options = {}) {
160
160
  root,
161
161
  taskType: options.taskType || options.task_type || normalizedRole,
162
162
  });
163
- const selected = ranked.candidates[0] || null;
164
- if (selected && process.env.ATRIS_ROUTER_EXPLAIN === '1') {
165
- console.error(routerPickExplanation(ranked));
163
+ return {
164
+ engine: ranked.candidates[0] || null,
165
+ reason: routerPickExplanation(ranked),
166
+ ranked: ranked.candidates,
167
+ };
168
+ }
169
+
170
+ function resolveEngineForRole(role, root = process.cwd(), options = {}) {
171
+ const picked = resolveEngineForRoleRanked(role, root, options);
172
+ // every pick logs one plain sentence saying which engine won and why;
173
+ // set ATRIS_ROUTER_EXPLAIN=0 to silence it in quiet contexts.
174
+ if (picked.engine && picked.reason && process.env.ATRIS_ROUTER_EXPLAIN !== '0') {
175
+ console.error(picked.reason);
166
176
  }
167
- return selected;
177
+ return picked.engine;
168
178
  }
169
179
 
170
180
  function resolveEngineForRoleWithPreference(role, root = process.cwd(), preferredEngineId = '', options = {}) {
@@ -215,8 +225,6 @@ function setEngineHealth(name, status, root = process.cwd()) {
215
225
  }
216
226
 
217
227
  module.exports = {
218
- ENGINE_REGISTRY_SCHEMA,
219
- ENGINE_TIERS,
220
228
  ENGINE_ROLES,
221
229
  ENGINE_HEALTH_STATUSES,
222
230
  engineRegistryFile,
@@ -224,8 +232,8 @@ module.exports = {
224
232
  canonicalEngineName,
225
233
  readEngineRegistry,
226
234
  engineRegistryView,
227
- registeredEngineIds,
228
235
  resolveRegisteredEngine,
236
+ resolveEngineForRoleRanked,
229
237
  resolveEngineForRole,
230
238
  resolveEngineForRoleWithPreference,
231
239
  setEngineHealth,
@@ -618,21 +618,15 @@ function queueList(root, args = []) {
618
618
 
619
619
  module.exports = {
620
620
  SCORECARD_SCHEMA,
621
- BENCH_SCHEMA,
622
621
  appendQueueEntry,
623
- appendResultsTsv,
624
622
  appendScorecardRow,
625
- areTargetsClean,
626
623
  buildScorecardRow,
627
624
  evaluateKeepRule,
628
- parseBenchResult,
629
625
  parseDailyArgs,
630
- parseQueueAddArgs,
631
626
  pickQueueEntry,
632
627
  queueAdd,
633
628
  queueList,
634
629
  readDailyState,
635
- readQueue,
636
630
  runApplyScript,
637
631
  runBench,
638
632
  runDaily,
package/lib/fleet.js CHANGED
@@ -30,10 +30,11 @@ const {
30
30
  parseOneLapValidatorVerdict,
31
31
  } = require('./one-lap-validator');
32
32
  const { listWorktrees } = require('../commands/worktree');
33
+ const { isConductorStatusLine } = require('./conductor-artifacts');
33
34
 
34
35
  // Lanes a fleet may never staff on its own: the human keeps irreversible
35
36
  // calls. Mirrors the autoland denied lanes.
36
- const DENIED_TAGS = ['billing', 'deploy', 'security', 'customer', 'external', 'feedback'];
37
+ const DENIED_TAGS = ['billing', 'money', 'payments', 'deploy', 'security', 'customer', 'external', 'feedback'];
37
38
 
38
39
  // ---------------------------------------------------------------------------
39
40
  // T1 — dispatch primitive
@@ -317,6 +318,20 @@ function dispatchResultExitCode(result) {
317
318
  return 0;
318
319
  }
319
320
 
321
+ // A child killed by a signal comes back either with spawnSync's `signal` field
322
+ // set (plain spawn) or a 128+N exit status (the sealed supervisor re-exits 143
323
+ // on SIGTERM). Either way stdout/report and stderr are empty, so name the
324
+ // signal instead of letting the leg read as a silent no-op (CLI-1190: on
325
+ // WEB-455 2026-07-25 the claude engine exited 143/SIGTERM with an empty report
326
+ // and the restaff receipt looked like nothing had happened).
327
+ const SIGNAL_EXIT_NAMES = Object.freeze({ 129: 'SIGHUP', 130: 'SIGINT', 137: 'SIGKILL', 143: 'SIGTERM' });
328
+ function dispatchResultSignal(result) {
329
+ if (!result || typeof result !== 'object') return null;
330
+ if (result.signal) return String(result.signal);
331
+ const code = dispatchResultExitCode(result);
332
+ return SIGNAL_EXIT_NAMES[code] || null;
333
+ }
334
+
320
335
  function dispatchTaskId(task) {
321
336
  return task && (task.display_id || task.task_id || task.id) ? String(task.display_id || task.task_id || task.id) : '';
322
337
  }
@@ -364,6 +379,8 @@ function detectDeadEngineDispatch(result) {
364
379
  const output = dispatchResultOutput(result).toLowerCase();
365
380
  const pattern = DEAD_ENGINE_OUTPUT_PATTERNS.find((p) => output.includes(p));
366
381
  if (pattern) return { reason: 'usage_limit', pattern };
382
+ const signal = dispatchResultSignal(result);
383
+ if (signal) return { reason: 'signalled', signal, exitCode };
367
384
  return { reason: 'nonzero_exit', exitCode };
368
385
  }
369
386
 
@@ -406,12 +423,21 @@ function normalizeDispatchResult(result, engineName) {
406
423
  }
407
424
 
408
425
  function failedDispatchLeg(result, engineName) {
409
- return {
426
+ const report = String(result && result.report || '').slice(-2000);
427
+ const signal = dispatchResultSignal(result);
428
+ const leg = {
410
429
  engine: String(result && result.engine || engineName || ''),
411
430
  exitCode: dispatchResultExitCode(result),
412
431
  stderr: String(result && result.stderr || '').slice(-2000),
413
- report: String(result && result.report || '').slice(-2000),
432
+ report,
414
433
  };
434
+ if (signal) {
435
+ // Record that the predecessor was killed; a signalled leg has no report of
436
+ // its own, so name the signal in place of the empty string (CLI-1190).
437
+ leg.signal = signal;
438
+ if (!report) leg.report = `(no report: killed by ${signal})`;
439
+ }
440
+ return leg;
415
441
  }
416
442
 
417
443
  async function dispatchEntryWithRestaff({
@@ -535,6 +561,7 @@ function dispatchToEngine({ task, engine, worktreePath, root = process.cwd(), ti
535
561
  brief_id: capturedBriefId || null,
536
562
  command,
537
563
  exitCode: result.status,
564
+ signal: result.signal || null,
538
565
  report: String(result.stdout || '').slice(-8000),
539
566
  stderr: String(result.stderr || '').slice(-2000),
540
567
  };
@@ -617,10 +644,41 @@ function assignEngines(staffed, engines) {
617
644
  // Land one arrival: rebase onto latest base first; a conflict pauses the
618
645
  // landing (never auto-resolve) and reports which files collided so the
619
646
  // conductor or a human can take it. `git` is injectable for tests.
647
+ // Ship failures print the cause first (MODULE_NOT_FOUND, command name) and a
648
+ // long node stack after. Tail-only clips hide the cause; keep a head+tail.
649
+ function clipHeadTail(text, { head = 400, tail = 300 } = {}) {
650
+ const s = String(text || '');
651
+ if (s.length <= head + tail) return s;
652
+ return `${s.slice(0, head)}\n...\n${s.slice(-tail)}`;
653
+ }
654
+
620
655
  // Fleet landings always target master. Without the explicit --target, ship
621
656
  // falls back to the launcher branch's atris-base — a flight launched from a
622
657
  // feature-branch checkout would merge PRs into that branch while the receipt
623
658
  // and land board define "landed" as in-master (PRs #207/#208, 2026-07-04).
659
+ // A ship failure must name its real cause. MODULE_NOT_FOUND from the spawned
660
+ // CLI is usually transient: the fleet ships by spawning node against this
661
+ // live checkout, and a concurrent rebase/pull can make a require vanish for a
662
+ // moment. So the ship retries once on that signature, and the failure detail
663
+ // always surfaces the missing-module line from either stream instead of
664
+ // letting head/tail clipping truncate it away (cost measured live: CLI-1185
665
+ // receipts kept only a loader stack tail and the module name was lost).
666
+ function shipWithRetry(cli, args, cwd) {
667
+ let shipped = cli(args, cwd);
668
+ if (shipped.status !== 0 && /MODULE_NOT_FOUND|Cannot find module/.test(`${shipped.stderr}${shipped.stdout}`)) {
669
+ shipped = cli(args, cwd);
670
+ shipped.retried_module_not_found = true;
671
+ }
672
+ return shipped;
673
+ }
674
+
675
+ function shipFailureDetail(shipped, { head = 400, tail = 300 } = {}) {
676
+ const combined = `${shipped.stderr || ''}\n${shipped.stdout || ''}`;
677
+ const moduleLine = combined.match(/Cannot find module '[^']+'[^\n]*/);
678
+ const cause = moduleLine ? `${moduleLine[0]}${shipped.retried_module_not_found ? ' (persisted after one retry)' : ''}\n` : '';
679
+ return `${cause}${clipHeadTail(combined.trim(), { head, tail })}`;
680
+ }
681
+
624
682
  function fleetShipArgs(entry, check) {
625
683
  return [
626
684
  'worktree', 'ship',
@@ -631,19 +689,113 @@ function fleetShipArgs(entry, check) {
631
689
  ];
632
690
  }
633
691
 
692
+ // A fleet build worktree is ephemeral. If any process inside one runs
693
+ // `npm link` (which repoints the global `atris` package symlink at that
694
+ // worktree), then removing the worktree at teardown leaves the global `atris`
695
+ // binary a dangling symlink, and everything resolving `atris` through PATH —
696
+ // interactive shells, any tooling that shells out to the bare command — dies
697
+ // with 'command not found'. Measured scope, not assumed: crons that invoke
698
+ // `node <checkout>/bin/atris.js` by absolute path are UNAFFECTED; an autoland
699
+ // tick ran fine four minutes before the repair. The failure is silent and
700
+ // reads like a shell/PATH problem, not a worktree-cleanup one (found live
701
+ // 2026-07-26, CLI-1193; same shape as the cron PATH wedge that cost 20 days
702
+ // of autoland).
703
+ // Recovery was one line: repoint the global link at the real checkout. So
704
+ // teardown does exactly that on its own: if the global `atris` link points
705
+ // into one of this flight's worktrees, repoint it at the primary checkout and
706
+ // verify the CLI still runs before the flight reports success.
707
+ function defaultReadGlobalCliLink() {
708
+ const rootResult = spawnSync('npm', ['root', '-g'], { encoding: 'utf8' });
709
+ if (rootResult.status !== 0) return null;
710
+ const globalRoot = String(rootResult.stdout || '').trim();
711
+ if (!globalRoot) return null;
712
+ const pkgPath = path.join(globalRoot, 'atris');
713
+ let stat;
714
+ try {
715
+ stat = fs.lstatSync(pkgPath);
716
+ } catch {
717
+ return null; // atris is not installed globally at all
718
+ }
719
+ if (!stat.isSymbolicLink()) return { path: pkgPath, target: null }; // real install, not a link
720
+ return { path: pkgPath, target: path.resolve(path.dirname(pkgPath), fs.readlinkSync(pkgPath)) };
721
+ }
722
+
723
+ function defaultRepointGlobalCliLink(primaryCheckout) {
724
+ // `npm link` from the real checkout re-creates the global symlink pointing at it.
725
+ return spawnSync('npm', ['link'], { cwd: primaryCheckout, encoding: 'utf8' });
726
+ }
727
+
728
+ function defaultVerifyCliRuns() {
729
+ const result = spawnSync('atris', ['--version'], { encoding: 'utf8' });
730
+ return { ok: result.status === 0, status: result.status, stdout: String(result.stdout || '').trim() };
731
+ }
732
+
733
+ function guardGlobalCliLink({
734
+ root = process.cwd(),
735
+ worktreePaths = [],
736
+ readGlobalLink = defaultReadGlobalCliLink,
737
+ repointLink = defaultRepointGlobalCliLink,
738
+ verifyCli = defaultVerifyCliRuns,
739
+ } = {}) {
740
+ const link = readGlobalLink();
741
+ if (!link || !link.target) return { ok: true, changed: false, reason: 'not_linked' };
742
+ const resolvedTarget = realpathOrResolve(link.target);
743
+ const worktrees = worktreePaths.filter(Boolean).map((wt) => realpathOrResolve(wt));
744
+ // Match the raw string so a worktree already removed by ship still matches
745
+ // (its path is gone from disk but the dangling link still names it).
746
+ const insideWorktree = worktrees.some((wt) => resolvedTarget === wt || resolvedTarget.startsWith(`${wt}${path.sep}`));
747
+ if (!insideWorktree) return { ok: true, changed: false, reason: 'healthy', target: link.target };
748
+ let primaryPath = root;
749
+ try {
750
+ primaryPath = listWorktrees(root)[0]?.path || root;
751
+ } catch {
752
+ primaryPath = root;
753
+ }
754
+ const primary = realpathOrResolve(primaryPath);
755
+ const repoint = repointLink(primary);
756
+ const verify = verifyCli();
757
+ return {
758
+ ok: Boolean(verify && verify.ok),
759
+ changed: true,
760
+ reason: 'linked_into_worktree',
761
+ was: link.target,
762
+ restoredTo: primary,
763
+ repoint: { status: repoint ? repoint.status : null },
764
+ verify,
765
+ };
766
+ }
767
+
768
+ // One-line " (path, path)" suffix naming the files behind a paused landing —
769
+ // unmerged files for a rebase_conflict, uncommitted paths for a dirty_worktree.
770
+ function pausedPathsSuffix(landed) {
771
+ const paths = (landed && landed.conflicts) || (landed && landed.dirty) || [];
772
+ return paths.length ? ` (${paths.join(', ')})` : '';
773
+ }
774
+
634
775
  function landArrival({ worktreePath, git = null }) {
635
776
  const run = git || ((args) => spawnSync('git', args, { cwd: worktreePath, encoding: 'utf8' }));
636
777
  const fetch = run(['fetch', 'origin']);
637
778
  if (fetch.status !== 0) return { ok: false, stage: 'fetch', detail: String(fetch.stderr || '').trim() };
638
779
  const rebase = run(['rebase', 'origin/master']);
639
780
  if (rebase.status !== 0) {
640
- const conflicted = run(['diff', '--name-only', '--diff-filter=U']);
781
+ const conflicts = String(run(['diff', '--name-only', '--diff-filter=U']).stdout || '')
782
+ .trim().split('\n').filter(Boolean);
641
783
  run(['rebase', '--abort']);
642
- return {
643
- ok: false,
644
- stage: 'rebase_conflict',
645
- conflicts: String(conflicted.stdout || '').trim().split('\n').filter(Boolean),
646
- };
784
+ // git rebase refuses outright when the worktree has uncommitted changes:
785
+ // it stops before a single conflict can occur, so the unmerged-file list is
786
+ // empty. Reporting an empty list as rebase_conflict sends the operator
787
+ // chasing a merge problem that does not exist while intact work sits
788
+ // uncommitted in the worktree (CLI-1190; hit live on WEB-455 2026-07-25
789
+ // after a killed engine left modified/untracked files behind). An empty
790
+ // conflicts list is never a conflict: name the dirty paths instead.
791
+ if (!conflicts.length) {
792
+ const dirty = String(run(['status', '--porcelain']).stdout || '')
793
+ .split('\n')
794
+ .map((line) => line.slice(3).trim())
795
+ .filter(Boolean);
796
+ return { ok: false, stage: 'dirty_worktree', dirty };
797
+ }
798
+ return { ok: false, stage: 'rebase_conflict', conflicts };
647
799
  }
648
800
  return { ok: true, stage: 'rebased' };
649
801
  }
@@ -651,11 +803,15 @@ function landArrival({ worktreePath, git = null }) {
651
803
  module.exports = {
652
804
  DENIED_TAGS,
653
805
  DEAD_ENGINE_OUTPUT_PATTERNS,
806
+ reviewOnlyEngineEnvironment,
807
+ shipWithRetry,
808
+ shipFailureDetail,
654
809
  get FLEET_CAPABLE() { return FLEET_CAPABLE; },
655
810
  get runFleetFlight() { return runFleetFlight; },
656
811
  get focusedCheck() { return focusedCheck; },
657
812
  get dispatchCheck() { return dispatchCheck; },
658
813
  get runDispatchFlight() { return runDispatchFlight; },
814
+ get prepareReviewSandbox() { return prepareReviewSandbox; },
659
815
  YOLO_ENGINE_FLAGS,
660
816
  DISPATCH_SELF_LAND_TARGET,
661
817
  parseDoneCheck,
@@ -665,19 +821,19 @@ module.exports = {
665
821
  buildEngineCommand,
666
822
  isSafeLane,
667
823
  taskTags,
668
- detectDeadEngineDispatch,
669
- rankFleetEngines,
670
824
  nextInstalledFleetEngine,
671
825
  dispatchToEngine,
672
826
  taskTags,
673
827
  isHumanHoldTag,
674
828
  isSafeLane,
675
- fileSurface,
676
- surfacesOverlap,
677
829
  staffFlight,
678
830
  assignEngines,
679
831
  landArrival,
832
+ detectDeadEngineDispatch,
833
+ failedDispatchLeg,
680
834
  fleetShipArgs,
835
+ clipHeadTail,
836
+ guardGlobalCliLink,
681
837
  defaultSelfLandCheck,
682
838
  };
683
839
 
@@ -913,7 +1069,9 @@ function reviewOnlyEngineEnvironment(worktreePath, options = {}) {
913
1069
  STRIPE_SECRET_KEY: '',
914
1070
  OPENAI_API_KEY: selectedSecret(['codex'], 'OPENAI_API_KEY'),
915
1071
  CODEX_API_KEY: selectedSecret(['codex'], 'CODEX_API_KEY'),
916
- ANTHROPIC_API_KEY: selectedSecret(['claude'], 'ANTHROPIC_API_KEY'),
1072
+ // owner policy: claude work runs on the subscription login, never API
1073
+ // billing, so the api key is stripped even when the parent env has one.
1074
+ ANTHROPIC_API_KEY: '',
917
1075
  CLAUDE_CODE_OAUTH_TOKEN: selectedSecret(['claude'], 'CLAUDE_CODE_OAUTH_TOKEN'),
918
1076
  CURSOR_API_KEY: selectedSecret(['cursor'], 'CURSOR_API_KEY'),
919
1077
  DEVIN_API_KEY: selectedSecret(['devin'], 'DEVIN_API_KEY'),
@@ -1042,6 +1200,15 @@ function prepareReviewSandbox({ root, taskId, engine }) {
1042
1200
  env: conductorGitEnvironment(),
1043
1201
  timeout: 30000,
1044
1202
  });
1203
+ // `git` walks up to the nearest enclosing repository when `root` is not itself
1204
+ // a git checkout. Without this guard, review-only dispatch would fetch and
1205
+ // trust the origin/master of whatever repo happens to sit above `root`, and
1206
+ // build against it. Refuse unless `root` is its own git top level.
1207
+ const toplevel = runConductorGit(['rev-parse', '--show-toplevel']);
1208
+ if (toplevel.status !== 0
1209
+ || realpathOrResolve(String(toplevel.stdout || '').trim()) !== realpathOrResolve(root)) {
1210
+ return { ok: false, detail: 'review-only dispatch requires the workspace to be its own git repository' };
1211
+ }
1045
1212
  const fetched = runConductorGit(['fetch', 'origin', 'master']);
1046
1213
  const original = runConductorGit(['remote', 'get-url', 'origin']);
1047
1214
  const base = run(['rev-parse', '--verify', 'origin/master^{commit}']);
@@ -1213,9 +1380,8 @@ function reviewCandidateSnapshot(boundary, expected = null) {
1213
1380
  const repeatedCommit = String(secondHead.stdout || '').trim();
1214
1381
  const treeOid = String(tree.stdout || '').trim();
1215
1382
  if (commit !== repeatedCommit) return fail('the executor changed HEAD while its candidate was being frozen');
1216
- const conductorEntry = /^\?\? \.atris\/(?:agent-worktree\.json|state\/briefs\.jsonl)$/;
1217
1383
  const dirty = String(status.stdout || '').trim().split(/\r?\n/).filter(Boolean)
1218
- .filter((line) => !conductorEntry.test(line));
1384
+ .filter((line) => !isConductorStatusLine(line));
1219
1385
  if (dirty.length) return fail(`the executor left uncommitted changes: ${dirty.slice(0, 10).join(', ')}`);
1220
1386
  if (expected && (commit !== expected.commit || treeOid !== expected.tree)) {
1221
1387
  return fail('the sealed candidate changed after the executor exited', { commit, tree: treeOid });
@@ -1415,8 +1581,7 @@ function importReviewCommit(boundary, { cli, taskId, engine, expectedCommit = ''
1415
1581
  const status = runSandbox(['status', '--porcelain=v1', '--untracked-files=all']);
1416
1582
  if (status.status !== 0) return fail('review_import', status.stderr || 'could not inspect the sealed sandbox');
1417
1583
  const dirtyEntries = String(status.stdout || '').trim().split(/\r?\n/).filter(Boolean);
1418
- const conductorEntries = /^\?\? \.atris\/(?:agent-worktree\.json|fleet-prompt-[^/]+\.md|runtime-tmp(?:\/.*)?|state\/briefs\.jsonl)$/;
1419
- const uncommitted = dirtyEntries.filter((line) => !conductorEntries.test(line));
1584
+ const uncommitted = dirtyEntries.filter((line) => !isConductorStatusLine(line));
1420
1585
  if (uncommitted.length) return fail('uncommitted_change', `the sealed executor must commit every requested change before Review: ${uncommitted.join(', ')}`);
1421
1586
  const headOid = expectedCommit;
1422
1587
  if (headOid === boundary.baseOid) return fail('no_change', 'the executor produced no committed change');
@@ -1543,9 +1708,12 @@ function inspectReviewChange(worktreePath, baseRef = 'origin/master') {
1543
1708
  detail: String(status.stderr || 'could not inspect worktree changes').trim(),
1544
1709
  };
1545
1710
  }
1546
- const conductorEntry = /^\?\? \.atris\/(?:agent-worktree\.json|state\/briefs\.jsonl)$/;
1711
+ // Conductor plumbing is not engine output. Counting it as a change is what let a
1712
+ // flight report "landed" for an engine that said "nothing to commit": the fleet's
1713
+ // own prompt file registered as dirt, has_change went true, and the no_change
1714
+ // pause never fired.
1547
1715
  const dirty = String(status.stdout || '').trim().split('\n').filter(Boolean)
1548
- .filter((line) => !conductorEntry.test(line));
1716
+ .filter((line) => !isConductorStatusLine(line));
1549
1717
  const hasCommittedDiff = committed.status === 1;
1550
1718
  return {
1551
1719
  has_change: hasCommittedDiff || dirty.length > 0,
@@ -1932,7 +2100,9 @@ async function runFleetFlight({
1932
2100
  ownCli = null,
1933
2101
  dispatcher = null,
1934
2102
  lander = null,
2103
+ rebase = null,
1935
2104
  checkoutBase = 'origin/master',
2105
+ guardCliLink = guardGlobalCliLink,
1936
2106
  } = {}) {
1937
2107
  const cli = ownCli || defaultOwnCli(root);
1938
2108
  const roster = engines || (() => {
@@ -2021,13 +2191,14 @@ async function runFleetFlight({
2021
2191
  });
2022
2192
 
2023
2193
  // Land serially: rebase-before-ship, conflict pauses (never auto-resolve).
2194
+ const rebaseArrival = rebase || landArrival;
2024
2195
  const land = lander || (({ entry }) => {
2025
- const rebased = landArrival({ worktreePath: entry.worktreePath });
2196
+ const rebased = rebaseArrival({ worktreePath: entry.worktreePath });
2026
2197
  if (!rebased.ok) return rebased;
2027
2198
  const check = focusedCheck(entry.task) || 'git log -1 --oneline';
2028
- const shipped = cli(fleetShipArgs(entry, check), entry.worktreePath);
2199
+ const shipped = shipWithRetry(cli, fleetShipArgs(entry, check), entry.worktreePath);
2029
2200
  if (shipped.status !== 0 || !/done: worktree shipped/.test(shipped.stdout)) {
2030
- return { ok: false, stage: 'ship', detail: (shipped.stderr || shipped.stdout).slice(-300) };
2201
+ return { ok: false, stage: 'ship', detail: shipFailureDetail(shipped, { head: 400, tail: 300 }) };
2031
2202
  }
2032
2203
  return { ok: true, stage: 'shipped' };
2033
2204
  });
@@ -2064,10 +2235,22 @@ async function runFleetFlight({
2064
2235
  } else {
2065
2236
  flight.paused.push({ task: entry.task.display_id, engine: activeEngine, ...(result.restaffed ? { restaffed: result.restaffed } : {}), ...landed });
2066
2237
  stampDispatchBrief(root, result.brief_id, 'partial', `paused ${entry.task.display_id} at ${landed.stage || 'landing'}`);
2067
- log(` ${activeEngine.padEnd(8)} ⏸ paused ${entry.task.display_id} at ${landed.stage}${landed.conflicts ? ` (${landed.conflicts.join(', ')})` : ''} — worktree kept`);
2238
+ log(` ${activeEngine.padEnd(8)} ⏸ paused ${entry.task.display_id} at ${landed.stage}${pausedPathsSuffix(landed)} — worktree kept`);
2068
2239
  }
2069
2240
  }
2070
2241
 
2242
+ // Teardown guard: a build worktree may have run `npm link` and hijacked the
2243
+ // global `atris`. Before reporting success, make sure this flight did not
2244
+ // leave the global CLI pointed into one of its own worktrees.
2245
+ const flightWorktrees = prepared.map((entry) => entry.worktreePath).filter(Boolean);
2246
+ const cliLink = guardCliLink({ root, worktreePaths: flightWorktrees });
2247
+ flight.cli_link = cliLink;
2248
+ if (cliLink.changed) {
2249
+ log(cliLink.ok
2250
+ ? ` restored global atris link (was ${cliLink.reason}); repointed at ${path.relative(root, cliLink.restoredTo) || cliLink.restoredTo} and the cli runs`
2251
+ : ` alert: global atris link was ${cliLink.reason}; repoint attempted at ${cliLink.restoredTo} but the cli still fails, so every cron/loop/mission is down`);
2252
+ }
2253
+
2071
2254
  fs.mkdirSync(path.dirname(receiptPath), { recursive: true });
2072
2255
  flight.receipt = receiptPath;
2073
2256
  fs.writeFileSync(flight.receipt, `${JSON.stringify(flight, null, 2)}\n`);
@@ -2351,9 +2534,9 @@ async function runDispatchFlight({
2351
2534
  change,
2352
2535
  };
2353
2536
  }
2354
- const shipped = cli(fleetShipArgs({ task: entry.task, engine: entry.engine || engine }, check), entry.worktreePath);
2537
+ const shipped = shipWithRetry(cli, fleetShipArgs({ task: entry.task, engine: entry.engine || engine }, check), entry.worktreePath);
2355
2538
  if (shipped.status !== 0 || !/done: worktree shipped/.test(shipped.stdout)) {
2356
- return { ok: false, stage: 'ship', detail: (shipped.stderr || shipped.stdout).slice(-500) };
2539
+ return { ok: false, stage: 'ship', detail: shipFailureDetail(shipped, { head: 400, tail: 500 }) };
2357
2540
  }
2358
2541
  return {
2359
2542
  ok: true,
@@ -2736,7 +2919,7 @@ async function runDispatchFlight({
2736
2919
  ...landed,
2737
2920
  });
2738
2921
  stampDispatchBrief(root, result.brief_id, 'partial', `paused ${entry.taskId} at ${landed.stage || 'landing'}`);
2739
- log(` ⏸ paused ${entry.taskId} at ${landed.stage}${landed.conflicts ? ` (${landed.conflicts.join(', ')})` : ''} — worktree kept`);
2922
+ log(` ⏸ paused ${entry.taskId} at ${landed.stage}${pausedPathsSuffix(landed)} — worktree kept`);
2740
2923
  }
2741
2924
  }
2742
2925