atris 3.34.0 → 3.36.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/AGENTS.md +35 -0
- package/FOR_AGENTS.md +5 -3
- package/README.md +5 -3
- package/atris/GETTING_STARTED.md +1 -1
- package/atris/atris.md +3 -0
- package/atris/policies/day-loop-voice.md +102 -0
- package/atris/policies/outbound-artifact-gate.md +2 -0
- package/atris/skills/design/SKILL.md +56 -32
- package/atris/skills/endgame/SKILL.md +12 -6
- package/atris/skills/engines/SKILL.md +35 -8
- package/atris/skills/fable-method/SKILL.md +66 -0
- package/atris/skills/improve/SKILL.md +65 -45
- package/atris/skills/render-cli/SKILL.md +88 -0
- package/atris/skills/youtube/SKILL.md +10 -1
- package/atris.md +6 -2
- package/ax +617 -31
- package/bin/atris.js +719 -266
- package/commands/activate.js +194 -88
- package/commands/aeo.js +52 -0
- package/commands/agents.js +166 -0
- package/commands/autoland.js +718 -72
- package/commands/autopilot-front.js +20 -2
- package/commands/autopilot.js +118 -2
- package/commands/avail.js +407 -0
- package/commands/bench.js +188 -0
- package/commands/brain.js +3 -0
- package/commands/brief.js +651 -0
- package/commands/business-sync.js +192 -6
- package/commands/business.js +91 -8
- package/commands/clean.js +50 -24
- package/commands/close.js +1083 -0
- package/commands/cloud.js +245 -0
- package/commands/codex-goal.js +26 -2
- package/commands/compile.js +292 -1
- package/commands/computer.js +150 -3
- package/commands/dream.js +365 -0
- package/commands/drill.js +371 -0
- package/commands/drive.js +187 -0
- package/commands/engine.js +1061 -29
- package/commands/experiments.js +28 -0
- package/commands/feed.js +202 -0
- package/commands/feedback.js +34 -12
- package/commands/fleet-report.js +206 -0
- package/commands/github.js +38 -0
- package/commands/gm.js +285 -3
- package/commands/goal.js +247 -0
- package/commands/improve.js +642 -26
- package/commands/init.js +83 -43
- package/commands/integrations.js +39 -11
- package/commands/interview.js +209 -0
- package/commands/land.js +253 -52
- package/commands/lesson.js +112 -1
- package/commands/lifecycle.js +39 -3
- package/commands/linear.js +38 -0
- package/commands/log.js +84 -1
- package/commands/loops.js +220 -16
- package/commands/meet.js +220 -0
- package/commands/member.js +899 -66
- package/commands/mission.js +3512 -332
- package/commands/next.js +137 -0
- package/commands/now.js +240 -21
- package/commands/one-lap.js +776 -0
- package/commands/orb.js +314 -0
- package/commands/pack-craft.js +179 -0
- package/commands/pack.js +823 -0
- package/commands/play.js +3 -2
- package/commands/probe.js +30 -3
- package/commands/pulse.js +241 -46
- package/commands/push.js +260 -82
- package/commands/radar.js +259 -14
- package/commands/rainmaker.js +49 -0
- package/commands/report.js +415 -0
- package/commands/scout.js +147 -0
- package/commands/search.js +363 -0
- package/commands/serve.js +54 -0
- package/commands/skill.js +47 -3
- package/commands/slop.js +50 -2
- package/commands/soul.js +1 -1
- package/commands/status.js +50 -5
- package/commands/stream.js +861 -0
- package/commands/stripe.js +38 -0
- package/commands/study.js +693 -0
- package/commands/supabase.js +39 -0
- package/commands/sync.js +67 -54
- package/commands/task.js +2275 -182
- package/commands/team.js +73 -0
- package/commands/truth.js +29 -3
- package/commands/unknowns.js +627 -0
- package/commands/update.js +44 -0
- package/commands/vercel.js +38 -0
- package/commands/verify.js +96 -0
- package/commands/watch.js +303 -0
- package/commands/wish.js +500 -0
- package/commands/workflow.js +11 -5
- package/commands/worktree.js +299 -20
- package/commands/write.js +399 -0
- package/commands/xp.js +29 -11
- package/lib/auto-accept-certified.js +391 -43
- package/lib/autoland.js +353 -52
- package/lib/ax-auto-lane.js +79 -0
- package/lib/bench/context.js +147 -0
- package/lib/bench/engines.js +141 -0
- package/lib/bench/report.js +140 -0
- package/lib/bench/runner.js +512 -0
- package/lib/brief-ledger.js +350 -0
- package/lib/cloud-mission.js +259 -0
- package/lib/codex-flight.js +154 -0
- package/lib/default-runner.js +45 -0
- package/lib/default-verifier.js +70 -0
- package/lib/engine-registry.js +232 -0
- package/lib/experiments/daily.js +640 -0
- package/lib/fleet.js +2431 -38
- package/lib/improve-vitals-html.js +171 -0
- package/lib/known-commands.js +58 -0
- package/lib/loop-doctor.js +416 -0
- package/lib/member-switches.js +144 -0
- package/lib/memory-view.js +14 -5
- package/lib/mission-room.js +1 -0
- package/lib/mission-root.js +52 -0
- package/lib/mission-runtime-loop.js +7 -0
- package/lib/next-moves.js +327 -10
- package/lib/official-cli-integration.js +174 -0
- package/lib/one-lap-validator.js +60 -0
- package/lib/orb-context.js +477 -0
- package/lib/orb-scorecard.js +224 -0
- package/lib/outbound-send-gate.js +165 -0
- package/lib/permission-grants.js +293 -0
- package/lib/policy-lessons.js +52 -1
- package/lib/pulse.js +277 -3
- package/lib/receipt-block.js +168 -0
- package/lib/receipt-evidence.js +65 -4
- package/lib/review-integrity.js +147 -0
- package/lib/router-brain.js +352 -0
- package/lib/runner-command.js +33 -0
- package/lib/self-drive.js +258 -0
- package/lib/short-name.js +103 -0
- package/lib/spawn-env.js +18 -0
- package/lib/state-detection.js +56 -1
- package/lib/sync-status.js +59 -0
- package/lib/task-db.js +319 -27
- package/lib/task-proof.js +43 -1
- package/lib/task-receipt.js +93 -0
- package/lib/team-presence.js +260 -0
- package/lib/tool-result-encode.js +7 -0
- package/lib/trust-tiers.js +90 -0
- package/lib/usage.js +107 -0
- package/lib/voice-gate.js +163 -0
- package/lib/wish-audit.js +1368 -0
- package/lib/wish-delegate.js +1840 -0
- package/lib/wish-design.js +110 -0
- package/lib/wish-stats.js +183 -0
- package/lib/wish-store.js +354 -0
- package/lib/zip.js +221 -0
- package/package.json +3 -1
- package/templates/loops/atris/loops/LOOPS.md +55 -0
- package/templates/loops/atris/loops/TICK.md +24 -0
- package/templates/loops/atris/loops/feedback.md +22 -0
- package/templates/loops/atris/loops/quality.md +22 -0
- package/templates/loops/atris/wiki/systems/loops.md +41 -0
- package/utils/api.js +5 -1
- package/utils/auth.js +57 -21
- package/utils/update-check.js +27 -6
- package/atris/learnings.jsonl +0 -1
package/lib/receipt-evidence.js
CHANGED
|
@@ -16,11 +16,65 @@ const MAX_RECEIPTS = 5;
|
|
|
16
16
|
function receiptVerifierPassed(receipt) {
|
|
17
17
|
const result = receipt?.result;
|
|
18
18
|
if (!result || typeof result !== 'object') return null;
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
const oneLapReview = receipt?.schema === 'atris.dispatch_receipt.v1'
|
|
20
|
+
&& receipt?.review_only === true
|
|
21
|
+
&& receipt?.context?.source === 'one_lap';
|
|
22
|
+
if (oneLapReview) {
|
|
23
|
+
const validator = result.validator_result;
|
|
24
|
+
const validatorEngine = String(validator?.engine || '').trim();
|
|
25
|
+
const executorEngine = String(validator?.executor_engine || '').trim();
|
|
26
|
+
return result.passed === true
|
|
27
|
+
&& result.master_boundary_enforced === true
|
|
28
|
+
&& result.master_unchanged === true
|
|
29
|
+
&& validator?.passed === true
|
|
30
|
+
&& validator?.independent === true
|
|
31
|
+
&& validator?.worktree_unchanged === true
|
|
32
|
+
&& Boolean(validatorEngine)
|
|
33
|
+
&& Boolean(executorEngine)
|
|
34
|
+
&& validatorEngine !== executorEngine;
|
|
35
|
+
}
|
|
36
|
+
// The receipt's aggregate result is authoritative. A nested verifier can
|
|
37
|
+
// explain one green check inside a failed flight, but it cannot turn that
|
|
38
|
+
// failed flight into passing evidence.
|
|
39
|
+
if (typeof result.passed === 'boolean') return result.passed;
|
|
40
|
+
const nested = [result.verifier_result?.passed, result.tick?.verifier_passed];
|
|
41
|
+
if (nested.includes(false)) return false;
|
|
42
|
+
if (nested.includes(true)) return true;
|
|
21
43
|
return null;
|
|
22
44
|
}
|
|
23
45
|
|
|
46
|
+
// A forced completion means the mission's own gate said no and an operator
|
|
47
|
+
// overrode it. The receipt can still show a passing verifier, so the forced
|
|
48
|
+
// flag lives in mission state (`completion_gate.forced`); read it back here
|
|
49
|
+
// so the review queue shows forced work as forced, not as passing evidence.
|
|
50
|
+
function loadForcedMissionIds(root) {
|
|
51
|
+
const forced = new Map();
|
|
52
|
+
let raw;
|
|
53
|
+
try {
|
|
54
|
+
raw = fs.readFileSync(path.resolve(root, '.atris/state/missions.jsonl'), 'utf8');
|
|
55
|
+
} catch {
|
|
56
|
+
return forced;
|
|
57
|
+
}
|
|
58
|
+
for (const line of raw.split('\n')) {
|
|
59
|
+
if (!line.trim()) continue;
|
|
60
|
+
let parsed = null;
|
|
61
|
+
try { parsed = JSON.parse(line); } catch { continue; }
|
|
62
|
+
const mission = parsed?.mission && typeof parsed.mission === 'object' ? parsed.mission : parsed;
|
|
63
|
+
const id = mission?.id || parsed?.mission_id;
|
|
64
|
+
if (!id) continue;
|
|
65
|
+
// Last record for a mission wins: an honest re-completion clears the flag.
|
|
66
|
+
forced.set(id, mission?.completion_gate?.forced === true);
|
|
67
|
+
}
|
|
68
|
+
return forced;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function receiptForced(receipt, forcedMissionIds) {
|
|
72
|
+
if (receipt?.completion_gate?.forced === true) return true;
|
|
73
|
+
if (receipt?.mission?.completion_gate?.forced === true) return true;
|
|
74
|
+
const missionId = receipt?.mission_id || receipt?.mission?.id;
|
|
75
|
+
return Boolean(missionId && forcedMissionIds.get(missionId) === true);
|
|
76
|
+
}
|
|
77
|
+
|
|
24
78
|
function extractReceiptEvidence(proofText, root = process.cwd()) {
|
|
25
79
|
const text = String(proofText || '');
|
|
26
80
|
const found = [];
|
|
@@ -37,6 +91,7 @@ function extractReceiptEvidence(proofText, root = process.cwd()) {
|
|
|
37
91
|
|
|
38
92
|
const receipts = [];
|
|
39
93
|
const missing = [];
|
|
94
|
+
let forcedMissionIds = null;
|
|
40
95
|
for (const rel of found) {
|
|
41
96
|
let raw;
|
|
42
97
|
try {
|
|
@@ -51,13 +106,19 @@ function extractReceiptEvidence(proofText, root = process.cwd()) {
|
|
|
51
106
|
const passed = receiptVerifierPassed(parsed);
|
|
52
107
|
if (passed !== null) entry.verifier_passed = passed;
|
|
53
108
|
if (parsed?.mission_id) entry.mission_id = parsed.mission_id;
|
|
109
|
+
if (forcedMissionIds === null) forcedMissionIds = loadForcedMissionIds(root);
|
|
110
|
+
if (receiptForced(parsed, forcedMissionIds)) entry.forced = true;
|
|
54
111
|
receipts.push(entry);
|
|
55
112
|
}
|
|
113
|
+
const anyForced = receipts.some((entry) => entry.forced === true);
|
|
56
114
|
return {
|
|
57
115
|
receipts,
|
|
58
116
|
missing,
|
|
59
|
-
|
|
60
|
-
|
|
117
|
+
any_forced: anyForced,
|
|
118
|
+
// Unknown verifier state is not proof, and a forced completion is not
|
|
119
|
+
// passing evidence: every named receipt must explicitly pass, unforced.
|
|
120
|
+
all_passing: receipts.length > 0 && !missing.length && !anyForced
|
|
121
|
+
&& receipts.every((entry) => entry.verifier_passed === true),
|
|
61
122
|
};
|
|
62
123
|
}
|
|
63
124
|
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// judge != worker: the builder of a task cannot be the actor whose review
|
|
4
|
+
// pass certifies or lands it. This module is the single home for actor
|
|
5
|
+
// identity rules; task-db, auto-accept, and the task CLI all lean on it.
|
|
6
|
+
// Level 1 trust: normalized string identity + builder exclusion + reserved
|
|
7
|
+
// system names + roster validation (warn by default). It stops accidental
|
|
8
|
+
// and lazy self-judging and makes deliberate spoofing auditable; signed
|
|
9
|
+
// events (level 2) are a follow-up, not this file.
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const os = require('os');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const functionalOwner = require('./functional-owner');
|
|
15
|
+
|
|
16
|
+
// System actors written by the machinery itself; a human or agent may never
|
|
17
|
+
// claim/ready/review under these names.
|
|
18
|
+
const RESERVED_ACTORS = new Set(['autoland-verifier', 'auto-accept-certified']);
|
|
19
|
+
|
|
20
|
+
function normalizeActor(value) {
|
|
21
|
+
return String(value || '')
|
|
22
|
+
.trim()
|
|
23
|
+
.toLowerCase()
|
|
24
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
25
|
+
.replace(/^-+|-+$/g, '');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function envActor() {
|
|
29
|
+
return normalizeActor(process.env.ATRIS_AGENT_ID || process.env.USER || '');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Builder identity, oldest signal wins: explicit stamp, then who claimed it,
|
|
33
|
+
// then whoever sent the first proof.
|
|
34
|
+
function taskBuilder(task) {
|
|
35
|
+
if (!task || typeof task !== 'object') return null;
|
|
36
|
+
const metadata = task.metadata || {};
|
|
37
|
+
const stamped = normalizeActor(metadata.built_by);
|
|
38
|
+
if (stamped) return stamped;
|
|
39
|
+
const claimed = normalizeActor(task.claimed_by);
|
|
40
|
+
if (claimed) return claimed;
|
|
41
|
+
for (const event of task.events || []) {
|
|
42
|
+
if (event && event.event_type === 'proof_ready') {
|
|
43
|
+
const actor = normalizeActor(event.actor || (event.payload && event.payload.actor));
|
|
44
|
+
if (actor) return actor;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function reviewEventActors(task) {
|
|
51
|
+
const actors = new Set();
|
|
52
|
+
for (const event of (task && task.events) || []) {
|
|
53
|
+
if (!event || !['proof_ready', 'reviewed'].includes(event.event_type)) continue;
|
|
54
|
+
const actor = normalizeActor(event.actor || (event.payload && event.payload.actor));
|
|
55
|
+
if (actor) actors.add(actor);
|
|
56
|
+
}
|
|
57
|
+
return actors;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Actors who reviewed the task and are not its builder.
|
|
61
|
+
function independentReviewActors(task) {
|
|
62
|
+
const builder = taskBuilder(task);
|
|
63
|
+
const actors = reviewEventActors(task);
|
|
64
|
+
if (builder) actors.delete(builder);
|
|
65
|
+
return actors;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// True when at least one review pass came from someone other than the
|
|
69
|
+
// builder. When the builder cannot be resolved at all, fall back to
|
|
70
|
+
// requiring two distinct actors — independence cannot be proven from one.
|
|
71
|
+
function hasIndependentReview(task) {
|
|
72
|
+
const builder = taskBuilder(task);
|
|
73
|
+
if (!builder) return reviewEventActors(task).size >= 2;
|
|
74
|
+
return independentReviewActors(task).size >= 1;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isReservedActor(actor) {
|
|
78
|
+
return RESERVED_ACTORS.has(normalizeActor(actor));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function rosterActors(root) {
|
|
82
|
+
const actors = new Set(functionalOwner.listWorkspaceMemberSlugs(root));
|
|
83
|
+
for (const id of ['atris-2-fast', 'atris2', 'atris2-fast', 'claude', 'codex', 'codex-executor', 'cursor', 'devin', 'executor', 'openclaw', 'windsurf']) {
|
|
84
|
+
actors.add(id);
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const user = normalizeActor(os.userInfo().username);
|
|
88
|
+
if (user) actors.add(user);
|
|
89
|
+
} catch (_) { /* identity lookup can fail in stripped containers */ }
|
|
90
|
+
const envUser = normalizeActor(process.env.USER);
|
|
91
|
+
if (envUser) actors.add(envUser);
|
|
92
|
+
const agentId = normalizeActor(process.env.ATRIS_AGENT_ID);
|
|
93
|
+
if (agentId) actors.add(agentId);
|
|
94
|
+
try {
|
|
95
|
+
const policyPath = path.join(root || process.cwd(), '.atris', 'policy', 'autoland.json');
|
|
96
|
+
const policy = JSON.parse(fs.readFileSync(policyPath, 'utf8'));
|
|
97
|
+
const enabledBy = normalizeActor(policy.enabled_by);
|
|
98
|
+
if (enabledBy) actors.add(enabledBy);
|
|
99
|
+
} catch (_) { /* no policy is fine */ }
|
|
100
|
+
return actors;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// off | warn | enforce. Env wins, then .atris/review-policy.json, then off.
|
|
104
|
+
// Off by default on purpose: ad-hoc --as names are everywhere in scripts and
|
|
105
|
+
// fixtures, and a warning that fires on legitimate flows teaches everyone to
|
|
106
|
+
// ignore warnings (the cry-wolf lesson). Reserved-actor rejection and
|
|
107
|
+
// builder-exclusion do not depend on this mode and are always enforced.
|
|
108
|
+
function actorValidationMode(root) {
|
|
109
|
+
const env = String(process.env.ATRIS_ACTOR_VALIDATION || '').toLowerCase();
|
|
110
|
+
if (['off', 'warn', 'enforce'].includes(env)) return env;
|
|
111
|
+
try {
|
|
112
|
+
const policyPath = path.join(root || process.cwd(), '.atris', 'review-policy.json');
|
|
113
|
+
const policy = JSON.parse(fs.readFileSync(policyPath, 'utf8'));
|
|
114
|
+
const mode = String(policy.actor_validation || '').toLowerCase();
|
|
115
|
+
if (['off', 'warn', 'enforce'].includes(mode)) return mode;
|
|
116
|
+
} catch (_) { /* no policy is fine */ }
|
|
117
|
+
return 'off';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Reserved names are rejected in every mode; unknown names pass in warn mode
|
|
121
|
+
// (with ok true + reason so callers can print) and fail in enforce mode.
|
|
122
|
+
function validateActor(actor, { root } = {}) {
|
|
123
|
+
const normalized = normalizeActor(actor);
|
|
124
|
+
if (!normalized) return { ok: true, mode: 'off', actor: normalized };
|
|
125
|
+
if (isReservedActor(normalized)) {
|
|
126
|
+
return { ok: false, mode: 'enforce', actor: normalized, reason: 'reserved_actor' };
|
|
127
|
+
}
|
|
128
|
+
const mode = actorValidationMode(root);
|
|
129
|
+
if (mode === 'off') return { ok: true, mode, actor: normalized };
|
|
130
|
+
if (rosterActors(root).has(normalized)) return { ok: true, mode, actor: normalized };
|
|
131
|
+
if (mode === 'enforce') return { ok: false, mode, actor: normalized, reason: 'actor_not_on_roster' };
|
|
132
|
+
return { ok: true, mode, actor: normalized, reason: 'actor_not_on_roster' };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = {
|
|
136
|
+
RESERVED_ACTORS,
|
|
137
|
+
normalizeActor,
|
|
138
|
+
envActor,
|
|
139
|
+
taskBuilder,
|
|
140
|
+
reviewEventActors,
|
|
141
|
+
independentReviewActors,
|
|
142
|
+
hasIndependentReview,
|
|
143
|
+
isReservedActor,
|
|
144
|
+
rosterActors,
|
|
145
|
+
actorValidationMode,
|
|
146
|
+
validateActor,
|
|
147
|
+
};
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
const DEFAULT_MIN_RECEIPTS = 3;
|
|
7
|
+
const DEFAULT_HALF_LIFE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
8
|
+
const DURATION_SCALE_MS = 60 * 1000;
|
|
9
|
+
|
|
10
|
+
function objectValue(value) {
|
|
11
|
+
return value && typeof value === 'object' ? value : null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function recordContainers(record) {
|
|
15
|
+
const row = objectValue(record);
|
|
16
|
+
if (!row) return [];
|
|
17
|
+
return [
|
|
18
|
+
row,
|
|
19
|
+
objectValue(row.payload),
|
|
20
|
+
objectValue(row.result),
|
|
21
|
+
objectValue(row.tick),
|
|
22
|
+
objectValue(row.result?.tick),
|
|
23
|
+
objectValue(row.payload?.tick),
|
|
24
|
+
objectValue(row.verifier_result),
|
|
25
|
+
objectValue(row.result?.verifier_result),
|
|
26
|
+
objectValue(row.payload?.verifier_result),
|
|
27
|
+
objectValue(row.mission),
|
|
28
|
+
objectValue(row.context),
|
|
29
|
+
].filter(Boolean);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function firstField(containers, names, accept = (value) => value != null) {
|
|
33
|
+
for (const container of containers) {
|
|
34
|
+
for (const name of names) {
|
|
35
|
+
const value = container[name];
|
|
36
|
+
if (accept(value)) return value;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function normalizedLabel(value) {
|
|
43
|
+
return String(value || '').trim().toLowerCase();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function finiteNonnegative(value) {
|
|
47
|
+
const number = Number(value);
|
|
48
|
+
return Number.isFinite(number) && number >= 0 ? number : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function recordDuration(containers) {
|
|
52
|
+
const direct = firstField(
|
|
53
|
+
containers,
|
|
54
|
+
['duration_ms', 'durationMs', 'elapsed_ms', 'elapsedMs'],
|
|
55
|
+
(value) => finiteNonnegative(value) !== null,
|
|
56
|
+
);
|
|
57
|
+
if (direct !== null) return finiteNonnegative(direct);
|
|
58
|
+
for (const container of containers) {
|
|
59
|
+
const started = Date.parse(String(container.started_at || container.startedAt || ''));
|
|
60
|
+
const finished = Date.parse(String(container.finished_at || container.finishedAt || ''));
|
|
61
|
+
if (Number.isFinite(started) && Number.isFinite(finished) && finished >= started) {
|
|
62
|
+
return finished - started;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function recordTimestamp(containers, fallbackMs) {
|
|
69
|
+
const raw = firstField(containers, [
|
|
70
|
+
'at',
|
|
71
|
+
'ts',
|
|
72
|
+
'finished_at',
|
|
73
|
+
'finishedAt',
|
|
74
|
+
'created_at',
|
|
75
|
+
'createdAt',
|
|
76
|
+
'updated_at',
|
|
77
|
+
'updatedAt',
|
|
78
|
+
'started_at',
|
|
79
|
+
'startedAt',
|
|
80
|
+
]);
|
|
81
|
+
const parsed = Date.parse(String(raw || ''));
|
|
82
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
83
|
+
return Number.isFinite(fallbackMs) ? fallbackMs : Date.now();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function observationFromRecord(record, source, fallbackMs) {
|
|
87
|
+
const containers = recordContainers(record);
|
|
88
|
+
const engine = normalizedLabel(firstField(containers, [
|
|
89
|
+
'engine_id',
|
|
90
|
+
'engine',
|
|
91
|
+
'selected_engine',
|
|
92
|
+
'executor_engine',
|
|
93
|
+
'runner',
|
|
94
|
+
]));
|
|
95
|
+
const taskType = normalizedLabel(firstField(containers, [
|
|
96
|
+
'task_type',
|
|
97
|
+
'taskType',
|
|
98
|
+
'job_type',
|
|
99
|
+
'jobType',
|
|
100
|
+
'role',
|
|
101
|
+
'kind',
|
|
102
|
+
]));
|
|
103
|
+
const passed = firstField(
|
|
104
|
+
containers,
|
|
105
|
+
['verified_passed', 'verifier_passed', 'passed', 'verified'],
|
|
106
|
+
(value) => typeof value === 'boolean',
|
|
107
|
+
);
|
|
108
|
+
if (!engine || !taskType || typeof passed !== 'boolean') return null;
|
|
109
|
+
return {
|
|
110
|
+
engine,
|
|
111
|
+
task_type: taskType,
|
|
112
|
+
verified_passed: passed,
|
|
113
|
+
duration_ms: recordDuration(containers),
|
|
114
|
+
at_ms: recordTimestamp(containers, fallbackMs),
|
|
115
|
+
source,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function readTaskReceiptObservations(root) {
|
|
120
|
+
const runsDir = path.join(root, 'atris', 'runs');
|
|
121
|
+
let entries;
|
|
122
|
+
try {
|
|
123
|
+
entries = fs.readdirSync(runsDir, { withFileTypes: true });
|
|
124
|
+
} catch {
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
const observations = [];
|
|
128
|
+
for (const entry of entries) {
|
|
129
|
+
if (!entry.isFile() || !entry.name.endsWith('.json') || !entry.name.includes('task-')) continue;
|
|
130
|
+
const file = path.join(runsDir, entry.name);
|
|
131
|
+
try {
|
|
132
|
+
const stat = fs.statSync(file);
|
|
133
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
134
|
+
const observation = observationFromRecord(parsed, path.relative(root, file), stat.mtimeMs);
|
|
135
|
+
if (observation) observations.push(observation);
|
|
136
|
+
} catch {}
|
|
137
|
+
}
|
|
138
|
+
return observations;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function readMissionEventObservations(root) {
|
|
142
|
+
const file = path.join(root, '.atris', 'state', 'mission_events.jsonl');
|
|
143
|
+
let raw;
|
|
144
|
+
let fallbackMs = Date.now();
|
|
145
|
+
try {
|
|
146
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
147
|
+
fallbackMs = fs.statSync(file).mtimeMs;
|
|
148
|
+
} catch {
|
|
149
|
+
return [];
|
|
150
|
+
}
|
|
151
|
+
const observations = [];
|
|
152
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
153
|
+
if (!line.trim()) continue;
|
|
154
|
+
try {
|
|
155
|
+
const observation = observationFromRecord(JSON.parse(line), path.relative(root, file), fallbackMs);
|
|
156
|
+
if (observation) observations.push(observation);
|
|
157
|
+
} catch {}
|
|
158
|
+
}
|
|
159
|
+
return observations;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function readDispatchReceiptObservations(root) {
|
|
163
|
+
const runsDir = path.join(root, 'atris', 'runs');
|
|
164
|
+
let entries;
|
|
165
|
+
try {
|
|
166
|
+
entries = fs.readdirSync(runsDir, { withFileTypes: true });
|
|
167
|
+
} catch {
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
170
|
+
const observations = [];
|
|
171
|
+
for (const entry of entries) {
|
|
172
|
+
if (!entry.isFile() || !entry.name.startsWith('dispatch-') || !entry.name.endsWith('.json')) continue;
|
|
173
|
+
const file = path.join(runsDir, entry.name);
|
|
174
|
+
try {
|
|
175
|
+
const stat = fs.statSync(file);
|
|
176
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
177
|
+
const results = Array.isArray(parsed?.results) ? parsed.results : [];
|
|
178
|
+
for (const [index, row] of results.entries()) {
|
|
179
|
+
if (typeof row?.verified_passed !== 'boolean') continue;
|
|
180
|
+
const source = `${path.relative(root, file)}#results[${index}]`;
|
|
181
|
+
const observation = observationFromRecord(row, source, stat.mtimeMs);
|
|
182
|
+
if (observation) observations.push(observation);
|
|
183
|
+
}
|
|
184
|
+
} catch {}
|
|
185
|
+
}
|
|
186
|
+
return observations;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function loadRouterHistory(root = process.cwd()) {
|
|
190
|
+
return [
|
|
191
|
+
...readTaskReceiptObservations(root),
|
|
192
|
+
...readMissionEventObservations(root),
|
|
193
|
+
...readDispatchReceiptObservations(root),
|
|
194
|
+
];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function median(values) {
|
|
198
|
+
const sorted = values
|
|
199
|
+
.map(finiteNonnegative)
|
|
200
|
+
.filter((value) => value !== null)
|
|
201
|
+
.sort((left, right) => left - right);
|
|
202
|
+
if (!sorted.length) return null;
|
|
203
|
+
const middle = Math.floor(sorted.length / 2);
|
|
204
|
+
return sorted.length % 2
|
|
205
|
+
? sorted[middle]
|
|
206
|
+
: (sorted[middle - 1] + sorted[middle]) / 2;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function historyKey(engine, taskType) {
|
|
210
|
+
return `${normalizedLabel(engine)}\u0000${normalizedLabel(taskType)}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function computeEngineTaskStats(observations, options = {}) {
|
|
214
|
+
const nowValue = options.now instanceof Date ? options.now.getTime() : Number(options.now ?? Date.now());
|
|
215
|
+
const nowMs = Number.isFinite(nowValue) ? nowValue : Date.now();
|
|
216
|
+
const halfLifeValue = Number(options.halfLifeMs ?? DEFAULT_HALF_LIFE_MS);
|
|
217
|
+
const halfLifeMs = Number.isFinite(halfLifeValue) && halfLifeValue > 0
|
|
218
|
+
? halfLifeValue
|
|
219
|
+
: DEFAULT_HALF_LIFE_MS;
|
|
220
|
+
const groups = new Map();
|
|
221
|
+
for (const row of Array.isArray(observations) ? observations : []) {
|
|
222
|
+
const engine = normalizedLabel(row?.engine);
|
|
223
|
+
const taskType = normalizedLabel(row?.task_type || row?.taskType);
|
|
224
|
+
if (!engine || !taskType || typeof row?.verified_passed !== 'boolean') continue;
|
|
225
|
+
const key = historyKey(engine, taskType);
|
|
226
|
+
const group = groups.get(key) || { engine, task_type: taskType, receipts: [] };
|
|
227
|
+
group.receipts.push(row);
|
|
228
|
+
groups.set(key, group);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return [...groups.values()].map((group) => {
|
|
232
|
+
let weightedPasses = 0;
|
|
233
|
+
let totalWeight = 0;
|
|
234
|
+
let verifiedPasses = 0;
|
|
235
|
+
for (const receipt of group.receipts) {
|
|
236
|
+
const atMs = Number(receipt.at_ms);
|
|
237
|
+
const ageMs = Number.isFinite(atMs) ? Math.max(0, nowMs - atMs) : halfLifeMs;
|
|
238
|
+
const weight = Math.pow(0.5, ageMs / halfLifeMs);
|
|
239
|
+
totalWeight += weight;
|
|
240
|
+
if (receipt.verified_passed) {
|
|
241
|
+
verifiedPasses += 1;
|
|
242
|
+
weightedPasses += weight;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const receiptCount = group.receipts.length;
|
|
246
|
+
const passRate = verifiedPasses / receiptCount;
|
|
247
|
+
const weightedPassRate = totalWeight > 0 ? weightedPasses / totalWeight : passRate;
|
|
248
|
+
const medianDurationMs = median(group.receipts.map((receipt) => receipt.duration_ms));
|
|
249
|
+
const durationScore = medianDurationMs === null
|
|
250
|
+
? 0
|
|
251
|
+
: 1 / (1 + (medianDurationMs / DURATION_SCALE_MS));
|
|
252
|
+
return {
|
|
253
|
+
engine: group.engine,
|
|
254
|
+
task_type: group.task_type,
|
|
255
|
+
receipt_count: receiptCount,
|
|
256
|
+
verified_passes: verifiedPasses,
|
|
257
|
+
verified_failures: receiptCount - verifiedPasses,
|
|
258
|
+
verified_pass_rate: passRate,
|
|
259
|
+
median_duration_ms: medianDurationMs,
|
|
260
|
+
recency_weighted_score: (weightedPassRate * 0.85) + (durationScore * 0.15),
|
|
261
|
+
};
|
|
262
|
+
}).sort((left, right) => left.engine.localeCompare(right.engine)
|
|
263
|
+
|| left.task_type.localeCompare(right.task_type));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function candidateId(candidate) {
|
|
267
|
+
return normalizedLabel(typeof candidate === 'string' ? candidate : candidate?.id || candidate?.name);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function fallbackOrder(candidate, index) {
|
|
271
|
+
const value = Number(candidate && typeof candidate === 'object' ? candidate.fallback_order : NaN);
|
|
272
|
+
return Number.isFinite(value) ? value : index;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function legacyRank(candidates) {
|
|
276
|
+
return candidates
|
|
277
|
+
.map((candidate, index) => ({ candidate, index }))
|
|
278
|
+
.sort((left, right) => fallbackOrder(left.candidate, left.index) - fallbackOrder(right.candidate, right.index)
|
|
279
|
+
|| candidateId(left.candidate).localeCompare(candidateId(right.candidate)))
|
|
280
|
+
.map((entry) => entry.candidate);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function rankEnginesDetailed(candidates, options = {}) {
|
|
284
|
+
const input = Array.isArray(candidates) ? candidates : [];
|
|
285
|
+
const legacy = legacyRank(input);
|
|
286
|
+
const taskType = normalizedLabel(options.taskType || options.task_type);
|
|
287
|
+
const minValue = Number(options.minReceipts ?? DEFAULT_MIN_RECEIPTS);
|
|
288
|
+
const minReceipts = Number.isInteger(minValue) && minValue > 0 ? minValue : DEFAULT_MIN_RECEIPTS;
|
|
289
|
+
const observations = Array.isArray(options.observations)
|
|
290
|
+
? options.observations
|
|
291
|
+
: loadRouterHistory(options.root || process.cwd());
|
|
292
|
+
const stats = computeEngineTaskStats(observations, options);
|
|
293
|
+
const statsByEngine = new Map(stats
|
|
294
|
+
.filter((row) => row.task_type === taskType)
|
|
295
|
+
.map((row) => [row.engine, row]));
|
|
296
|
+
const thinEngines = legacy
|
|
297
|
+
.map(candidateId)
|
|
298
|
+
.filter((engine) => !statsByEngine.has(engine) || statsByEngine.get(engine).receipt_count < minReceipts);
|
|
299
|
+
|
|
300
|
+
if (!taskType || thinEngines.length) {
|
|
301
|
+
return {
|
|
302
|
+
candidates: legacy,
|
|
303
|
+
stats,
|
|
304
|
+
task_type: taskType,
|
|
305
|
+
used_track_record: false,
|
|
306
|
+
thin_engines: thinEngines,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const ranked = legacy
|
|
311
|
+
.map((candidate, index) => ({ candidate, index, stats: statsByEngine.get(candidateId(candidate)) }))
|
|
312
|
+
.sort((left, right) => right.stats.recency_weighted_score - left.stats.recency_weighted_score
|
|
313
|
+
|| fallbackOrder(left.candidate, left.index) - fallbackOrder(right.candidate, right.index)
|
|
314
|
+
|| candidateId(left.candidate).localeCompare(candidateId(right.candidate)))
|
|
315
|
+
.map((entry) => entry.candidate);
|
|
316
|
+
return {
|
|
317
|
+
candidates: ranked,
|
|
318
|
+
stats,
|
|
319
|
+
task_type: taskType,
|
|
320
|
+
used_track_record: true,
|
|
321
|
+
thin_engines: [],
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function rankEngines(candidates, options = {}) {
|
|
326
|
+
return rankEnginesDetailed(candidates, options).candidates;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function routerPickExplanation(decision) {
|
|
330
|
+
const winner = decision?.candidates?.[0];
|
|
331
|
+
const engine = candidateId(winner);
|
|
332
|
+
if (!engine) return '';
|
|
333
|
+
const taskType = normalizedLabel(decision.task_type) || 'work';
|
|
334
|
+
if (!decision.used_track_record) {
|
|
335
|
+
return `router picked ${engine} because ${taskType} track records are thin, so fallback order applies.`;
|
|
336
|
+
}
|
|
337
|
+
const stats = decision.stats.find((row) => row.engine === engine && row.task_type === taskType);
|
|
338
|
+
if (!stats) return `router picked ${engine} because it has the strongest ${taskType} track record.`;
|
|
339
|
+
const passRate = (stats.verified_pass_rate * 100).toFixed(1);
|
|
340
|
+
const duration = stats.median_duration_ms === null ? 'no duration' : `${Math.round(stats.median_duration_ms)} ms median`;
|
|
341
|
+
return `router picked ${engine} because ${stats.receipt_count} ${taskType} receipts scored ${stats.recency_weighted_score.toFixed(3)} with a ${passRate}% verified pass rate and ${duration}.`;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
module.exports = {
|
|
345
|
+
DEFAULT_MIN_RECEIPTS,
|
|
346
|
+
DEFAULT_HALF_LIFE_MS,
|
|
347
|
+
loadRouterHistory,
|
|
348
|
+
computeEngineTaskStats,
|
|
349
|
+
rankEngines,
|
|
350
|
+
rankEnginesDetailed,
|
|
351
|
+
routerPickExplanation,
|
|
352
|
+
};
|
package/lib/runner-command.js
CHANGED
|
@@ -40,11 +40,43 @@ const RUNNER_PROFILE_DEFS = Object.freeze({
|
|
|
40
40
|
model: '',
|
|
41
41
|
commandTemplate: '{bin} --trust -p {prompt}',
|
|
42
42
|
}),
|
|
43
|
+
// Compatibility names for fleet rosters. These map to real installed
|
|
44
|
+
// runners instead of retired or UI-only model names.
|
|
45
|
+
fable: Object.freeze({
|
|
46
|
+
bin: 'claude',
|
|
47
|
+
model: '',
|
|
48
|
+
commandTemplate: '',
|
|
49
|
+
}),
|
|
50
|
+
composer: Object.freeze({
|
|
51
|
+
bin: 'ax',
|
|
52
|
+
model: 'composer-2-5-fast',
|
|
53
|
+
commandTemplate: '{bin} --fast {prompt}',
|
|
54
|
+
}),
|
|
55
|
+
haiku: Object.freeze({
|
|
56
|
+
bin: 'claude',
|
|
57
|
+
model: 'claude-haiku-4-5',
|
|
58
|
+
commandTemplate: '',
|
|
59
|
+
}),
|
|
43
60
|
devin: Object.freeze({
|
|
44
61
|
bin: 'devin',
|
|
45
62
|
model: '',
|
|
46
63
|
commandTemplate: '{bin} -p -- {prompt}',
|
|
47
64
|
}),
|
|
65
|
+
grok: Object.freeze({
|
|
66
|
+
bin: 'grok',
|
|
67
|
+
model: 'grok-4.5',
|
|
68
|
+
commandTemplate: '{bin} --always-approve -p {prompt}',
|
|
69
|
+
}),
|
|
70
|
+
hermes: Object.freeze({
|
|
71
|
+
bin: 'hermes',
|
|
72
|
+
model: '',
|
|
73
|
+
commandTemplate: '{bin} -p -- {prompt}',
|
|
74
|
+
}),
|
|
75
|
+
droid: Object.freeze({
|
|
76
|
+
bin: 'droid',
|
|
77
|
+
model: '',
|
|
78
|
+
commandTemplate: '{bin} exec {prompt}',
|
|
79
|
+
}),
|
|
48
80
|
});
|
|
49
81
|
|
|
50
82
|
// Alias -> canonical profile name. Every alias resolves to the same config as
|
|
@@ -53,6 +85,7 @@ const RUNNER_PROFILE_DEFS = Object.freeze({
|
|
|
53
85
|
const RUNNER_PROFILE_ALIASES = Object.freeze({
|
|
54
86
|
'atris2-fast': 'atris-fast',
|
|
55
87
|
'atris-2-fast': 'atris-fast',
|
|
88
|
+
'hermes-agent': 'hermes',
|
|
56
89
|
});
|
|
57
90
|
|
|
58
91
|
// Back-compat surface: RUNNER_PROFILES still resolves every accepted name
|