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.
- package/README.md +2 -0
- package/atris/PERSONA.md +3 -7
- package/atris.md +10 -2
- package/ax +158 -33
- package/bin/atris.js +49 -2
- package/commands/activate.js +16 -3
- package/commands/agent-spawn.js +0 -7
- package/commands/autoland.js +92 -14
- package/commands/autopilot-front.js +0 -4
- package/commands/autopilot.js +0 -12
- package/commands/brain.js +0 -7
- package/commands/brief.js +0 -2
- package/commands/business-sync.js +0 -1
- package/commands/business.js +107 -12
- package/commands/clean.js +31 -2
- package/commands/close.js +46 -11
- package/commands/cloud.js +0 -1
- package/commands/codex-goal.js +0 -2
- package/commands/computer.js +6 -5
- package/commands/console.js +46 -9
- package/commands/deck.js +29 -8
- package/commands/dream.js +0 -7
- package/commands/drill.js +0 -1
- package/commands/engine.js +10 -18
- package/commands/experiments.js +0 -4
- package/commands/fleet-report.js +12 -4
- package/commands/game.js +261 -0
- package/commands/gm.js +10 -2
- package/commands/goal.js +0 -6
- package/commands/harvest.js +0 -1
- package/commands/improve.js +29 -8
- package/commands/init.js +18 -34
- package/commands/integrations.js +0 -7
- package/commands/land.js +71 -8
- package/commands/launchpad.js +0 -2
- package/commands/lesson.js +17 -7
- package/commands/loop-front.js +0 -2
- package/commands/loops.js +6 -5
- package/commands/meet.js +0 -2
- package/commands/member.js +68 -3
- package/commands/mission.js +359 -102
- package/commands/next.js +0 -1
- package/commands/now.js +0 -12
- package/commands/one-lap.js +8 -8
- package/commands/orb.js +0 -4
- package/commands/pack-craft.js +0 -1
- package/commands/pack.js +670 -77
- package/commands/play.js +0 -2
- package/commands/probe.js +10 -4
- package/commands/pulse.js +73 -48
- package/commands/push.js +0 -4
- package/commands/radar.js +14 -11
- package/commands/recover.js +340 -0
- package/commands/report.js +0 -1
- package/commands/router.js +319 -0
- package/commands/run-front.js +0 -2
- package/commands/search.js +0 -3
- package/commands/skill.js +7 -2
- package/commands/slop.js +225 -7
- package/commands/status.js +6 -0
- package/commands/stream.js +0 -2
- package/commands/study.js +0 -3
- package/commands/sync-checkout.js +51 -0
- package/commands/sync.js +11 -40
- package/commands/task.js +120 -38
- package/commands/teach.js +86 -0
- package/commands/unknowns.js +0 -10
- package/commands/update.js +0 -1
- package/commands/visualize.js +0 -5
- package/commands/watch.js +0 -6
- package/commands/wiki.js +41 -11
- package/commands/wish.js +0 -1
- package/commands/worktree.js +87 -14
- package/commands/xp.js +1 -6
- package/commands/youtube.js +0 -2
- package/lib/accept-verify-gate.js +122 -0
- package/lib/activity-stream.js +0 -2
- package/lib/auto-accept-certified.js +145 -2
- package/lib/autoland.js +0 -3
- package/lib/ax-auto-lane.js +68 -7
- package/lib/bench/context.js +0 -2
- package/lib/bench/report.js +0 -1
- package/lib/bench/runner.js +0 -11
- package/lib/brief-ledger.js +0 -6
- package/lib/business-simulate.js +382 -0
- package/lib/chat-log-scan.js +0 -5
- package/lib/checkout-sync.js +52 -0
- package/lib/claude-boot-block.js +48 -0
- package/lib/cloud-mission.js +0 -6
- package/lib/codex-flight.js +0 -1
- package/lib/conductor-artifacts.js +25 -0
- package/lib/context-gatherer.js +0 -1
- package/lib/deck-compose.js +0 -1
- package/lib/deck-history.js +0 -1
- package/lib/deck-layout.js +0 -5
- package/lib/deck-review.js +0 -7
- package/lib/deck-schema.js +0 -1
- package/lib/default-verifier.js +0 -2
- package/lib/engine-registry.js +16 -8
- package/lib/experiments/daily.js +0 -6
- package/lib/fleet.js +210 -27
- package/lib/functional-owner.js +0 -3
- package/lib/inspect-fields.js +0 -1
- package/lib/known-commands.js +2 -2
- package/lib/learnings.js +0 -2
- package/lib/loop-doctor.js +11 -17
- package/lib/manifest.js +0 -2
- package/lib/member-alive.js +0 -1
- package/lib/member-switches.js +0 -5
- package/lib/mission-artifact.js +0 -1
- package/lib/mission-protected-lane.js +293 -0
- package/lib/mission-room.js +0 -2
- package/lib/mission-runtime-loop.js +0 -1
- package/lib/next-moves.js +0 -18
- package/lib/official-cli-integration.js +0 -4
- package/lib/orb-context.js +6 -3
- package/lib/orb-scorecard.js +0 -1
- package/lib/outbound-send-gate.js +0 -6
- package/lib/permission-grants.js +0 -6
- package/lib/policy-lessons.js +0 -2
- package/lib/pulse.js +68 -15
- package/lib/review-integrity.js +0 -2
- package/lib/router-brain.js +0 -2
- package/lib/runner-command.js +0 -4
- package/lib/runtime-bootstrap.js +0 -1
- package/lib/scorecard.js +0 -1
- package/lib/self-drive.js +1 -1
- package/lib/state-detection.js +0 -6
- package/lib/task-db.js +0 -4
- package/lib/team-presence.js +0 -1
- package/lib/voice-gate.js +63 -2
- package/lib/wiki.js +152 -9
- package/lib/wish-audit.js +20 -6
- package/lib/wish-delegate.js +26 -20
- package/lib/wish-stats.js +0 -5
- package/lib/wish-store.js +0 -1
- package/lib/workspace-safety.js +0 -2
- package/lib/zip.js +0 -1
- 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
|
+
};
|
package/commands/report.js
CHANGED
|
@@ -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
|
+
};
|
package/commands/run-front.js
CHANGED