atris 3.37.1 → 3.40.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 (164) hide show
  1. package/AGENTS.md +25 -6
  2. package/README.md +2 -0
  3. package/atris/PERSONA.md +11 -11
  4. package/atris.md +17 -2
  5. package/ax +160 -34
  6. package/bin/atris.js +78 -6
  7. package/commands/activate.js +16 -3
  8. package/commands/agent-spawn.js +13 -18
  9. package/commands/autoland.js +76 -47
  10. package/commands/autopilot-front.js +0 -4
  11. package/commands/autopilot.js +0 -12
  12. package/commands/bench.js +10 -12
  13. package/commands/brain.js +0 -7
  14. package/commands/brief.js +0 -2
  15. package/commands/business-sync.js +0 -1
  16. package/commands/business.js +452 -12
  17. package/commands/chat-scan.js +5 -7
  18. package/commands/clean.js +31 -2
  19. package/commands/close.js +46 -11
  20. package/commands/cloud.js +0 -1
  21. package/commands/codex-goal.js +8 -12
  22. package/commands/computer.js +6 -5
  23. package/commands/console.js +64 -11
  24. package/commands/decide.js +166 -0
  25. package/commands/deck.js +30 -12
  26. package/commands/dream.js +0 -7
  27. package/commands/drill.js +14 -25
  28. package/commands/engine.js +206 -26
  29. package/commands/experiments.js +0 -4
  30. package/commands/fleet-report.js +12 -4
  31. package/commands/game.js +261 -0
  32. package/commands/gm.js +17 -7
  33. package/commands/goal.js +0 -6
  34. package/commands/harvest.js +1 -5
  35. package/commands/improve.js +29 -8
  36. package/commands/init.js +41 -37
  37. package/commands/integrations.js +0 -7
  38. package/commands/land.js +77 -20
  39. package/commands/launchpad.js +1 -16
  40. package/commands/lesson.js +17 -7
  41. package/commands/lifecycle.js +5 -5
  42. package/commands/log.js +55 -5
  43. package/commands/loop-front.js +0 -2
  44. package/commands/loops.js +6 -5
  45. package/commands/meet.js +0 -2
  46. package/commands/member.js +622 -573
  47. package/commands/mission.js +790 -314
  48. package/commands/next.js +0 -1
  49. package/commands/now.js +0 -12
  50. package/commands/one-lap.js +8 -8
  51. package/commands/orb.js +0 -4
  52. package/commands/pack-craft.js +0 -1
  53. package/commands/pack.js +3599 -424
  54. package/commands/play.js +6 -6
  55. package/commands/probe.js +12 -6
  56. package/commands/pulse.js +88 -64
  57. package/commands/push.js +0 -4
  58. package/commands/radar.js +14 -11
  59. package/commands/recover.js +340 -0
  60. package/commands/release.js +10 -9
  61. package/commands/report.js +0 -1
  62. package/commands/router.js +320 -0
  63. package/commands/run-front.js +0 -2
  64. package/commands/search.js +0 -3
  65. package/commands/site-deploy.js +870 -0
  66. package/commands/site.js +11 -2
  67. package/commands/skill.js +7 -2
  68. package/commands/slop.js +237 -7
  69. package/commands/status.js +6 -0
  70. package/commands/stream.js +4 -20
  71. package/commands/study.js +0 -3
  72. package/commands/sync-checkout.js +51 -0
  73. package/commands/sync.js +11 -40
  74. package/commands/task.js +999 -595
  75. package/commands/taste.js +101 -0
  76. package/commands/teach.js +86 -0
  77. package/commands/team.js +83 -3
  78. package/commands/unknowns.js +0 -10
  79. package/commands/update.js +0 -1
  80. package/commands/vercel.js +4 -2
  81. package/commands/visualize.js +0 -5
  82. package/commands/voice.js +195 -0
  83. package/commands/watch.js +1 -28
  84. package/commands/wiki.js +42 -15
  85. package/commands/wish.js +0 -1
  86. package/commands/workflow.js +2 -2
  87. package/commands/worktree.js +88 -28
  88. package/commands/xp.js +28 -30
  89. package/commands/youtube.js +0 -2
  90. package/lib/accept-verify-gate.js +126 -0
  91. package/lib/activity-stream.js +0 -2
  92. package/lib/arg-parser.js +41 -0
  93. package/lib/auto-accept-certified.js +261 -3
  94. package/lib/autoland.js +66 -3
  95. package/lib/ax-auto-lane.js +68 -7
  96. package/lib/bench/context.js +0 -2
  97. package/lib/bench/report.js +0 -1
  98. package/lib/bench/runner.js +18 -11
  99. package/lib/brief-ledger.js +0 -6
  100. package/lib/business-simulate.js +382 -0
  101. package/lib/chat-log-scan.js +0 -5
  102. package/lib/checkout-sync.js +52 -0
  103. package/lib/claude-boot-block.js +48 -0
  104. package/lib/cloud-mission.js +0 -6
  105. package/lib/codex-flight.js +0 -1
  106. package/lib/conductor-artifacts.js +25 -0
  107. package/lib/context-gatherer.js +7 -2
  108. package/lib/deck-compose.js +0 -1
  109. package/lib/deck-history.js +0 -1
  110. package/lib/deck-layout.js +0 -5
  111. package/lib/deck-review.js +0 -7
  112. package/lib/deck-schema.js +0 -1
  113. package/lib/default-verifier.js +0 -2
  114. package/lib/engine-registry.js +157 -28
  115. package/lib/experiments/daily.js +0 -6
  116. package/lib/falsifier-probe.js +84 -0
  117. package/lib/fleet.js +275 -42
  118. package/lib/functional-owner.js +0 -3
  119. package/lib/git-spawn.js +15 -0
  120. package/lib/inspect-fields.js +0 -1
  121. package/lib/json-file.js +37 -0
  122. package/lib/known-commands.js +2 -2
  123. package/lib/learnings.js +0 -2
  124. package/lib/lesson-preflight.js +146 -0
  125. package/lib/loop-doctor.js +11 -19
  126. package/lib/manifest.js +0 -2
  127. package/lib/member-alive.js +0 -1
  128. package/lib/member-switches.js +0 -5
  129. package/lib/mission-artifact.js +0 -1
  130. package/lib/mission-human-asks.js +28 -0
  131. package/lib/mission-protected-lane.js +296 -0
  132. package/lib/mission-room.js +0 -2
  133. package/lib/mission-runtime-loop.js +0 -1
  134. package/lib/next-moves.js +0 -18
  135. package/lib/official-cli-integration.js +47 -6
  136. package/lib/orb-context.js +14 -4
  137. package/lib/orb-scorecard.js +0 -1
  138. package/lib/outbound-send-gate.js +0 -6
  139. package/lib/pack-capabilities.js +685 -0
  140. package/lib/permission-grants.js +0 -6
  141. package/lib/policy-lessons.js +0 -2
  142. package/lib/pulse.js +68 -15
  143. package/lib/review-integrity.js +0 -2
  144. package/lib/router-brain.js +51 -3
  145. package/lib/runner-command.js +0 -10
  146. package/lib/runtime-bootstrap.js +0 -1
  147. package/lib/scorecard.js +0 -1
  148. package/lib/self-drive.js +45 -14
  149. package/lib/state-detection.js +0 -6
  150. package/lib/task-db.js +137 -7
  151. package/lib/task-decision.js +50 -0
  152. package/lib/taste-lessons.js +153 -0
  153. package/lib/team-presence.js +0 -1
  154. package/lib/tool-result-encode.js +17 -1
  155. package/lib/voice-gate.js +129 -2
  156. package/lib/wiki.js +152 -9
  157. package/lib/wish-audit.js +21 -7
  158. package/lib/wish-delegate.js +27 -21
  159. package/lib/wish-stats.js +0 -5
  160. package/lib/wish-store.js +0 -1
  161. package/lib/workspace-safety.js +0 -2
  162. package/lib/zip.js +95 -8
  163. package/package.json +2 -1
  164. package/templates/business-starter/persona.md +9 -0
