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
@@ -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
+ };
@@ -28,7 +28,13 @@ function hasContextProfile(root = process.cwd()) {
28
28
  function compactText(value, max = 160) {
29
29
  const text = String(value || '').replace(/\s+/g, ' ').trim();
30
30
  if (!text) return '';
31
- return text.length > max ? `${text.slice(0, Math.max(0, max - 3)).trim()}...` : text;
31
+ if (text.length <= max) return text;
32
+ const slice = text.slice(0, Math.max(0, max - 3));
33
+ const lastSpace = slice.lastIndexOf(' ');
34
+ // Cut on a word boundary so a title never ends on half a word ("...an autonomo...").
35
+ // Fall back to the hard slice only when a single word already fills the limit.
36
+ const body = lastSpace > 0 ? slice.slice(0, lastSpace) : slice;
37
+ return `${body.replace(/[\s,;:.!?-]+$/, '')}...`;
32
38
  }
33
39
 
34
40
  function inferDomain(answer) {
@@ -173,6 +179,5 @@ module.exports = {
173
179
  renderPrompt,
174
180
  starterTaskTitle,
175
181
  inferDomain,
176
- normalizeQuestionText,
177
182
  isAtrisMetaQuestion,
178
183
  };
@@ -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,
@@ -2,7 +2,7 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
- const { spawnSync } = require('child_process');
5
+ const childProcess = require('child_process');
6
6
  const {
7
7
  RUNNER_PROFILE_DEFS,
8
8
  RUNNER_PROFILE_ALIASES,
@@ -13,30 +13,33 @@ const { rankEnginesDetailed, routerPickExplanation } = require('./router-brain')
13
13
  const ENGINE_REGISTRY_SCHEMA = 'atris.engine_registry.v2';
14
14
  const ENGINE_TIERS = Object.freeze(['fast', 'pro', 'max']);
15
15
  const ENGINE_ROLES = Object.freeze(['navigator', 'executor', 'validator']);
16
+ const ENGINE_DUTIES = Object.freeze(['leader', 'errands', 'learning']);
16
17
  const ENGINE_HEALTH_STATUSES = Object.freeze(['ready', 'not_installed', 'credit_out', 'error']);
17
18
 
18
19
  const ENGINE_SEED_META = Object.freeze({
19
- 'atris-fast': Object.freeze({ tier: 'fast', roles: Object.freeze(['navigator']), fallback_order: 10 }),
20
- codex: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), fallback_order: 10 }),
21
- claude: Object.freeze({ tier: 'max', roles: Object.freeze(['validator', 'executor']), fallback_order: 20 }),
22
- cursor: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), fallback_order: 30 }),
23
- devin: Object.freeze({ tier: 'max', roles: Object.freeze(['executor']), fallback_order: 40 }),
24
- grok: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), fallback_order: 45 }),
25
- fable: Object.freeze({ tier: 'max', roles: Object.freeze(['validator', 'executor']), fallback_order: 50 }),
26
- composer: Object.freeze({ tier: 'fast', roles: Object.freeze(['navigator', 'executor']), fallback_order: 60 }),
27
- haiku: Object.freeze({ tier: 'fast', roles: Object.freeze(['validator']), fallback_order: 70 }),
28
- hermes: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), fallback_order: 80 }),
29
- droid: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), fallback_order: 90 }),
20
+ 'atris-fast': Object.freeze({ tier: 'fast', roles: Object.freeze(['navigator']), models: Object.freeze(['atris fast']), duty: 'learning', fallback_order: 10 }),
21
+ codex: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), models: Object.freeze(['codex']), fallback_order: 10 }),
22
+ claude: Object.freeze({ tier: 'max', roles: Object.freeze(['validator', 'executor']), models: Object.freeze(['opus 5', 'opus 4.8', 'fable', 'haiku']), fallback_order: 20 }),
23
+ cursor: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), models: Object.freeze(['composer 2.5', 'grok 4.5', 'kimi 3']), fallback_order: 30 }),
24
+ devin: Object.freeze({ tier: 'max', roles: Object.freeze(['executor']), models: Object.freeze(['built-in router']), duty: 'errands', fallback_order: 40 }),
25
+ grok: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), models: Object.freeze(['grok 4.5']), fallback_order: 45 }),
26
+ fable: Object.freeze({ tier: 'max', roles: Object.freeze(['validator', 'executor']), models: Object.freeze(['opus 5', 'opus 4.8', 'fable', 'haiku']), duty: 'leader', fallback_order: 50 }),
27
+ composer: Object.freeze({ tier: 'fast', roles: Object.freeze(['navigator', 'executor']), models: Object.freeze(['composer 2.5']), fallback_order: 60 }),
28
+ haiku: Object.freeze({ tier: 'fast', roles: Object.freeze(['validator']), models: Object.freeze(['haiku']), fallback_order: 70 }),
29
+ droid: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), models: Object.freeze(['built-in router']), duty: 'errands', fallback_order: 90 }),
30
30
  });
