atris 3.37.1 → 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 +120 -38
  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 +68 -7
  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,340 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { spawnSync } = require('node:child_process');
6
+ const { reasonClass, HUMAN_BLOCKING_REASONS } = require('../lib/self-drive');
7
+ const { missionLockRecords } = require('../lib/loop-doctor');
8
+ const {
9
+ loadMissionMap,
10
+ acquireMissionLock,
11
+ releaseMissionLock,
12
+ } = require('./mission');
13
+
14
+ const RECOVERY_CATALOG = [
15
+ {
16
+ id: 'max-ticks-reached',
17
+ matches: (reason) => reason === 'max-ticks-reached',
18
+ happened: 'the run used its tick allowance before the mission finished',
19
+ apply: 'resume',
20
+ },
21
+ {
22
+ id: 'verifier-failed',
23
+ matches: (reason) => reason === 'verifier-failed' || reason === 'verifier_failed',
24
+ happened: 'the mission verifier failed',
25
+ apply: 'verifier',
26
+ },
27
+ {
28
+ id: 'repeated-error:claude-error',
29
+ matches: (reason) => reason === 'repeated-error:claude-error' || reason.startsWith('repeated-error:'),
30
+ happened: 'the worker returned the same error twice',
31
+ apply: null,
32
+ },
33
+ {
34
+ id: 'aborted-during-claude',
35
+ matches: (reason) => reason === 'aborted-during-claude',
36
+ happened: 'the worker was interrupted during its tick',
37
+ apply: 'resume',
38
+ },
39
+ {
40
+ id: 'aborted',
41
+ matches: (reason) => reason === 'aborted',
42
+ happened: 'the mission run was interrupted',
43
+ apply: 'resume',
44
+ },
45
+ {
46
+ id: 'stuck-repeating',
47
+ matches: (reason) => ['stuck-repeating', 'repeated-pause-reason', 'repeated_pause_reason'].includes(reason),
48
+ happened: 'the worker kept returning the same pause or summary',
49
+ apply: 'resume',
50
+ },
51
+ {
52
+ id: 'lock-busy',
53
+ matches: (reason) => ['lock-busy', 'stale-mission-lock', 'stale_mission_lock'].includes(reason),
54
+ happened: 'a mission tick lock prevented another tick from starting',
55
+ apply: null,
56
+ },
57
+ {
58
+ id: 'auth-required',
59
+ matches: (reason) => reason === 'auth-required',
60
+ happened: 'the runner needs an operator login',
61
+ apply: null,
62
+ },
63
+ {
64
+ id: 'model-unavailable',
65
+ matches: (reason) => reason === 'model-unavailable',
66
+ happened: 'the configured model is unavailable',
67
+ apply: null,
68
+ },
69
+ {
70
+ id: 'rate-limit-exceeded-wall',
71
+ matches: (reason) => reason === 'rate-limit-exceeded-wall',
72
+ happened: 'the rate-limit reset falls outside this run window',
73
+ apply: null,
74
+ },
75
+ ];
76
+
77
+ function classifyRecovery(value) {
78
+ const reason = reasonClass(value);
79
+ const entry = RECOVERY_CATALOG.find((candidate) => candidate.matches(reason));
80
+ if (entry) {
81
+ return {
82
+ catalog_entry: entry.id,
83
+ reason,
84
+ happened: entry.happened,
85
+ apply: entry.apply,
86
+ human_blocking: HUMAN_BLOCKING_REASONS.has(reason),
87
+ };
88
+ }
89
+ return {
90
+ catalog_entry: 'unknown',
91
+ reason,
92
+ happened: `the mission stopped for "${reason}"`,
93
+ apply: null,
94
+ human_blocking: HUMAN_BLOCKING_REASONS.has(reason),
95
+ };
96
+ }
97
+
98
+ function missionStopReason(mission) {
99
+ if (mission.stop_reason) return mission.stop_reason;
100
+ if (mission.pause_reason) return mission.pause_reason;
101
+ if (mission.status === 'blocked' && mission.verifier_result?.passed === false) return 'verifier-failed';
102
+ return mission.last_tick_reason || mission.status || 'blocked';
103
+ }
104
+
105
+ function manualRecoveryCommand(row) {
106
+ const id = row.mission_id;
107
+ switch (row.catalog_entry) {
108
+ case 'auth-required':
109
+ return 'atris login';
110
+ case 'model-unavailable':
111
+ return `atris mission set-runner ${id} auto`;
112
+ case 'rate-limit-exceeded-wall':
113
+ return `atris mission run ${id}`;
114
+ case 'repeated-error:claude-error':
115
+ return `atris mission run ${id}`;
116
+ case 'lock-busy':
117
+ return `atris mission status ${id} --json`;
118
+ default:
119
+ return `atris mission status ${id} --json`;
120
+ }
121
+ }
122
+
123
+ function recoveryCommand(row) {
124
+ if (row.apply === 'stale-lock') return `atris recover ${row.mission_id} --apply`;
125
+ if (row.human_blocking || !row.apply) return manualRecoveryCommand(row);
126
+ return `atris recover ${row.mission_id} --apply`;
127
+ }
128
+
129
+ function missionRows(root) {
130
+ return [...loadMissionMap(root).values()]
131
+ .filter((mission) => ['blocked', 'paused'].includes(String(mission.status || '').toLowerCase()))
132
+ .map((mission) => {
133
+ const classified = classifyRecovery(missionStopReason(mission));
134
+ const verifier = String(mission.verifier || mission.effective_verifier || '').trim();
135
+ const missingVerifier = classified.apply === 'verifier' && !verifier;
136
+ const hasHumanAsk = Array.isArray(mission.human_asks) && mission.human_asks.length > 0;
137
+ const operatorRequired = missingVerifier || hasHumanAsk;
138
+ return {
139
+ mission_id: mission.id,
140
+ mission: mission.objective || mission.id,
141
+ status: mission.status,
142
+ receipt_path: mission.receipt_path || null,
143
+ next_action: mission.next_action || null,
144
+ verifier: verifier || null,
145
+ ...classified,
146
+ ...(operatorRequired ? {
147
+ apply: null,
148
+ operator_required: true,
149
+ happened: hasHumanAsk
150
+ ? `${classified.happened}, and the mission has a human ask`
151
+ : `${classified.happened}, but no verifier command is configured`,
152
+ } : {}),
153
+ };
154
+ });
155
+ }
156
+
157
+ function lockRows(root) {
158
+ return missionLockRecords(root).map((lock) => {
159
+ const match = String(lock.lock || '').match(/^mission-(.+)[.]lock$/);
160
+ const missionId = match ? match[1] : String(lock.lock || '').replace(/[.]lock$/, '');
161
+ const classified = classifyRecovery('stale_mission_lock');
162
+ const stale = lock.stale === true;
163
+ return {
164
+ mission_id: missionId,
165
+ mission: missionId,
166
+ status: 'lock busy',
167
+ receipt_path: null,
168
+ next_action: null,
169
+ ...classified,
170
+ happened: stale
171
+ ? classified.happened
172
+ : `the mission tick lock is held by live pid ${lock.pid}`,
173
+ apply: stale ? 'stale-lock' : null,
174
+ operator_required: !stale,
175
+ stale_lock: {
176
+ file: lock.lock,
177
+ pid: lock.pid,
178
+ reason: lock.reason,
179
+ },
180
+ };
181
+ });
182
+ }
183
+
184
+ function selectRows(root, ref = '') {
185
+ const selected = [...lockRows(root), ...missionRows(root)];
186
+ if (!ref) return selected;
187
+ const needle = String(ref).trim();
188
+ return selected.filter((row) => row.mission_id === needle || row.mission_id.includes(needle));
189
+ }
190
+
191
+ function runMissionTick(root, row, verify) {
192
+ const cli = path.join(__dirname, '..', 'bin', 'atris.js');
193
+ const args = [
194
+ cli,
195
+ 'mission',
196
+ 'tick',
197
+ row.mission_id,
198
+ ...(verify ? ['--verify'] : []),
199
+ '--summary',
200
+ verify ? 'recovery reran the mission verifier' : 'recovery resumed the paused mission',
201
+ '--json',
202
+ ];
203
+ return spawnSync(process.execPath, args, {
204
+ cwd: root,
205
+ encoding: 'utf8',
206
+ timeout: 120000,
207
+ env: { ...process.env, ATRIS_SKIP_UPDATE_CHECK: '1' },
208
+ });
209
+ }
210
+
211
+ function applyStaleLock(root, row) {
212
+ const lock = acquireMissionLock(row.mission_id, root);
213
+ if (!lock.ok) {
214
+ return {
215
+ outcome: 'needs operator',
216
+ detail: `lock is still held by pid ${lock.holder?.pid || '?'}`,
217
+ };
218
+ }
219
+ releaseMissionLock(lock);
220
+ const lockPath = path.join(root, '.atris', 'state', row.stale_lock.file);
221
+ return fs.existsSync(lockPath)
222
+ ? { outcome: 'failed', detail: 'stale lock still exists' }
223
+ : { outcome: 'recovered', detail: 'cleared the stale mission tick lock' };
224
+ }
225
+
226
+ function applyMissionRow(root, row) {
227
+ if (row.human_blocking || row.operator_required) {
228
+ return { outcome: 'needs operator', detail: row.happened };
229
+ }
230
+ if (row.apply === 'stale-lock') return applyStaleLock(root, row);
231
+ if (!row.apply) {
232
+ return { outcome: 'not applied', detail: 'the cause must be inspected before another run' };
233
+ }
234
+ if (row.apply === 'resume' && row.status !== 'paused') {
235
+ return { outcome: 'not applied', detail: 'only paused missions are resumed automatically' };
236
+ }
237
+ const run = runMissionTick(root, row, row.apply === 'verifier');
238
+ if (run.error || run.status !== 0) {
239
+ return {
240
+ outcome: 'failed',
241
+ detail: String(run.error?.message || run.stderr || run.stdout || 'mission tick failed').trim(),
242
+ };
243
+ }
244
+ const mission = loadMissionMap(root).get(row.mission_id) || null;
245
+ if (row.apply === 'verifier' && mission?.verifier_result?.passed !== true) {
246
+ return {
247
+ outcome: 'still blocked',
248
+ detail: 'the verifier still fails',
249
+ end_state: mission,
250
+ };
251
+ }
252
+ return {
253
+ outcome: 'recovered',
254
+ detail: row.apply === 'verifier' ? 'the verifier passed' : 'the mission resumed',
255
+ end_state: mission,
256
+ };
257
+ }
258
+
259
+ function writeRecoveryReceipt(root, results) {
260
+ const runs = path.join(root, 'atris', 'runs');
261
+ fs.mkdirSync(runs, { recursive: true });
262
+ const at = new Date().toISOString();
263
+ const name = `recover-${at.replace(/[:.]/g, '-')}-${process.pid}.json`;
264
+ const relative = path.join('atris', 'runs', name);
265
+ fs.writeFileSync(path.join(root, relative), `${JSON.stringify({
266
+ schema: 'atris.recovery_receipt.v1',
267
+ at,
268
+ applied: true,
269
+ results: results.map((row) => ({
270
+ mission_id: row.mission_id,
271
+ catalog_entry: row.catalog_entry,
272
+ previous_status: row.status,
273
+ outcome: row.outcome,
274
+ detail: row.detail,
275
+ end_status: row.end_state?.status || row.status,
276
+ mission_receipt: row.end_state?.receipt_path || row.receipt_path || null,
277
+ next_action: row.end_state?.next_action || row.next_action || recoveryCommand(row),
278
+ })),
279
+ }, null, 2)}\n`);
280
+ return relative;
281
+ }
282
+
283
+ function textLines(rows, receiptPath, applying) {
284
+ if (!rows.length) return ['recovery report', 'no blocked or paused missions found'];
285
+ const lines = ['recovery report'];
286
+ for (const row of rows) {
287
+ const state = row.human_blocking || row.operator_required
288
+ ? 'needs operator'
289
+ : (applying ? `${row.outcome}: ${row.detail}` : (row.apply ? 'safe to apply' : 'report only'));
290
+ lines.push(
291
+ `${row.mission_id}: ${row.happened}`,
292
+ ` ${state}; next: ${recoveryCommand(row)}`,
293
+ ` receipt: ${row.end_state?.receipt_path || row.receipt_path || 'none today'}`,
294
+ );
295
+ }
296
+ if (receiptPath) lines.push(`recovery receipt: ${receiptPath}`);
297
+ return lines;
298
+ }
299
+
300
+ function parseArgs(args) {
301
+ const apply = args.includes('--apply');
302
+ const json = args.includes('--json');
303
+ const help = args.includes('--help') || args.includes('-h') || args[0] === 'help';
304
+ const ref = args.find((arg) => !String(arg).startsWith('-') && arg !== 'help') || '';
305
+ return { apply, json, help, ref };
306
+ }
307
+
308
+ function recoverCommand(args = [], options = {}) {
309
+ const parsed = parseArgs(args);
310
+ if (parsed.help) {
311
+ console.log('Usage: atris recover [mission-id] [--apply] [--json]');
312
+ console.log('Reports blocked and paused missions. --apply clears dead locks, resumes safe pauses, and reruns failed verifiers.');
313
+ return 0;
314
+ }
315
+ const root = path.resolve(options.root || process.cwd());
316
+ const rows = selectRows(root, parsed.ref);
317
+ const results = parsed.apply
318
+ ? rows.map((row) => ({ ...row, ...applyMissionRow(root, row) }))
319
+ : rows;
320
+ const receiptPath = parsed.apply ? writeRecoveryReceipt(root, results) : null;
321
+ const payload = {
322
+ ok: true,
323
+ action: parsed.apply ? 'recovery_applied' : 'recovery_report',
324
+ report_only: !parsed.apply,
325
+ results: results.map((row) => ({
326
+ ...row,
327
+ recovery_command: recoveryCommand(row),
328
+ disposition: row.human_blocking || row.operator_required ? 'needs operator' : (row.apply ? 'safe' : 'report only'),
329
+ })),
330
+ receipt_path: receiptPath,
331
+ };
332
+ if (parsed.json) console.log(JSON.stringify(payload, null, 2));
333
+ else console.log(textLines(results, receiptPath, parsed.apply).join('\n'));
334
+ return 0;
335
+ }
336
+
337
+ module.exports = {
338
+ classifyRecovery,
339
+ recoverCommand,
340
+ };
@@ -409,7 +409,6 @@ function reportCommand(args = []) {
409
409
  module.exports = {
410
410
  buildWeekReportData,
411
411
  renderWeekReport,
412
- renderWeekReportHtml,
413
412
  reportCommand,
414
413
  showReportHelp,
415
414
  };
@@ -0,0 +1,319 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+ const { spawnSync } = require('node:child_process');
8
+ const { loadOverrides, pickLane } = require('../lib/ax-auto-lane');
9
+
10
+ const REPO_ROOT = path.resolve(__dirname, '..');
11
+ const DEFAULT_GOLD_PATH = path.join(REPO_ROOT, 'scripts', 'det', 'data', 'ax-lane-gold.jsonl');
12
+ const LANE_ORDER = ['fast', 'pro', 'max', 'code-fast'];
13
+ const DEPTH = { fast: 0, 'code-fast': 1, pro: 1, max: 2 };
14
+ const QUALITY_MISS_WEIGHT = 3;
15
+
16
+ function readFlag(args, names) {
17
+ for (const name of names) {
18
+ const prefix = `${name}=`;
19
+ const inline = args.find((arg) => String(arg).startsWith(prefix));
20
+ if (inline) return String(inline).slice(prefix.length);
21
+ const index = args.indexOf(name);
22
+ if (index >= 0 && args[index + 1] && !String(args[index + 1]).startsWith('--')) {
23
+ return String(args[index + 1]);
24
+ }
25
+ }
26
+ return null;
27
+ }
28
+
29
+ function resolveRouterPaths(args = [], env = process.env) {
30
+ const home = env.HOME || os.homedir();
31
+ return {
32
+ picks: path.resolve(readFlag(args, ['--picks', '--picks-path'])
33
+ || env.ATRIS_ROUTER_PICKS_PATH
34
+ || env.ATRIS_AX_AUTO_PICKS_PATH
35
+ || path.join(home, '.atris', 'ax-auto-picks.jsonl')),
36
+ overrides: path.resolve(readFlag(args, ['--overrides', '--overrides-path'])
37
+ || env.ATRIS_ROUTER_OVERRIDES_PATH
38
+ || path.join(home, '.atris', 'router-overrides.json')),
39
+ gold: path.resolve(readFlag(args, ['--gold', '--gold-path'])
40
+ || env.ATRIS_ROUTER_GOLD_PATH
41
+ || DEFAULT_GOLD_PATH),
42
+ };
43
+ }
44
+
45
+ function readJsonl(filePath) {
46
+ try {
47
+ return fs.readFileSync(filePath, 'utf8')
48
+ .split(/\r?\n/)
49
+ .filter((line) => line.trim())
50
+ .flatMap((line) => {
51
+ try {
52
+ return [JSON.parse(line)];
53
+ } catch (_) {
54
+ return [];
55
+ }
56
+ });
57
+ } catch (_) {
58
+ return [];
59
+ }
60
+ }
61
+
62
+ function outcomeKind(row) {
63
+ if (!row || row.event !== 'outcome') return null;
64
+ if (row.outcome === 'rephrased') return 'rephrased';
65
+ if (row.ok === true || row.outcome === 'ok') return 'ok';
66
+ if (row.ok === false || row.outcome === 'error' || row.error) return 'error';
67
+ return null;
68
+ }
69
+
70
+ function routerStatus(records, overrides) {
71
+ const picks = Object.fromEntries(LANE_ORDER.map((lane) => [lane, 0]));
72
+ const outcomes = { ok: 0, error: 0, rephrased: 0 };
73
+ for (const row of records) {
74
+ if (row && row.event !== 'outcome' && Object.hasOwn(picks, row.lane)) picks[row.lane] += 1;
75
+ const kind = outcomeKind(row);
76
+ if (kind) outcomes[kind] += 1;
77
+ }
78
+ return {
79
+ picks,
80
+ outcomes,
81
+ active_overrides: Array.isArray(overrides) ? overrides.length : 0,
82
+ };
83
+ }
84
+
85
+ function printStatus(status, output = console.log) {
86
+ for (const lane of LANE_ORDER) output(`${lane} picks: ${status.picks[lane]}`);
87
+ output(`ok outcomes: ${status.outcomes.ok}`);
88
+ output(`error outcomes: ${status.outcomes.error}`);
89
+ output(`rephrased outcomes: ${status.outcomes.rephrased}`);
90
+ output(`active overrides: ${status.active_overrides}`);
91
+ }
92
+
93
+ function messageWords(message) {
94
+ return String(message || '').toLowerCase().match(/[a-z0-9]+/g) || [];
95
+ }
96
+
97
+ function signatureForPick(pick) {
98
+ const firstWords = messageWords(pick && pick.message).slice(0, 2);
99
+ if (!pick || !pick.lane || !firstWords.length) return null;
100
+ return `${firstWords.join(' ')}|${pick.lane}`;
101
+ }
102
+
103
+ function nextLane(lane) {
104
+ if (lane === 'fast') return 'pro';
105
+ if (lane === 'pro' || lane === 'code-fast') return 'max';
106
+ return null;
107
+ }
108
+
109
+ function groupPicksBySignature(records) {
110
+ const outcomesByPick = new Map();
111
+ for (const row of records) {
112
+ const kind = outcomeKind(row);
113
+ if (!kind || !row.pick_id) continue;
114
+ if (!outcomesByPick.has(row.pick_id)) outcomesByPick.set(row.pick_id, new Set());
115
+ outcomesByPick.get(row.pick_id).add(kind);
116
+ }
117
+
118
+ const groups = new Map();
119
+ for (const pick of records) {
120
+ if (!pick || pick.event === 'outcome' || !pick.pick_id) continue;
121
+ const signature = signatureForPick(pick);
122
+ if (!signature) continue;
123
+ if (!groups.has(signature)) {
124
+ groups.set(signature, {
125
+ signature,
126
+ lane: pick.lane,
127
+ words: messageWords(pick.message).slice(0, 2),
128
+ picks: [],
129
+ bad_picks: [],
130
+ });
131
+ }
132
+ const group = groups.get(signature);
133
+ group.picks.push(pick);
134
+ const kinds = outcomesByPick.get(pick.pick_id) || new Set();
135
+ if (kinds.has('error') || kinds.has('rephrased')) group.bad_picks.push(pick);
136
+ }
137
+ return groups;
138
+ }
139
+
140
+ function promotionCandidates(records, minimumMisses = 3) {
141
+ const candidates = [];
142
+ for (const group of groupPicksBySignature(records).values()) {
143
+ const promotedLane = nextLane(group.lane);
144
+ const misses = group.bad_picks.length;
145
+ if (!promotedLane || misses < minimumMisses) continue;
146
+ const idHash = crypto.createHash('sha256').update(group.signature).digest('hex').slice(0, 12);
147
+ candidates.push({
148
+ signature: group.signature,
149
+ misses,
150
+ from_lane: group.lane,
151
+ override: {
152
+ id: `learned-${idHash}`,
153
+ test: { startsWith: group.words.join(' ') },
154
+ lane: promotedLane,
155
+ reason: `learned from ${misses} misses`,
156
+ },
157
+ });
158
+ }
159
+ return candidates.sort((left, right) => left.signature.localeCompare(right.signature));
160
+ }
161
+
162
+ function evaluateRows(rows, overrides = []) {
163
+ let correct = 0;
164
+ let weightedError = 0;
165
+ for (const row of rows) {
166
+ const picked = pickLane(row.message, { overrides }).lane;
167
+ if (picked === row.lane) {
168
+ correct += 1;
169
+ continue;
170
+ }
171
+ weightedError += DEPTH[picked] < DEPTH[row.lane] ? QUALITY_MISS_WEIGHT : 1;
172
+ }
173
+ const total = rows.length;
174
+ return {
175
+ total,
176
+ correct,
177
+ accuracy: total ? correct / total : 0,
178
+ weighted_error_rate: total ? weightedError / (total * QUALITY_MISS_WEIGHT) : 0,
179
+ weighted_accuracy: total ? 1 - (weightedError / (total * QUALITY_MISS_WEIGHT)) : 0,
180
+ };
181
+ }
182
+
183
+ function runCandidateHarness(args = [], env = process.env) {
184
+ const paths = resolveRouterPaths(args, env);
185
+ const candidate = JSON.parse(env.ATRIS_ROUTER_CANDIDATE_JSON || '{}');
186
+ const rows = readJsonl(paths.gold);
187
+ process.stdout.write(`${JSON.stringify(evaluateRows(rows, [candidate]))}\n`);
188
+ return rows.length ? 0 : 2;
189
+ }
190
+
191
+ function runSealedEval(goldPath, options = {}) {
192
+ const spawn = options.spawnSync || spawnSync;
193
+ const evalScript = path.join(REPO_ROOT, 'scripts', 'det', 'ax-lane-eval.js');
194
+ const args = [evalScript, '--json'];
195
+ if (path.resolve(goldPath) !== path.resolve(DEFAULT_GOLD_PATH)) args.push('--data', goldPath);
196
+ const result = spawn(process.execPath, args, {
197
+ cwd: REPO_ROOT,
198
+ encoding: 'utf8',
199
+ env: options.env || process.env,
200
+ });
201
+ if (result.error || result.status !== 0) {
202
+ throw new Error(String((result.error && result.error.message) || result.stderr || 'sealed eval failed').trim());
203
+ }
204
+ const report = JSON.parse(result.stdout);
205
+ return {
206
+ ...report,
207
+ weighted_accuracy: 1 - Number(report.weighted_error_rate || 0),
208
+ };
209
+ }
210
+
211
+ function runOverrideEval(goldPath, override, options = {}) {
212
+ const spawn = options.spawnSync || spawnSync;
213
+ const result = spawn(process.execPath, [__filename, '_eval-candidate', '--gold', goldPath], {
214
+ cwd: REPO_ROOT,
215
+ encoding: 'utf8',
216
+ env: {
217
+ ...(options.env || process.env),
218
+ ATRIS_ROUTER_CANDIDATE_JSON: JSON.stringify(override),
219
+ },
220
+ });
221
+ if (result.error || result.status !== 0) {
222
+ throw new Error(String((result.error && result.error.message) || result.stderr || 'override eval failed').trim());
223
+ }
224
+ return JSON.parse(result.stdout);
225
+ }
226
+
227
+ function evaluateCandidateGate(goldPath, override, options = {}) {
228
+ const baseline = runSealedEval(goldPath, options);
229
+ const candidate = runOverrideEval(goldPath, override, options);
230
+ return {
231
+ keep: candidate.weighted_accuracy >= baseline.weighted_accuracy,
232
+ baseline,
233
+ candidate,
234
+ };
235
+ }
236
+
237
+ function writeOverrides(filePath, overrides) {
238
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
239
+ const tempPath = `${filePath}.${process.pid}.tmp`;
240
+ fs.writeFileSync(tempPath, `${JSON.stringify(overrides, null, 2)}\n`, 'utf8');
241
+ fs.renameSync(tempPath, filePath);
242
+ }
243
+
244
+ function promote(args, paths, options = {}) {
245
+ const output = options.output || console.log;
246
+ const records = readJsonl(paths.picks);
247
+ const candidates = promotionCandidates(records);
248
+ const existing = loadOverrides(paths.overrides);
249
+ const accepted = [];
250
+ if (!candidates.length) {
251
+ output('no promotion candidates');
252
+ return { code: 0, candidates, accepted };
253
+ }
254
+
255
+ for (const candidate of candidates) {
256
+ output(`candidate ${candidate.signature}: ${candidate.from_lane} -> ${candidate.override.lane}, ${candidate.override.reason}`);
257
+ if (existing.some((override) => override.id === candidate.override.id)) {
258
+ output(`keep ${candidate.override.id}: already active`);
259
+ continue;
260
+ }
261
+ const gate = evaluateCandidateGate(paths.gold, candidate.override, options);
262
+ const before = gate.baseline.weighted_accuracy.toFixed(4);
263
+ const after = gate.candidate.weighted_accuracy.toFixed(4);
264
+ if (!gate.keep) {
265
+ output(`revert ${candidate.override.id}: weighted accuracy ${before} -> ${after}, candidate rejected`);
266
+ continue;
267
+ }
268
+ output(`keep ${candidate.override.id}: weighted accuracy ${before} -> ${after}`);
269
+ accepted.push(candidate.override);
270
+ }
271
+
272
+ if (args.includes('--dry-run')) {
273
+ output('dry run: no overrides written');
274
+ } else if (accepted.length) {
275
+ writeOverrides(paths.overrides, [...existing, ...accepted]);
276
+ output(`promoted overrides: ${accepted.length}`);
277
+ } else {
278
+ output('promoted overrides: 0');
279
+ }
280
+ return { code: 0, candidates, accepted };
281
+ }
282
+
283
+ function printHelp(output = console.log) {
284
+ output('usage: atris router status [--picks <path>] [--overrides <path>]');
285
+ output('usage: atris router promote [--dry-run] [--picks <path>] [--overrides <path>] [--gold <path>]');
286
+ }
287
+
288
+ function routerCommand(args = [], options = {}) {
289
+ const env = options.env || process.env;
290
+ const output = options.output || console.log;
291
+ const paths = resolveRouterPaths(args, env);
292
+ const subcommand = args[0] || 'status';
293
+ if (subcommand === 'status') {
294
+ printStatus(routerStatus(readJsonl(paths.picks), loadOverrides(paths.overrides)), output);
295
+ return 0;
296
+ }
297
+ if (subcommand === 'promote') return promote(args.slice(1), paths, { ...options, env, output }).code;
298
+ if (subcommand === 'help' || subcommand === '--help' || subcommand === '-h') {
299
+ printHelp(output);
300
+ return 0;
301
+ }
302
+ printHelp(output);
303
+ return 2;
304
+ }
305
+
306
+ if (require.main === module) {
307
+ const args = process.argv.slice(2);
308
+ process.exitCode = args[0] === '_eval-candidate'
309
+ ? runCandidateHarness(args.slice(1))
310
+ : routerCommand(args);
311
+ }
312
+
313
+ module.exports = {
314
+ groupPicksBySignature,
315
+ nextLane,
316
+ promotionCandidates,
317
+ readJsonl,
318
+ routerCommand,
319
+ };
@@ -136,8 +136,6 @@ async function runMissionFront(args = []) {
136
136
  module.exports = {
137
137
  runMissionFront,
138
138
  pickRunnableMission,
139
- liveCodexSession,
140
- runnerDrivableForFrontDoor,
141
139
  runObjective,
142
140
  runBudgetSeconds,
143
141
  runTickBudget,
@@ -355,9 +355,6 @@ function searchCommand(args = [], options = {}) {
355
355
  }
356
356
 
357
357
  module.exports = {
358
- collectSearchResults,
359
- renderCompactSearch,
360
- renderRawSearch,
361
358
  searchCommand,
362
359
  showSearchHelp,
363
360
  };