package/lib/fleet.js CHANGED
@@ -24,16 +24,19 @@ const {
24
24
  } = require('./brief-ledger');
25
25
  const { RUNNER_PROFILE_DEFS, buildRunnerCommand } = require('./runner-command');
26
26
  const { resolveDefaultVerifier } = require('./default-verifier');
27
- const { rankEngines } = require('./router-brain');
27
+ const { rankEnginesDetailed } = require('./router-brain');
28
28
  const {
29
29
  buildOneLapValidatorPrompt,
30
30
  parseOneLapValidatorVerdict,
31
31
  } = require('./one-lap-validator');
32
32
  const { listWorktrees } = require('../commands/worktree');
33
+ const { isConductorStatusLine } = require('./conductor-artifacts');
34
+ const { matchLessons } = require('./lesson-preflight');
35
+ const { matchTaste } = require('./taste-lessons');
33
36
 
34
37
  // Lanes a fleet may never staff on its own: the human keeps irreversible
35
38
  // calls. Mirrors the autoland denied lanes.
36
- const DENIED_TAGS = ['billing', 'deploy', 'security', 'customer', 'external', 'feedback'];
39
+ const DENIED_TAGS = ['billing', 'money', 'payments', 'deploy', 'security', 'customer', 'external', 'feedback'];
37
40
 
38
41
  // ---------------------------------------------------------------------------
39
42
  // T1 — dispatch primitive
@@ -93,6 +96,30 @@ function buildFleetPrompt(task, { worktreePath, yolo = false } = {}) {
93
96
  '',
94
97
  'Final report (plain text): files changed, test command + result, commit sha (or say the commit failed and why).'
95
98
  );
99
+ const lessonFiles = [
100
+ ...(Array.isArray(task.files) ? task.files : []),
101
+ ...(Array.isArray(task.metadata && task.metadata.files) ? task.metadata.files : []),
102
+ ...fileSurface(task),
103
+ ];
104
+ const briefText = lines.join('\n');
105
+ const preflightRoot = worktreePath || process.cwd();
106
+ const lessons = matchLessons({
107
+ briefText,
108
+ files: lessonFiles,
109
+ root: preflightRoot,
110
+ });
111
+ if (lessons.length) {
112
+ lines.push('', '## lessons that apply', ...lessons.map((lesson) => `- ${lesson.text}`));
113
+ }
114
+ const requestedTasteScope = String(
115
+ task.scope || (task.metadata && task.metadata.scope) || task.tag || 'any'
116
+ ).toLowerCase();
117
+ const taste = matchTaste({ briefText, scope: requestedTasteScope, root: preflightRoot });
118
+ if (taste.length) {
119
+ lines.push('', "## the owner's taste", ...taste.map((entry) => (
120
+ `- The operator's verdict is ${entry.verdict} for "${entry.subject}". The reason is: ${entry.why}`
121
+ )));
122
+ }
96
123
  return lines.join('\n');
97
124
  }
98
125
 
@@ -317,6 +344,20 @@ function dispatchResultExitCode(result) {
317
344
  return 0;
318
345
  }
319
346
 
347
+ // A child killed by a signal comes back either with spawnSync's `signal` field
348
+ // set (plain spawn) or a 128+N exit status (the sealed supervisor re-exits 143
349
+ // on SIGTERM). Either way stdout/report and stderr are empty, so name the
350
+ // signal instead of letting the leg read as a silent no-op (CLI-1190: on
351
+ // WEB-455 2026-07-25 the claude engine exited 143/SIGTERM with an empty report
352
+ // and the restaff receipt looked like nothing had happened).
353
+ const SIGNAL_EXIT_NAMES = Object.freeze({ 129: 'SIGHUP', 130: 'SIGINT', 137: 'SIGKILL', 143: 'SIGTERM' });
354
+ function dispatchResultSignal(result) {
355
+ if (!result || typeof result !== 'object') return null;
356
+ if (result.signal) return String(result.signal);
357
+ const code = dispatchResultExitCode(result);
358
+ return SIGNAL_EXIT_NAMES[code] || null;
359
+ }
360
+
320
361
  function dispatchTaskId(task) {
321
362
  return task && (task.display_id || task.task_id || task.id) ? String(task.display_id || task.task_id || task.id) : '';
322
363
  }
