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,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
+ };
@@ -7,7 +7,7 @@ const { execFileSync } = require('child_process');
7
7
  *
8
8
  * - Reads git log since last tag
9
9
  * - Determines bump type (minor if any scorecard has reward>=5, else patch)
10
- * - Bumps package.json version
10
+ * - Bumps package.json and package-lock.json together
11
11
  * - Commits, tags, pushes
12
12
  * - Creates GitHub release via `gh`
13
13
  * - Drafts a /launch post (3 emoji bullets)
@@ -79,16 +79,17 @@ async function releaseAtris({ dryRun = false } = {}) {
79
79
  return;
80
80
  }
81
81
 
82
- // 7. Bump package.json
83
- const updatedPkgRaw = pkgRaw.replace(
84
- `"version": "${currentVersion}"`,
85
- `"version": "${nextVersion}"`
86
- );
87
- fs.writeFileSync(pkgPath, updatedPkgRaw);
88
- console.log(`bumped package.json to ${nextVersion}`);
82
+ // 7. Let npm keep package.json and package-lock.json aligned.
83
+ execFileSync('npm', [
84
+ 'version',
85
+ nextVersion,
86
+ '--no-git-tag-version',
87
+ '--ignore-scripts',
88
+ ], { cwd, stdio: 'ignore' });
89
+ console.log(`bumped package.json and package-lock.json to ${nextVersion}`);
89
90
 
90
91
  // 8. Commit
91
- execFileSync('git', ['add', 'package.json'], { cwd });
92
+ execFileSync('git', ['add', 'package.json', 'package-lock.json'], { cwd });
92
93
  execFileSync('git', ['commit', '-m', `v${nextVersion}`], { cwd });
93
94
  console.log('committed');
94
95
 