31
31
 
32
32
  function engineRegistryFile(root = process.cwd()) {
33
33
  return path.join(root, '.atris', 'state', 'engines.json');
34
34
  }
35
35
 
36
+ // Machine probe. Routing never calls this on a settled registry: it runs once
37
+ // when an engine first appears (seeding the policy file), at the execution
38
+ // stage right before a spawn, and on the explicit `atris engine doctor`.
36
39
  function binInstalled(bin) {
37
40
  const safe = String(bin || '').replace(/[^A-Za-z0-9_.-]/g, '');
38
41
  if (!safe) return false;
39
- const probe = spawnSync('sh', ['-c', `command -v ${safe}`], { encoding: 'utf8' });
42
+ const probe = childProcess.spawnSync('sh', ['-c', `command -v ${safe}`], { encoding: 'utf8' });
40
43
  return probe.status === 0 && Boolean(String(probe.stdout || '').trim());
41
44
  }
42
45
 
@@ -68,6 +71,18 @@ function normalizeRoles(value, fallback = ['executor']) {
68
71
  return filtered.length ? filtered : fallback;
69
72
  }
70
73
 
74
+ function normalizeModels(value, fallback = []) {
75
+ const models = Array.isArray(value) ? value.map((model) => String(model || '').trim()) : [];
76
+ const filtered = models.filter((model, index) => model && models.indexOf(model) === index);
77
+ return filtered.length ? filtered : fallback;
78
+ }
79
+
80
+ function normalizeDuty(value, fallback = '') {
81
+ if (value === undefined) return fallback || '';
82
+ const duty = String(value || '').trim();
83
+ return ENGINE_DUTIES.includes(duty) ? duty : '';
84
+ }
85
+
71
86
  function normalizeFallbackOrder(value, fallback) {
72
87
  const order = Number(value);
73
88
  return Number.isInteger(order) ? order : fallback;
@@ -75,22 +90,32 @@ function normalizeFallbackOrder(value, fallback) {
75
90
 
76
91
  function normalizeEngineEntry(id, saved = {}) {
77
92
  const def = RUNNER_PROFILE_DEFS[id];
78
- const seed = ENGINE_SEED_META[id] || { tier: 'pro', roles: ['executor'], fallback_order: 100 };
79
- const installed = binInstalled(def.bin);
93
+ const seed = ENGINE_SEED_META[id] || { tier: 'pro', roles: ['executor'], models: [id], fallback_order: 100 };
80
94
  const savedHealth = saved && saved.health && typeof saved.health === 'object' ? saved.health : {};
81
95
  const savedStatus = String(savedHealth.status || '').trim();
82
- const savedFailure = savedStatus === 'credit_out'
83
- || savedStatus === 'error'
84
- || (savedStatus === 'not_installed' && Boolean(savedHealth.last_failure_ts));
85
- const status = savedFailure ? savedStatus : (installed ? 'ready' : 'not_installed');
96
+ // Policy over probes: a saved health status is the routing truth, verbatim.
97
+ // Only an engine the registry has never seen gets one seeding probe; after
98
+ // that, installed-state changes flow through `atris engine doctor` (or
99
+ // `atris engine health`), never through the resolve path.
100
+ let status;
101
+ let installed;
102
+ if (ENGINE_HEALTH_STATUSES.includes(savedStatus)) {
103
+ status = savedStatus;
104
+ installed = typeof saved.installed === 'boolean' ? saved.installed : status !== 'not_installed';
105
+ } else {
106
+ installed = binInstalled(def.bin);
107
+ status = installed ? 'ready' : 'not_installed';
108
+ }
86
109
  const health = { status };
87
- if (savedFailure && savedHealth.last_failure_ts) health.last_failure_ts = String(savedHealth.last_failure_ts);
110
+ if (status !== 'ready' && savedHealth.last_failure_ts) health.last_failure_ts = String(savedHealth.last_failure_ts);
88
111
  return {
89
112
  id,
90
113
  name: id,
91
114
  bin: def.bin,
92
115
  tier: normalizeTier(saved.tier, seed.tier),
93
116
  roles: normalizeRoles(saved.roles, Array.from(seed.roles)),
117
+ models: normalizeModels(saved.models, Array.from(seed.models)),
118
+ duty: normalizeDuty(saved.duty, seed.duty),
94
119
  fallback_order: normalizeFallbackOrder(saved.fallback_order, seed.fallback_order),
95
120
  installed,
96
121
  health,
@@ -118,6 +143,62 @@ function writeEngineRegistry(root, registry) {
118
143
  fs.writeFileSync(file, `${JSON.stringify(registry, null, 2)}\n`, 'utf8');
119
144
  }
120
145
 
146
+ function setEngineOverrides(name, overrides = {}, root = process.cwd()) {
147
+ const id = canonicalEngineName(name);
148
+ const knownIds = RUNNER_PROFILE_NAMES.filter((engineId) => ENGINE_SEED_META[engineId]);
149
+ if (!id || !knownIds.includes(id)) {
150
+ throw new Error(`Unknown engine "${name}". Known engines: ${knownIds.join(', ')}`);
151
+ }
152
+
153
+ const nextOverrides = {};
154
+ if (Object.prototype.hasOwnProperty.call(overrides, 'duty')) {
155
+ const duty = String(overrides.duty || '').trim();
156
+ if (!ENGINE_DUTIES.includes(duty)) {
157
+ throw new Error(`Unknown duty "${overrides.duty}". Known duties: ${ENGINE_DUTIES.join(', ')}`);
158
+ }
159
+ nextOverrides.duty = duty;
160
+ }
161
+ if (Object.prototype.hasOwnProperty.call(overrides, 'models')) {
162
+ const models = normalizeModels(overrides.models, []);
163
+ if (!models.length) throw new Error('models must include at least one name');
164
+ nextOverrides.models = models;
165
+ }
166
+ if (!Object.keys(nextOverrides).length) throw new Error('set requires --duty or --models');
167
+
168
+ const file = engineRegistryFile(root);
169
+ const raw = readRawRegistry(file);
170
+ const savedById = new Map();
171
+ for (const entry of raw.engines || []) {
172
+ const savedId = canonicalEngineName(entry && (entry.id || entry.name));
173
+ if (savedId && knownIds.includes(savedId)) savedById.set(savedId, entry);
174
+ }
175
+
176
+ if (nextOverrides.duty === 'leader' || nextOverrides.duty === 'learning') {
177
+ for (const engineId of knownIds) {
178
+ if (engineId === id) continue;
179
+ const saved = savedById.get(engineId) || {};
180
+ const effectiveDuty = Object.prototype.hasOwnProperty.call(saved, 'duty')
181
+ ? normalizeDuty(saved.duty, '')
182
+ : normalizeDuty(undefined, ENGINE_SEED_META[engineId].duty);
183
+ if (effectiveDuty === nextOverrides.duty) {
184
+ savedById.set(engineId, { ...saved, id: engineId, name: engineId, duty: '' });
185
+ }
186
+ }
187
+ }
188
+
189
+ const saved = savedById.get(id) || {};
190
+ savedById.set(id, { ...saved, id, name: id, ...nextOverrides });
191
+ const ordered = knownIds.map((engineId) => savedById.get(engineId)).filter(Boolean);
192
+ const next = {
193
+ ...raw,
194
+ schema: ENGINE_REGISTRY_SCHEMA,
195
+ updated_at: new Date().toISOString(),
196
+ engines: ordered,
197
+ };
198
+ writeEngineRegistry(root, next);
199
+ return { id, ...nextOverrides };
200
+ }
201
+
121
202
  function readEngineRegistry(root = process.cwd(), options = {}) {
122
203
  const registry = seededRegistry(root);
123
204
  if (options.persist !== false) writeEngineRegistry(root, registry);
@@ -144,7 +225,7 @@ function resolveRegisteredEngine(name, root = process.cwd()) {
144
225
  return engine;
145
226
  }
146
227
 
147
- function resolveEngineForRole(role, root = process.cwd(), options = {}) {
228
+ function resolveEngineForRoleRanked(role, root = process.cwd(), options = {}) {
148
229
  const normalizedRole = String(role || '').trim().toLowerCase();
149
230
  if (!ENGINE_ROLES.includes(normalizedRole)) {
150
231
  throw new Error(`Unknown role "${role}". Known roles: ${ENGINE_ROLES.join(', ')}`);
@@ -159,12 +240,24 @@ function resolveEngineForRole(role, root = process.cwd(), options = {}) {
159
240
  const ranked = rankEnginesDetailed(engines, {
160
241
  root,
161
242
  taskType: options.taskType || options.task_type || normalizedRole,
243
+ lowStakes: options.lowStakes,
244
+ stakes: options.stakes,
162
245
  });
163
- const selected = ranked.candidates[0] || null;
164
- if (selected && process.env.ATRIS_ROUTER_EXPLAIN === '1') {
165
- console.error(routerPickExplanation(ranked));
246
+ return {
247
+ engine: ranked.candidates[0] || null,
248
+ reason: routerPickExplanation(ranked),
249
+ ranked: ranked.candidates,
250
+ };
251
+ }
252
+
253
+ function resolveEngineForRole(role, root = process.cwd(), options = {}) {
254
+ const picked = resolveEngineForRoleRanked(role, root, options);
255
+ // every pick logs one plain sentence saying which engine won and why;
256
+ // set ATRIS_ROUTER_EXPLAIN=0 to silence it in quiet contexts.
257
+ if (picked.engine && picked.reason && process.env.ATRIS_ROUTER_EXPLAIN !== '0') {
258
+ console.error(picked.reason);
166
259
  }
167
- return selected;
260
+ return picked.engine;
168
261
  }
169
262
 
170
263
  function resolveEngineForRoleWithPreference(role, root = process.cwd(), preferredEngineId = '', options = {}) {
@@ -214,19 +307,55 @@ function setEngineHealth(name, status, root = process.cwd()) {
214
307
  return engines.find((engine) => engine.id === id);
215
308
  }
216
309
 
310
+ // Execution-stage guard. Routing hands out engines from policy without ever
311
+ // touching the machine, so the moment we are about to spawn one is where a
312
+ // missing binary has to fail loudly, in one plain sentence naming the binary.
313
+ function requireEngineBin(engineOrId) {
314
+ const id = typeof engineOrId === 'string'
315
+ ? canonicalEngineName(engineOrId)
316
+ : canonicalEngineName(engineOrId && engineOrId.id);
317
+ const def = RUNNER_PROFILE_DEFS[id];
318
+ if (!def) {
319
+ throw new Error(`Unknown engine "${engineOrId && engineOrId.id ? engineOrId.id : engineOrId}". Known engines: ${RUNNER_PROFILE_NAMES.join(', ')}`);
320
+ }
321
+ if (!binInstalled(def.bin)) {
322
+ throw new Error(`${id} CLI (${def.bin}) is not installed here, so this run cannot start.`);
323
+ }
324
+ return def.bin;
325
+ }
326
+
327
+ // The explicit opt-in probe pass: check every engine binary on this machine,
328
+ // fold the result back into the policy file (a ready/not_installed flip only;
329
+ // credit_out and error are operator policy and survive), and report.
330
+ function engineDoctorReport(root = process.cwd()) {
331
+ const registry = readEngineRegistry(root, { persist: false });
332
+ const engines = registry.engines.map((engine) => {
333
+ const installed = binInstalled(engine.bin);
334
+ let health = engine.health && engine.health.status ? engine.health : { status: installed ? 'ready' : 'not_installed' };
335
+ if (installed && health.status === 'not_installed') health = { status: 'ready' };
336
+ if (!installed && health.status === 'ready') health = { status: 'not_installed' };
337
+ return { ...engine, installed, health };
338
+ });
339
+ const next = { ...registry, updated_at: new Date().toISOString(), engines };
340
+ writeEngineRegistry(root, next);
341
+ return engines;
342
+ }
343
+
217
344
  module.exports = {
218
- ENGINE_REGISTRY_SCHEMA,
219
- ENGINE_TIERS,
220
345
  ENGINE_ROLES,
346
+ ENGINE_DUTIES,
221
347
  ENGINE_HEALTH_STATUSES,
222
348
  engineRegistryFile,
223
349
  binInstalled,
224
350
  canonicalEngineName,
225
351
  readEngineRegistry,
352
+ requireEngineBin,
353
+ engineDoctorReport,
226
354
  engineRegistryView,
227
- registeredEngineIds,
228
355
  resolveRegisteredEngine,
356
+ resolveEngineForRoleRanked,
229
357
  resolveEngineForRole,
230
358
  resolveEngineForRoleWithPreference,
359
+ setEngineOverrides,
231
360
  setEngineHealth,
232
361
  };
@@ -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,
@@ -0,0 +1,84 @@
1
+ 'use strict';
2
+
3
+ // A check that cannot fail is not a check. `task ready --verify` runs a command
4
+ // and accepts the work on exit 0, which catches a command that fails but never
5
+ // catches one that could not have failed. The 2026-07-26 reward-ledger audit
6
+ // found 131 of 802 accepted proofs carrying nothing falsifiable.
7
+ //
8
+ // The probe: run the same command again in an empty directory, with none of
9
+ // the work present. A check anchored to this codebase fails there. One that
10
+ // passes anywhere - `true`, `echo done`, a check of something outside the
11
+ // repo - passes there too, and proves nothing about the work.
12
+ //
13
+ // Scope, stated plainly: this asks whether a check depends on the codebase at
14
+ // all. It does not ask whether the check exercises the change. `git diff
15
+ // --check` fails in an empty directory and still says nothing about whether
16
+ // the code works, so it clears this probe. The probe raises the floor; it is
17
+ // not a ceiling.
18
+
19
+ const fs = require('fs');
20
+ const os = require('os');
21
+ const path = require('path');
22
+ const { spawnSync } = require('child_process');
23
+
24
+ const PROBE_TIMEOUT_MS = 60_000;
25
+
26
+ // An absolute path reaches back into a real checkout no matter where the
27
+ // command runs, so the empty directory proves nothing about it. Say so rather
28
+ // than reporting a false verdict.
29
+ function absolutePathIn(command) {
30
+ const match = String(command || '').match(/(?:^|[\s"'`=(])(\/[^\s"'`)]+)/);
31
+ return match ? match[1] : '';
32
+ }
33
+
34
+ function probeVerifierCanFail({ command, runner, tmpRoot } = {}) {
35
+ const cmd = String(command || '').trim();
36
+ if (!cmd) return { probed: false, canFail: null, reason: 'no command to probe' };
37
+
38
+ const absolute = absolutePathIn(cmd);
39
+ if (absolute) {
40
+ return {
41
+ probed: false,
42
+ canFail: null,
43
+ reason: `not probed: the command reaches an absolute path (${absolute}), so running it away from this checkout proves nothing`,
44
+ };
45
+ }
46
+
47
+ const dir = fs.mkdtempSync(path.join(tmpRoot || os.tmpdir(), 'atris-falsifier-probe-'));
48
+ try {
49
+ const run = runner || spawnSync;
50
+ const result = run('bash', ['-lc', cmd], {
51
+ cwd: dir,
52
+ encoding: 'utf8',
53
+ timeout: PROBE_TIMEOUT_MS,
54
+ });
55
+ if (result.error) {
56
+ const timedOut = /ETIMEDOUT/i.test(String(result.error.code || result.error.message || ''));
57
+ return {
58
+ probed: false,
59
+ canFail: null,
60
+ reason: timedOut
61
+ ? `not probed: the command ran past ${PROBE_TIMEOUT_MS / 1000}s with none of the work present`
62
+ : `not probed: the command could not start away from this checkout (${result.error.message})`,
63
+ };
64
+ }
65
+ if (result.status === 0) {
66
+ return {
67
+ probed: true,
68
+ canFail: false,
69
+ exit: 0,
70
+ reason: 'this check passes in an empty directory, with none of the work present, so passing here says nothing about the work',
71
+ };
72
+ }
73
+ return {
74
+ probed: true,
75
+ canFail: true,
76
+ exit: result.status,
77
+ reason: 'this check fails when the work is absent, so passing it means something',
78
+ };
79
+ } finally {
80
+ fs.rmSync(dir, { recursive: true, force: true });
81
+ }
82
+ }
83
+
84
+ module.exports = { probeVerifierCanFail, absolutePathIn, PROBE_TIMEOUT_MS };