@@ -364,6 +405,8 @@ function detectDeadEngineDispatch(result) {
364
405
  const output = dispatchResultOutput(result).toLowerCase();
365
406
  const pattern = DEAD_ENGINE_OUTPUT_PATTERNS.find((p) => output.includes(p));
366
407
  if (pattern) return { reason: 'usage_limit', pattern };
408
+ const signal = dispatchResultSignal(result);
409
+ if (signal) return { reason: 'signalled', signal, exitCode };
367
410
  return { reason: 'nonzero_exit', exitCode };
368
411
  }
369
412
 
@@ -379,13 +422,18 @@ function installedFleetEngines(root) {
379
422
  return normalizeInstalledEngines(roster(root).filter((e) => e.installed));
380
423
  }
381
424
 
382
- function rankFleetEngines(engines, root = process.cwd()) {
383
- return rankEngines(normalizeInstalledEngines(engines), {
425
+ function rankFleetEnginesDetailed(engines, root = process.cwd(), options = {}) {
426
+ return rankEnginesDetailed(normalizeInstalledEngines(engines), {
384
427
  root,
385
428
  taskType: 'executor',
429
+ lowStakes: options.lowStakes === true,
386
430
  });
387
431
  }
388
432
 
433
+ function rankFleetEngines(engines, root = process.cwd()) {
434
+ return rankFleetEnginesDetailed(engines, root).candidates;
435
+ }
436
+
389
437
  function nextInstalledFleetEngine(current, { root = process.cwd(), installedEngines = null } = {}) {
390
438
  const engines = installedEngines ? normalizeInstalledEngines(installedEngines) : installedFleetEngines(root);
391
439
  const currentName = String(current || '').trim();
@@ -406,12 +454,21 @@ function normalizeDispatchResult(result, engineName) {
406
454
  }
407
455
 
408
456
  function failedDispatchLeg(result, engineName) {
409
- return {
457
+ const report = String(result && result.report || '').slice(-2000);
458
+ const signal = dispatchResultSignal(result);
459
+ const leg = {
410
460
  engine: String(result && result.engine || engineName || ''),
411
461
  exitCode: dispatchResultExitCode(result),
412
462
  stderr: String(result && result.stderr || '').slice(-2000),
413
- report: String(result && result.report || '').slice(-2000),
463
+ report,
414
464
  };
465
+ if (signal) {
466
+ // Record that the predecessor was killed; a signalled leg has no report of
467
+ // its own, so name the signal in place of the empty string (CLI-1190).
468
+ leg.signal = signal;
469
+ if (!report) leg.report = `(no report: killed by ${signal})`;
470
+ }
471
+ return leg;
415
472
  }
416
473
 
417
474
  async function dispatchEntryWithRestaff({
@@ -535,6 +592,7 @@ function dispatchToEngine({ task, engine, worktreePath, root = process.cwd(), ti
535
592
  brief_id: capturedBriefId || null,
536
593
  command,
537
594
  exitCode: result.status,
595
+ signal: result.signal || null,
538
596
  report: String(result.stdout || '').slice(-8000),
539
597
  stderr: String(result.stderr || '').slice(-2000),
540
598
  };
@@ -543,27 +601,30 @@ function dispatchToEngine({ task, engine, worktreePath, root = process.cwd(), ti
543
601
  // ---------------------------------------------------------------------------
544
602
  // T2 — staffing
545
603
 
604
+ const {
605
+ taskTagTokens,
606
+ isDecisionHoldTag,
607
+ isDecisionTask,
608
+ DECISION_REFUSE_REASON,
609
+ } = require('./task-decision');
610
+
546
611
  function taskTags(task) {
547
- const fromTags = Array.isArray(task.tags) ? task.tags : [];
548
- const fromTag = task && task.tag ? [task.tag] : [];
549
612
  // Tags added after creation live in metadata.tags (`atris task tag`); a
550
613
  // fleet that only read task.tags/title hashtags would ignore an owner-hold
551
614
  // flag stamped on a live task and keep restaffing it (CLI-879).
552
- const fromMeta = task && task.metadata && Array.isArray(task.metadata.tags) ? task.metadata.tags : [];
553
- const fromTitle = (String(task.title || '').match(/#([a-z0-9-]+)/gi) || []).map((t) => t.slice(1));
554
- return [...fromTag, ...fromTags, ...fromMeta, ...fromTitle].map((t) => String(t).toLowerCase());
615
+ return taskTagTokens(task);
555
616
  }
556
617
 
557
618
  // A task flagged for a human decision is never fleet-staffable, whatever its
558
- // lane. Mirrors the sweep's needs-human hold so both loops agree.
619
+ // lane. Mirrors the sweep's needs-human hold so both loops agree. Also honors
620
+ // the clearer `decision` tag so policy questions stay off autonomous lanes.
559
621
  function isHumanHoldTag(tag) {
560
- const normalized = String(tag).trim().toLowerCase().replace(/_/g, '-');
561
- return normalized === 'needs-human' || normalized === 'needshuman';
622
+ return isDecisionHoldTag(tag);
562
623
  }
563
624
 
564
625
  function isSafeLane(task) {
626
+ if (isDecisionTask(task)) return false;
565
627
  const tags = taskTags(task);
566
- if (tags.some(isHumanHoldTag)) return false;
567
628
  return !tags.some((t) => DENIED_TAGS.includes(t));
568
629
  }
569
630
 
@@ -617,10 +678,41 @@ function assignEngines(staffed, engines) {
617
678
  // Land one arrival: rebase onto latest base first; a conflict pauses the
618
679
  // landing (never auto-resolve) and reports which files collided so the
619
680
  // conductor or a human can take it. `git` is injectable for tests.
681
+ // Ship failures print the cause first (MODULE_NOT_FOUND, command name) and a
682
+ // long node stack after. Tail-only clips hide the cause; keep a head+tail.
683
+ function clipHeadTail(text, { head = 400, tail = 300 } = {}) {
684
+ const s = String(text || '');
685
+ if (s.length <= head + tail) return s;
686
+ return `${s.slice(0, head)}\n...\n${s.slice(-tail)}`;
687
+ }
688
+
620
689
  // Fleet landings always target master. Without the explicit --target, ship
621
690
  // falls back to the launcher branch's atris-base — a flight launched from a
622
691
  // feature-branch checkout would merge PRs into that branch while the receipt
623
692
  // and land board define "landed" as in-master (PRs #207/#208, 2026-07-04).
693
+ // A ship failure must name its real cause. MODULE_NOT_FOUND from the spawned
694
+ // CLI is usually transient: the fleet ships by spawning node against this
695
+ // live checkout, and a concurrent rebase/pull can make a require vanish for a
696
+ // moment. So the ship retries once on that signature, and the failure detail
697
+ // always surfaces the missing-module line from either stream instead of
698
+ // letting head/tail clipping truncate it away (cost measured live: CLI-1185
699
+ // receipts kept only a loader stack tail and the module name was lost).
700
+ function shipWithRetry(cli, args, cwd) {
701
+ let shipped = cli(args, cwd);
702
+ if (shipped.status !== 0 && /MODULE_NOT_FOUND|Cannot find module/.test(`${shipped.stderr}${shipped.stdout}`)) {
703
+ shipped = cli(args, cwd);
704
+ shipped.retried_module_not_found = true;
705
+ }
706
+ return shipped;
707
+ }
708
+
709
+ function shipFailureDetail(shipped, { head = 400, tail = 300 } = {}) {
710
+ const combined = `${shipped.stderr || ''}\n${shipped.stdout || ''}`;
711
+ const moduleLine = combined.match(/Cannot find module '[^']+'[^\n]*/);
712
+ const cause = moduleLine ? `${moduleLine[0]}${shipped.retried_module_not_found ? ' (persisted after one retry)' : ''}\n` : '';
713
+ return `${cause}${clipHeadTail(combined.trim(), { head, tail })}`;
714
+ }
715
+
624
716
  function fleetShipArgs(entry, check) {
625
717
  return [
626
718
  'worktree', 'ship',
@@ -631,19 +723,113 @@ function fleetShipArgs(entry, check) {
631
723
  ];
632
724
  }
633
725
 
726
+ // A fleet build worktree is ephemeral. If any process inside one runs
727
+ // `npm link` (which repoints the global `atris` package symlink at that
728
+ // worktree), then removing the worktree at teardown leaves the global `atris`
729
+ // binary a dangling symlink, and everything resolving `atris` through PATH —
730
+ // interactive shells, any tooling that shells out to the bare command — dies
731
+ // with 'command not found'. Measured scope, not assumed: crons that invoke
732
+ // `node <checkout>/bin/atris.js` by absolute path are UNAFFECTED; an autoland
733
+ // tick ran fine four minutes before the repair. The failure is silent and
734
+ // reads like a shell/PATH problem, not a worktree-cleanup one (found live
735
+ // 2026-07-26, CLI-1193; same shape as the cron PATH wedge that cost 20 days
736
+ // of autoland).
737
+ // Recovery was one line: repoint the global link at the real checkout. So
738
+ // teardown does exactly that on its own: if the global `atris` link points
739
+ // into one of this flight's worktrees, repoint it at the primary checkout and
740
+ // verify the CLI still runs before the flight reports success.
741
+ function defaultReadGlobalCliLink() {
742
+ const rootResult = spawnSync('npm', ['root', '-g'], { encoding: 'utf8' });
743
+ if (rootResult.status !== 0) return null;
744
+ const globalRoot = String(rootResult.stdout || '').trim();
745
+ if (!globalRoot) return null;
746
+ const pkgPath = path.join(globalRoot, 'atris');
747
+ let stat;
748
+ try {
749
+ stat = fs.lstatSync(pkgPath);
750
+ } catch {
751
+ return null; // atris is not installed globally at all
752
+ }
753
+ if (!stat.isSymbolicLink()) return { path: pkgPath, target: null }; // real install, not a link
754
+ return { path: pkgPath, target: path.resolve(path.dirname(pkgPath), fs.readlinkSync(pkgPath)) };
755
+ }
756
+
757
+ function defaultRepointGlobalCliLink(primaryCheckout) {
758
+ // `npm link` from the real checkout re-creates the global symlink pointing at it.
759
+ return spawnSync('npm', ['link'], { cwd: primaryCheckout, encoding: 'utf8' });
760
+ }
761
+
762
+ function defaultVerifyCliRuns() {
763
+ const result = spawnSync('atris', ['--version'], { encoding: 'utf8' });
764
+ return { ok: result.status === 0, status: result.status, stdout: String(result.stdout || '').trim() };
765
+ }
766
+
767
+ function guardGlobalCliLink({
768
+ root = process.cwd(),
769
+ worktreePaths = [],
770
+ readGlobalLink = defaultReadGlobalCliLink,
771
+ repointLink = defaultRepointGlobalCliLink,
772
+ verifyCli = defaultVerifyCliRuns,
773
+ } = {}) {
774
+ const link = readGlobalLink();
775
+ if (!link || !link.target) return { ok: true, changed: false, reason: 'not_linked' };
776
+ const resolvedTarget = realpathOrResolve(link.target);
777
+ const worktrees = worktreePaths.filter(Boolean).map((wt) => realpathOrResolve(wt));
778
+ // Match the raw string so a worktree already removed by ship still matches
779
+ // (its path is gone from disk but the dangling link still names it).
780
+ const insideWorktree = worktrees.some((wt) => resolvedTarget === wt || resolvedTarget.startsWith(`${wt}${path.sep}`));
781
+ if (!insideWorktree) return { ok: true, changed: false, reason: 'healthy', target: link.target };
782
+ let primaryPath = root;
783
+ try {
784
+ primaryPath = listWorktrees(root)[0]?.path || root;
785
+ } catch {
786
+ primaryPath = root;
787
+ }
788
+ const primary = realpathOrResolve(primaryPath);
789
+ const repoint = repointLink(primary);
790
+ const verify = verifyCli();
791
+ return {
792
+ ok: Boolean(verify && verify.ok),
793
+ changed: true,
794
+ reason: 'linked_into_worktree',
795
+ was: link.target,
796
+ restoredTo: primary,
797
+ repoint: { status: repoint ? repoint.status : null },
798
+ verify,
799
+ };
800
+ }
801
+
802
+ // One-line " (path, path)" suffix naming the files behind a paused landing —
803
+ // unmerged files for a rebase_conflict, uncommitted paths for a dirty_worktree.
804
+ function pausedPathsSuffix(landed) {
805
+ const paths = (landed && landed.conflicts) || (landed && landed.dirty) || [];
806
+ return paths.length ? ` (${paths.join(', ')})` : '';
807
+ }
808
+
634
809
  function landArrival({ worktreePath, git = null }) {
635
810
  const run = git || ((args) => spawnSync('git', args, { cwd: worktreePath, encoding: 'utf8' }));
636
811
  const fetch = run(['fetch', 'origin']);
637
812
  if (fetch.status !== 0) return { ok: false, stage: 'fetch', detail: String(fetch.stderr || '').trim() };
638
813
  const rebase = run(['rebase', 'origin/master']);
639
814
  if (rebase.status !== 0) {
640
- const conflicted = run(['diff', '--name-only', '--diff-filter=U']);
815
+ const conflicts = String(run(['diff', '--name-only', '--diff-filter=U']).stdout || '')
816
+ .trim().split('\n').filter(Boolean);
641
817
  run(['rebase', '--abort']);
642
- return {
643
- ok: false,
644
- stage: 'rebase_conflict',
645
- conflicts: String(conflicted.stdout || '').trim().split('\n').filter(Boolean),
646
- };
818
+ // git rebase refuses outright when the worktree has uncommitted changes:
819
+ // it stops before a single conflict can occur, so the unmerged-file list is
820
+ // empty. Reporting an empty list as rebase_conflict sends the operator
821
+ // chasing a merge problem that does not exist while intact work sits
822
+ // uncommitted in the worktree (CLI-1190; hit live on WEB-455 2026-07-25
823
+ // after a killed engine left modified/untracked files behind). An empty
824
+ // conflicts list is never a conflict: name the dirty paths instead.
825
+ if (!conflicts.length) {
826
+ const dirty = String(run(['status', '--porcelain']).stdout || '')
827
+ .split('\n')
828
+ .map((line) => line.slice(3).trim())
829
+ .filter(Boolean);
830
+ return { ok: false, stage: 'dirty_worktree', dirty };
831
+ }
832
+ return { ok: false, stage: 'rebase_conflict', conflicts };
647
833
  }
648
834
  return { ok: true, stage: 'rebased' };
649
835
  }
@@ -651,11 +837,15 @@ function landArrival({ worktreePath, git = null }) {
651
837
  module.exports = {
652
838
  DENIED_TAGS,
653
839
  DEAD_ENGINE_OUTPUT_PATTERNS,
840
+ reviewOnlyEngineEnvironment,
841
+ shipWithRetry,
842
+ shipFailureDetail,
654
843
  get FLEET_CAPABLE() { return FLEET_CAPABLE; },
655
844
  get runFleetFlight() { return runFleetFlight; },
656
845
  get focusedCheck() { return focusedCheck; },
657
846
  get dispatchCheck() { return dispatchCheck; },
658
847
  get runDispatchFlight() { return runDispatchFlight; },
848
+ get prepareReviewSandbox() { return prepareReviewSandbox; },
659
849
  YOLO_ENGINE_FLAGS,
660
850
  DISPATCH_SELF_LAND_TARGET,
661
851
  parseDoneCheck,
@@ -665,19 +855,22 @@ module.exports = {
665
855
  buildEngineCommand,
666
856
  isSafeLane,
667
857
  taskTags,
668
- detectDeadEngineDispatch,
669
- rankFleetEngines,
670
858
  nextInstalledFleetEngine,
859
+ rankFleetEnginesDetailed,
671
860
  dispatchToEngine,
672
861
  taskTags,
673
862
  isHumanHoldTag,
863
+ isDecisionTask,
864
+ DECISION_REFUSE_REASON,
674
865
  isSafeLane,
675
- fileSurface,
676
- surfacesOverlap,
677
866
  staffFlight,
678
867
  assignEngines,
679
868
  landArrival,
869
+ detectDeadEngineDispatch,
870
+ failedDispatchLeg,
680
871
  fleetShipArgs,
872
+ clipHeadTail,
873
+ guardGlobalCliLink,
681
874
  defaultSelfLandCheck,
682
875
  };
683
876
 
@@ -913,7 +1106,9 @@ function reviewOnlyEngineEnvironment(worktreePath, options = {}) {
913
1106
  STRIPE_SECRET_KEY: '',
914
1107
  OPENAI_API_KEY: selectedSecret(['codex'], 'OPENAI_API_KEY'),
915
1108
  CODEX_API_KEY: selectedSecret(['codex'], 'CODEX_API_KEY'),
916
- ANTHROPIC_API_KEY: selectedSecret(['claude'], 'ANTHROPIC_API_KEY'),
1109
+ // owner policy: claude work runs on the subscription login, never API
1110
+ // billing, so the api key is stripped even when the parent env has one.
1111
+ ANTHROPIC_API_KEY: '',
917
1112
  CLAUDE_CODE_OAUTH_TOKEN: selectedSecret(['claude'], 'CLAUDE_CODE_OAUTH_TOKEN'),
918
1113
  CURSOR_API_KEY: selectedSecret(['cursor'], 'CURSOR_API_KEY'),
919
1114
  DEVIN_API_KEY: selectedSecret(['devin'], 'DEVIN_API_KEY'),
@@ -1042,6 +1237,15 @@ function prepareReviewSandbox({ root, taskId, engine }) {
1042
1237
  env: conductorGitEnvironment(),
1043
1238
  timeout: 30000,
1044
1239
  });
1240
+ // `git` walks up to the nearest enclosing repository when `root` is not itself
1241
+ // a git checkout. Without this guard, review-only dispatch would fetch and
1242
+ // trust the origin/master of whatever repo happens to sit above `root`, and
1243
+ // build against it. Refuse unless `root` is its own git top level.
1244
+ const toplevel = runConductorGit(['rev-parse', '--show-toplevel']);
1245
+ if (toplevel.status !== 0
1246
+ || realpathOrResolve(String(toplevel.stdout || '').trim()) !== realpathOrResolve(root)) {
1247
+ return { ok: false, detail: 'review-only dispatch requires the workspace to be its own git repository' };
1248
+ }
1045
1249
  const fetched = runConductorGit(['fetch', 'origin', 'master']);
1046
1250
  const original = runConductorGit(['remote', 'get-url', 'origin']);
1047
1251
  const base = run(['rev-parse', '--verify', 'origin/master^{commit}']);
@@ -1213,9 +1417,8 @@ function reviewCandidateSnapshot(boundary, expected = null) {
1213
1417
  const repeatedCommit = String(secondHead.stdout || '').trim();
1214
1418
  const treeOid = String(tree.stdout || '').trim();
1215
1419
  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
1420
  const dirty = String(status.stdout || '').trim().split(/\r?\n/).filter(Boolean)
1218
- .filter((line) => !conductorEntry.test(line));
1421
+ .filter((line) => !isConductorStatusLine(line));
1219
1422
  if (dirty.length) return fail(`the executor left uncommitted changes: ${dirty.slice(0, 10).join(', ')}`);
1220
1423
  if (expected && (commit !== expected.commit || treeOid !== expected.tree)) {
1221
1424
  return fail('the sealed candidate changed after the executor exited', { commit, tree: treeOid });
@@ -1415,8 +1618,7 @@ function importReviewCommit(boundary, { cli, taskId, engine, expectedCommit = ''
1415
1618
  const status = runSandbox(['status', '--porcelain=v1', '--untracked-files=all']);
1416
1619
  if (status.status !== 0) return fail('review_import', status.stderr || 'could not inspect the sealed sandbox');
1417
1620
  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));
1621
+ const uncommitted = dirtyEntries.filter((line) => !isConductorStatusLine(line));
1420
1622
  if (uncommitted.length) return fail('uncommitted_change', `the sealed executor must commit every requested change before Review: ${uncommitted.join(', ')}`);
1421
1623
  const headOid = expectedCommit;
1422
1624
  if (headOid === boundary.baseOid) return fail('no_change', 'the executor produced no committed change');
@@ -1543,9 +1745,12 @@ function inspectReviewChange(worktreePath, baseRef = 'origin/master') {
1543
1745
  detail: String(status.stderr || 'could not inspect worktree changes').trim(),
1544
1746
  };
1545
1747
  }
1546
- const conductorEntry = /^\?\? \.atris\/(?:agent-worktree\.json|state\/briefs\.jsonl)$/;
1748
+ // Conductor plumbing is not engine output. Counting it as a change is what let a
1749
+ // flight report "landed" for an engine that said "nothing to commit": the fleet's
1750
+ // own prompt file registered as dirt, has_change went true, and the no_change
1751
+ // pause never fired.
1547
1752
  const dirty = String(status.stdout || '').trim().split('\n').filter(Boolean)
1548
- .filter((line) => !conductorEntry.test(line));
1753
+ .filter((line) => !isConductorStatusLine(line));
1549
1754
  const hasCommittedDiff = committed.status === 1;
1550
1755
  return {
1551
1756
  has_change: hasCommittedDiff || dirty.length > 0,
@@ -1932,18 +2137,29 @@ async function runFleetFlight({
1932
2137
  ownCli = null,
1933
2138
  dispatcher = null,
1934
2139
  lander = null,
2140
+ rebase = null,
1935
2141
  checkoutBase = 'origin/master',
2142
+ guardCliLink = guardGlobalCliLink,
1936
2143
  } = {}) {
1937
2144
  const cli = ownCli || defaultOwnCli(root);
1938
- const roster = engines || (() => {
2145
+ // Staff first, rank second: every task staffFlight returns already cleared
2146
+ // the safe-lane filter (not a decision row, no denied tags), so the rank
2147
+ // that pairs engines to those tasks is low stakes and the stretch zone
2148
+ // rule may trade the strongest engine for the cheapest learnable one.
2149
+ // Protected or denied work never reaches staffFlight's output, so it never
2150
+ // gets the flag; when nothing is staffed there is no task to rank for and
2151
+ // the flag stays off.
2152
+ const staffedTasks = staffFlight(readProjectionTasks(root), { slots });
2153
+ const rosterDetail = engines ? null : (() => {
1939
2154
  const { roster: fullRoster } = require('../commands/engine');
1940
2155
  const installed = fullRoster(root)
1941
2156
  .filter((e) => e.installed && FLEET_CAPABLE.includes(e.name))
1942
2157
  .map((e) => e.name);
1943
- return rankFleetEngines(installed, root);
2158
+ return rankFleetEnginesDetailed(installed, root, { lowStakes: staffedTasks.length > 0 });
1944
2159
  })();
2160
+ const roster = engines || rosterDetail.candidates;
1945
2161
 
1946
- const staffed = assignEngines(staffFlight(readProjectionTasks(root), { slots }), roster);
2162
+ const staffed = assignEngines(staffedTasks, roster);
1947
2163
  // The receipt path is decided BEFORE landings so each task's ready-proof
1948
2164
  // can cite it — the proof policy certifies receipt-backed proofs agent-side.
1949
2165
  const receiptPath = path.join(root, 'atris', 'runs', `fleet-${nowStamp()}.json`);
@@ -1953,10 +2169,14 @@ async function runFleetFlight({
1953
2169
  slots,
1954
2170
  roster,
1955
2171
  staffed: staffed.map((s) => ({ task: s.task.display_id, title: String(s.task.title || '').slice(0, 140), engine: s.engine, surface: s.surface })),
2172
+ stretch_zone_pick: rosterDetail && rosterDetail.stretch_zone_pick ? rosterDetail.stretch_zone_pick : null,
1956
2173
  results: [],
1957
2174
  landed: [],
1958
2175
  paused: [],
1959
2176
  };
2177
+ if (flight.stretch_zone_pick) {
2178
+ flight.stretch_zone_note = `low-stakes staffing picked ${flight.stretch_zone_pick} from the stretch zone: the cheapest engine with a learnable track record won this lane.`;
2179
+ }
1960
2180
 
1961
2181
  log('');
1962
2182
  log(` fleet — ${roster.length} engine${roster.length === 1 ? '' : 's'} ready, ${staffed.length} task${staffed.length === 1 ? '' : 's'} staffed`);
@@ -2021,13 +2241,14 @@ async function runFleetFlight({
2021
2241
  });
2022
2242
 
2023
2243
  // Land serially: rebase-before-ship, conflict pauses (never auto-resolve).
2244
+ const rebaseArrival = rebase || landArrival;
2024
2245
  const land = lander || (({ entry }) => {
2025
- const rebased = landArrival({ worktreePath: entry.worktreePath });
2246
+ const rebased = rebaseArrival({ worktreePath: entry.worktreePath });
2026
2247
  if (!rebased.ok) return rebased;
2027
2248
  const check = focusedCheck(entry.task) || 'git log -1 --oneline';
2028
- const shipped = cli(fleetShipArgs(entry, check), entry.worktreePath);
2249
+ const shipped = shipWithRetry(cli, fleetShipArgs(entry, check), entry.worktreePath);
2029
2250
  if (shipped.status !== 0 || !/done: worktree shipped/.test(shipped.stdout)) {
2030
- return { ok: false, stage: 'ship', detail: (shipped.stderr || shipped.stdout).slice(-300) };
2251
+ return { ok: false, stage: 'ship', detail: shipFailureDetail(shipped, { head: 400, tail: 300 }) };
2031
2252
  }
2032
2253
  return { ok: true, stage: 'shipped' };
2033
2254
  });
@@ -2064,10 +2285,22 @@ async function runFleetFlight({
2064
2285
  } else {
2065
2286
  flight.paused.push({ task: entry.task.display_id, engine: activeEngine, ...(result.restaffed ? { restaffed: result.restaffed } : {}), ...landed });
2066
2287
  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`);
2288
+ log(` ${activeEngine.padEnd(8)} ⏸ paused ${entry.task.display_id} at ${landed.stage}${pausedPathsSuffix(landed)} — worktree kept`);
2068
2289
  }
2069
2290
  }
2070
2291
 
2292
+ // Teardown guard: a build worktree may have run `npm link` and hijacked the
2293
+ // global `atris`. Before reporting success, make sure this flight did not
2294
+ // leave the global CLI pointed into one of its own worktrees.
2295
+ const flightWorktrees = prepared.map((entry) => entry.worktreePath).filter(Boolean);
2296
+ const cliLink = guardCliLink({ root, worktreePaths: flightWorktrees });
2297
+ flight.cli_link = cliLink;
2298
+ if (cliLink.changed) {
2299
+ log(cliLink.ok
2300
+ ? ` restored global atris link (was ${cliLink.reason}); repointed at ${path.relative(root, cliLink.restoredTo) || cliLink.restoredTo} and the cli runs`
2301
+ : ` alert: global atris link was ${cliLink.reason}; repoint attempted at ${cliLink.restoredTo} but the cli still fails, so every cron/loop/mission is down`);
2302
+ }
2303
+
2071
2304
  fs.mkdirSync(path.dirname(receiptPath), { recursive: true });
2072
2305
  flight.receipt = receiptPath;
2073
2306
  fs.writeFileSync(flight.receipt, `${JSON.stringify(flight, null, 2)}\n`);
@@ -2351,9 +2584,9 @@ async function runDispatchFlight({
2351
2584
  change,
2352
2585
  };
2353
2586
  }
2354
- const shipped = cli(fleetShipArgs({ task: entry.task, engine: entry.engine || engine }, check), entry.worktreePath);
2587
+ const shipped = shipWithRetry(cli, fleetShipArgs({ task: entry.task, engine: entry.engine || engine }, check), entry.worktreePath);
2355
2588
  if (shipped.status !== 0 || !/done: worktree shipped/.test(shipped.stdout)) {
2356
- return { ok: false, stage: 'ship', detail: (shipped.stderr || shipped.stdout).slice(-500) };
2589
+ return { ok: false, stage: 'ship', detail: shipFailureDetail(shipped, { head: 400, tail: 500 }) };
2357
2590
  }
2358
2591
  return {
2359
2592
  ok: true,
@@ -2736,7 +2969,7 @@ async function runDispatchFlight({
2736
2969
  ...landed,
2737
2970
  });
2738
2971
  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`);
2972
+ log(` ⏸ paused ${entry.taskId} at ${landed.stage}${pausedPathsSuffix(landed)} — worktree kept`);
2740
2973
  }
2741
2974
  }
2742
2975
 
@@ -179,11 +179,8 @@ function resolveFunctionalOwner({ requestedOwner, title, tag, note, goal, root,
179
179
  }
180
180
 
181
181
  module.exports = {
182
- ENGINE_OWNER_IDS,
183
182
  FUNCTIONAL_MEMBER_TOPICS,
184
183
  normalizeOwnerSlug,
185
- isEngineOwner,
186
184
  listWorkspaceMemberSlugs,
187
- proposedMemberSlugFromTask,
188
185
  resolveFunctionalOwner,
189
186
  };
@@ -0,0 +1,15 @@
1
+ 'use strict';
2
+
3
+ const { spawnSync } = require('child_process');
4
+
5
+ // One synchronous git runner for commands that shell out to git.
6
+ // check: true throws on a non-zero exit; check: false hands back the raw result.
7
+ function runGit(args, { cwd = process.cwd(), check = true, maxBuffer } = {}) {
8
+ const result = spawnSync('git', args, { cwd, encoding: 'utf8', ...(maxBuffer ? { maxBuffer } : {}) });
9
+ if (check && result.status !== 0) {
10
+ throw new Error(`git ${args.join(' ')} failed: ${(result.stderr || result.stdout || '').trim()}`);
11
+ }
12
+ return result;
13
+ }
14
+
15
+ module.exports = { runGit };
@@ -168,7 +168,6 @@ module.exports = {
168
168
  missionAckInspectValue,
169
169
  missionPingsInspectValue,
170
170
  missionInspectFieldValues,
171
- formatInspectFieldText,
172
171
  inspectTextLines,
173
172
  buildInspectPayload,
174
173
  };
@@ -0,0 +1,37 @@
1
+ // Shared JSON file read/write so every call site fails the same way.
2
+ //
3
+ // Bare `JSON.parse(fs.readFileSync(...))` drifted across commands: some sites
4
+ // caught, some returned null, some returned {}. These two helpers keep the
5
+ // tolerant shape in one place.
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+
10
+ /**
11
+ * Read and parse a JSON file, returning `fallback` when it is missing or corrupt.
12
+ * @param {string} filePath
13
+ * @param {*} [fallback=null]
14
+ * @returns {*}
15
+ */
16
+ function readJson(filePath, fallback = null) {
17
+ try {
18
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
19
+ } catch {
20
+ return fallback;
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Write a value as pretty JSON with a trailing newline, creating parent dirs.
26
+ * @param {string} filePath
27
+ * @param {*} value
28
+ * @param {{ indent?: number }} [options]
29
+ * @returns {string} the path written
30
+ */
31
+ function writeJson(filePath, value, { indent = 2 } = {}) {
32
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
33
+ fs.writeFileSync(filePath, JSON.stringify(value, null, indent) + '\n', 'utf8');
34
+ return filePath;
35
+ }
36
+
37
+ module.exports = { readJson, writeJson };
@@ -2,8 +2,8 @@
2
2
 
3
3
  const knownCommands = ['init', 'log', 'logs', 'wish', 'drill', 'dream', 'now', 'goal', 'wtf', 'orb', 'radar', 'stream', 'ctop', 'launchpad', 'status', 'analytics', 'visualize', 'brain', 'brainstorm', 'autopilot', 'run', '_start', 'plan', 'do', 'review', 'release',
4
4
  'activate', '_activate', 'agent', 'team', 'chat', 'fast', 'ax', 'console', 'serve', 'login', 'logout', 'whoami', 'switch', 'use', 'accounts', '_resolve', '_profile-email', '_switch-session', 'shell-init', 'update', 'upgrade', 'version', 'help', 'next', 'atris',
5
- 'clean', 'close', 'harvest', 'verify', 'search', 'scout', 'skill', 'member', 'codex-goal', 'app', 'apps', 'learn', 'lesson', 'plugin', 'experiments', 'bench', 'receipt', 'proof', 'openclaw', 'pull', 'push', 'watch', 'cloud', 'live', 'align', 'terminal', 'computer', 'diff', 'business', 'sync', 'youtube',
6
- 'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'agents', 'probe', 'worktree', 'land', 'autoland', 'drive', 'aeo', 'slop', 'strings', 'write', 'security-review', 'secure', 'deck', 'site', 'theme', 'card', 'reel', 'improve', 'study', 'rainmaker', 'xp', 'play', 'gm', 'x', 'recap', 'report', 'signup', 'clarity', 'interview', 'meet', 'moves', 'unknowns', 'avail',
5
+ 'clean', 'close', 'harvest', 'verify', 'recover', 'search', 'scout', 'skill', 'member', 'codex-goal', 'app', 'apps', 'learn', 'lesson', 'taste', 'teach', 'plugin', 'experiments', 'bench', 'router', 'receipt', 'proof', 'openclaw', 'pull', 'push', 'watch', 'cloud', 'live', 'align', 'terminal', 'computer', 'diff', 'business', 'sync', 'youtube',
6
+ 'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'decide', 'agents', 'probe', 'worktree', 'land', 'autoland', 'drive', 'aeo', 'slop', 'voice', 'strings', 'write', 'security-review', 'secure', 'deck', 'site', 'theme', 'card', 'reel', 'improve', 'study', 'rainmaker', 'xp', 'play', 'gm', 'game', 'x', 'recap', 'report', 'signup', 'clarity', 'interview', 'meet', 'moves', 'unknowns', 'avail', 'sync-checkout',
7
7
  'github', 'vercel', 'supabase', 'linear', 'stripe', 'gmail', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
8
8
  'fork', 'browse', 'publish', 'pack', 'sleep', 'wake', 'feedback', 'errors', 'wiki', 'code-review', 'cr', 'soul', 'fleet', 'fleet-report', 'loops', 'self-improve', 'compile', 'spaceship', 'truth', 'sign', 'engine', 'engines', 'feed', 'brief'];
9
9
 
package/lib/learnings.js CHANGED
@@ -222,8 +222,6 @@ function exportMarkdown() {
222
222
  module.exports = {
223
223
  TYPES,
224
224
  SOURCES,
225
- getLearningsPath,
226
- ensureLearningsFile,
227
225
  effectiveConfidence,
228
226
  loadLearnings,
229
227
  addLearning,