@@ -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,320 @@
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
+ // Preserve alias priority and inline-value priority within each alias.
17
+ function readFirstNamedFlag(args, names) {
18
+ for (const name of names) {
19
+ const prefix = `${name}=`;
20
+ const inline = args.find((arg) => String(arg).startsWith(prefix));
21
+ if (inline) return String(inline).slice(prefix.length);
22
+ const index = args.indexOf(name);
23
+ if (index >= 0 && args[index + 1] && !String(args[index + 1]).startsWith('--')) {
24
+ return String(args[index + 1]);
25
+ }
26
+ }
27
+ return null;
28
+ }
29
+
30
+ function resolveRouterPaths(args = [], env = process.env) {
31
+ const home = env.HOME || os.homedir();
32
+ return {
33
+ picks: path.resolve(readFirstNamedFlag(args, ['--picks', '--picks-path'])
34
+ || env.ATRIS_ROUTER_PICKS_PATH
35
+ || env.ATRIS_AX_AUTO_PICKS_PATH
36
+ || path.join(home, '.atris', 'ax-auto-picks.jsonl')),
37
+ overrides: path.resolve(readFirstNamedFlag(args, ['--overrides', '--overrides-path'])
38
+ || env.ATRIS_ROUTER_OVERRIDES_PATH
39
+ || path.join(home, '.atris', 'router-overrides.json')),
40
+ gold: path.resolve(readFirstNamedFlag(args, ['--gold', '--gold-path'])
41
+ || env.ATRIS_ROUTER_GOLD_PATH
42
+ || DEFAULT_GOLD_PATH),
43
+ };
44
+ }
45
+
46
+ function readJsonl(filePath) {
47
+ try {
48
+ return fs.readFileSync(filePath, 'utf8')
49
+ .split(/\r?\n/)
50
+ .filter((line) => line.trim())
51
+ .flatMap((line) => {
52
+ try {
53
+ return [JSON.parse(line)];
54
+ } catch (_) {
55
+ return [];
56
+ }
57
+ });
58
+ } catch (_) {
59
+ return [];
60
+ }
61
+ }
62
+
63
+ function outcomeKind(row) {
64
+ if (!row || row.event !== 'outcome') return null;
65
+ if (row.outcome === 'rephrased') return 'rephrased';
66
+ if (row.ok === true || row.outcome === 'ok') return 'ok';
67
+ if (row.ok === false || row.outcome === 'error' || row.error) return 'error';
68
+ return null;
69
+ }
70
+
71
+ function routerStatus(records, overrides) {
72
+ const picks = Object.fromEntries(LANE_ORDER.map((lane) => [lane, 0]));
73
+ const outcomes = { ok: 0, error: 0, rephrased: 0 };
74
+ for (const row of records) {
75
+ if (row && row.event !== 'outcome' && Object.hasOwn(picks, row.lane)) picks[row.lane] += 1;
76
+ const kind = outcomeKind(row);
77
+ if (kind) outcomes[kind] += 1;
78
+ }
79
+ return {
80
+ picks,
81
+ outcomes,
82
+ active_overrides: Array.isArray(overrides) ? overrides.length : 0,
83
+ };
84
+ }
85
+
86
+ function printStatus(status, output = console.log) {
87
+ for (const lane of LANE_ORDER) output(`${lane} picks: ${status.picks[lane]}`);
88
+ output(`ok outcomes: ${status.outcomes.ok}`);
89
+ output(`error outcomes: ${status.outcomes.error}`);
90
+ output(`rephrased outcomes: ${status.outcomes.rephrased}`);
91
+ output(`active overrides: ${status.active_overrides}`);
92
+ }
93
+
94
+ function messageWords(message) {
95
+ return String(message || '').toLowerCase().match(/[a-z0-9]+/g) || [];
96
+ }
97
+
98
+ function signatureForPick(pick) {
99
+ const firstWords = messageWords(pick && pick.message).slice(0, 2);
100
+ if (!pick || !pick.lane || !firstWords.length) return null;
101
+ return `${firstWords.join(' ')}|${pick.lane}`;
102
+ }
103
+
104
+ function nextLane(lane) {
105
+ if (lane === 'fast') return 'pro';
106
+ if (lane === 'pro' || lane === 'code-fast') return 'max';
107
+ return null;
108
+ }
109
+
110
+ function groupPicksBySignature(records) {
111
+ const outcomesByPick = new Map();
112
+ for (const row of records) {
113
+ const kind = outcomeKind(row);
114
+ if (!kind || !row.pick_id) continue;
115
+ if (!outcomesByPick.has(row.pick_id)) outcomesByPick.set(row.pick_id, new Set());
116
+ outcomesByPick.get(row.pick_id).add(kind);
117
+ }
118
+
119
+ const groups = new Map();
120
+ for (const pick of records) {
121
+ if (!pick || pick.event === 'outcome' || !pick.pick_id) continue;
122
+ const signature = signatureForPick(pick);
123
+ if (!signature) continue;
124
+ if (!groups.has(signature)) {
125
+ groups.set(signature, {
126
+ signature,
127
+ lane: pick.lane,
128
+ words: messageWords(pick.message).slice(0, 2),
129
+ picks: [],
130
+ bad_picks: [],
131
+ });
132
+ }
133
+ const group = groups.get(signature);
134
+ group.picks.push(pick);
135
+ const kinds = outcomesByPick.get(pick.pick_id) || new Set();
136
+ if (kinds.has('error') || kinds.has('rephrased')) group.bad_picks.push(pick);
137
+ }
138
+ return groups;
139
+ }
140
+
141
+ function promotionCandidates(records, minimumMisses = 3) {
142
+ const candidates = [];
143
+ for (const group of groupPicksBySignature(records).values()) {
144
+ const promotedLane = nextLane(group.lane);
145
+ const misses = group.bad_picks.length;
146
+ if (!promotedLane || misses < minimumMisses) continue;
147
+ const idHash = crypto.createHash('sha256').update(group.signature).digest('hex').slice(0, 12);
148
+ candidates.push({
149
+ signature: group.signature,
150
+ misses,
151
+ from_lane: group.lane,
152
+ override: {
153
+ id: `learned-${idHash}`,
154
+ test: { startsWith: group.words.join(' ') },
155
+ lane: promotedLane,
156
+ reason: `learned from ${misses} misses`,
157
+ },
158
+ });
159
+ }
160
+ return candidates.sort((left, right) => left.signature.localeCompare(right.signature));
161
+ }
162
+
163
+ function evaluateRows(rows, overrides = []) {
164
+ let correct = 0;
165
+ let weightedError = 0;
166
+ for (const row of rows) {
167
+ const picked = pickLane(row.message, { overrides }).lane;
168
+ if (picked === row.lane) {
169
+ correct += 1;
170
+ continue;
171
+ }
172
+ weightedError += DEPTH[picked] < DEPTH[row.lane] ? QUALITY_MISS_WEIGHT : 1;
173
+ }
174
+ const total = rows.length;
175
+ return {
176
+ total,
177
+ correct,
178
+ accuracy: total ? correct / total : 0,
179
+ weighted_error_rate: total ? weightedError / (total * QUALITY_MISS_WEIGHT) : 0,
180
+ weighted_accuracy: total ? 1 - (weightedError / (total * QUALITY_MISS_WEIGHT)) : 0,
181
+ };
182
+ }
183
+
184
+ function runCandidateHarness(args = [], env = process.env) {
185
+ const paths = resolveRouterPaths(args, env);
186
+ const candidate = JSON.parse(env.ATRIS_ROUTER_CANDIDATE_JSON || '{}');
187
+ const rows = readJsonl(paths.gold);
188
+ process.stdout.write(`${JSON.stringify(evaluateRows(rows, [candidate]))}\n`);
189
+ return rows.length ? 0 : 2;
190
+ }
191
+
192
+ function runSealedEval(goldPath, options = {}) {
193
+ const spawn = options.spawnSync || spawnSync;
194
+ const evalScript = path.join(REPO_ROOT, 'scripts', 'det', 'ax-lane-eval.js');
195
+ const args = [evalScript, '--json'];
196
+ if (path.resolve(goldPath) !== path.resolve(DEFAULT_GOLD_PATH)) args.push('--data', goldPath);
197
+ const result = spawn(process.execPath, args, {
198
+ cwd: REPO_ROOT,
199
+ encoding: 'utf8',
200
+ env: options.env || process.env,
201
+ });
202
+ if (result.error || result.status !== 0) {
203
+ throw new Error(String((result.error && result.error.message) || result.stderr || 'sealed eval failed').trim());
204
+ }
205
+ const report = JSON.parse(result.stdout);
206
+ return {
207
+ ...report,
208
+ weighted_accuracy: 1 - Number(report.weighted_error_rate || 0),
209
+ };
210
+ }
211
+
212
+ function runOverrideEval(goldPath, override, options = {}) {
213
+ const spawn = options.spawnSync || spawnSync;
214
+ const result = spawn(process.execPath, [__filename, '_eval-candidate', '--gold', goldPath], {
215
+ cwd: REPO_ROOT,
216
+ encoding: 'utf8',
217
+ env: {
218
+ ...(options.env || process.env),
219
+ ATRIS_ROUTER_CANDIDATE_JSON: JSON.stringify(override),
220
+ },
221
+ });
222
+ if (result.error || result.status !== 0) {
223
+ throw new Error(String((result.error && result.error.message) || result.stderr || 'override eval failed').trim());
224
+ }
225
+ return JSON.parse(result.stdout);
226
+ }
227
+
228
+ function evaluateCandidateGate(goldPath, override, options = {}) {
229
+ const baseline = runSealedEval(goldPath, options);
230
+ const candidate = runOverrideEval(goldPath, override, options);
231
+ return {
232
+ keep: candidate.weighted_accuracy >= baseline.weighted_accuracy,
233
+ baseline,
234
+ candidate,
235
+ };
236
+ }
237
+
238
+ function writeOverrides(filePath, overrides) {
239
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
240
+ const tempPath = `${filePath}.${process.pid}.tmp`;
241
+ fs.writeFileSync(tempPath, `${JSON.stringify(overrides, null, 2)}\n`, 'utf8');
242
+ fs.renameSync(tempPath, filePath);
243
+ }
244
+
245
+ function promote(args, paths, options = {}) {
246
+ const output = options.output || console.log;
247
+ const records = readJsonl(paths.picks);
248
+ const candidates = promotionCandidates(records);
249
+ const existing = loadOverrides(paths.overrides);
250
+ const accepted = [];
251
+ if (!candidates.length) {
252
+ output('no promotion candidates');
253
+ return { code: 0, candidates, accepted };
254
+ }
255
+
256
+ for (const candidate of candidates) {
257
+ output(`candidate ${candidate.signature}: ${candidate.from_lane} -> ${candidate.override.lane}, ${candidate.override.reason}`);
258
+ if (existing.some((override) => override.id === candidate.override.id)) {
259
+ output(`keep ${candidate.override.id}: already active`);
260
+ continue;
261
+ }
262
+ const gate = evaluateCandidateGate(paths.gold, candidate.override, options);
263
+ const before = gate.baseline.weighted_accuracy.toFixed(4);
264
+ const after = gate.candidate.weighted_accuracy.toFixed(4);
265
+ if (!gate.keep) {
266
+ output(`revert ${candidate.override.id}: weighted accuracy ${before} -> ${after}, candidate rejected`);
267
+ continue;
268
+ }
269
+ output(`keep ${candidate.override.id}: weighted accuracy ${before} -> ${after}`);
270
+ accepted.push(candidate.override);
271
+ }
272
+
273
+ if (args.includes('--dry-run')) {
274
+ output('dry run: no overrides written');
275
+ } else if (accepted.length) {
276
+ writeOverrides(paths.overrides, [...existing, ...accepted]);
277
+ output(`promoted overrides: ${accepted.length}`);
278
+ } else {
279
+ output('promoted overrides: 0');
280
+ }
281
+ return { code: 0, candidates, accepted };
282
+ }
283
+
284
+ function printHelp(output = console.log) {
285
+ output('usage: atris router status [--picks <path>] [--overrides <path>]');
286
+ output('usage: atris router promote [--dry-run] [--picks <path>] [--overrides <path>] [--gold <path>]');
287
+ }
288
+
289
+ function routerCommand(args = [], options = {}) {
290
+ const env = options.env || process.env;
291
+ const output = options.output || console.log;
292
+ const paths = resolveRouterPaths(args, env);
293
+ const subcommand = args[0] || 'status';
294
+ if (subcommand === 'status') {
295
+ printStatus(routerStatus(readJsonl(paths.picks), loadOverrides(paths.overrides)), output);
296
+ return 0;
297
+ }
298
+ if (subcommand === 'promote') return promote(args.slice(1), paths, { ...options, env, output }).code;
299
+ if (subcommand === 'help' || subcommand === '--help' || subcommand === '-h') {
300
+ printHelp(output);
301
+ return 0;
302
+ }
303
+ printHelp(output);
304
+ return 2;
305
+ }
306
+
307
+ if (require.main === module) {
308
+ const args = process.argv.slice(2);
309
+ process.exitCode = args[0] === '_eval-candidate'
310
+ ? runCandidateHarness(args.slice(1))
311
+ : routerCommand(args);
312
+ }
313
+
314
+ module.exports = {
315
+ groupPicksBySignature,
316
+ nextLane,
317
+ promotionCandidates,
318
+ readJsonl,
319
+ routerCommand,
320
+ };