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.
Files changed (163) hide show
  1. package/AGENTS.md +35 -0
  2. package/FOR_AGENTS.md +5 -3
  3. package/README.md +5 -3
  4. package/atris/GETTING_STARTED.md +1 -1
  5. package/atris/atris.md +3 -0
  6. package/atris/policies/day-loop-voice.md +102 -0
  7. package/atris/policies/outbound-artifact-gate.md +2 -0
  8. package/atris/skills/design/SKILL.md +56 -32
  9. package/atris/skills/endgame/SKILL.md +12 -6
  10. package/atris/skills/engines/SKILL.md +35 -8
  11. package/atris/skills/fable-method/SKILL.md +66 -0
  12. package/atris/skills/improve/SKILL.md +65 -45
  13. package/atris/skills/render-cli/SKILL.md +88 -0
  14. package/atris/skills/youtube/SKILL.md +10 -1
  15. package/atris.md +6 -2
  16. package/ax +617 -31
  17. package/bin/atris.js +719 -266
  18. package/commands/activate.js +194 -88
  19. package/commands/aeo.js +52 -0
  20. package/commands/agents.js +166 -0
  21. package/commands/autoland.js +718 -72
  22. package/commands/autopilot-front.js +20 -2
  23. package/commands/autopilot.js +118 -2
  24. package/commands/avail.js +407 -0
  25. package/commands/bench.js +188 -0
  26. package/commands/brain.js +3 -0
  27. package/commands/brief.js +651 -0
  28. package/commands/business-sync.js +192 -6
  29. package/commands/business.js +91 -8
  30. package/commands/clean.js +50 -24
  31. package/commands/close.js +1083 -0
  32. package/commands/cloud.js +245 -0
  33. package/commands/codex-goal.js +26 -2
  34. package/commands/compile.js +292 -1
  35. package/commands/computer.js +150 -3
  36. package/commands/dream.js +365 -0
  37. package/commands/drill.js +371 -0
  38. package/commands/drive.js +187 -0
  39. package/commands/engine.js +1061 -29
  40. package/commands/experiments.js +28 -0
  41. package/commands/feed.js +202 -0
  42. package/commands/feedback.js +34 -12
  43. package/commands/fleet-report.js +206 -0
  44. package/commands/github.js +38 -0
  45. package/commands/gm.js +285 -3
  46. package/commands/goal.js +247 -0
  47. package/commands/improve.js +642 -26
  48. package/commands/init.js +83 -43
  49. package/commands/integrations.js +39 -11
  50. package/commands/interview.js +209 -0
  51. package/commands/land.js +253 -52
  52. package/commands/lesson.js +112 -1
  53. package/commands/lifecycle.js +39 -3
  54. package/commands/linear.js +38 -0
  55. package/commands/log.js +84 -1
  56. package/commands/loops.js +220 -16
  57. package/commands/meet.js +220 -0
  58. package/commands/member.js +899 -66
  59. package/commands/mission.js +3512 -332
  60. package/commands/next.js +137 -0
  61. package/commands/now.js +240 -21
  62. package/commands/one-lap.js +776 -0
  63. package/commands/orb.js +314 -0
  64. package/commands/pack-craft.js +179 -0
  65. package/commands/pack.js +823 -0
  66. package/commands/play.js +3 -2
  67. package/commands/probe.js +30 -3
  68. package/commands/pulse.js +241 -46
  69. package/commands/push.js +260 -82
  70. package/commands/radar.js +259 -14
  71. package/commands/rainmaker.js +49 -0
  72. package/commands/report.js +415 -0
  73. package/commands/scout.js +147 -0
  74. package/commands/search.js +363 -0
  75. package/commands/serve.js +54 -0
  76. package/commands/skill.js +47 -3
  77. package/commands/slop.js +50 -2
  78. package/commands/soul.js +1 -1
  79. package/commands/status.js +50 -5
  80. package/commands/stream.js +861 -0
  81. package/commands/stripe.js +38 -0
  82. package/commands/study.js +693 -0
  83. package/commands/supabase.js +39 -0
  84. package/commands/sync.js +67 -54
  85. package/commands/task.js +2275 -182
  86. package/commands/team.js +73 -0
  87. package/commands/truth.js +29 -3
  88. package/commands/unknowns.js +627 -0
  89. package/commands/update.js +44 -0
  90. package/commands/vercel.js +38 -0
  91. package/commands/verify.js +96 -0
  92. package/commands/watch.js +303 -0
  93. package/commands/wish.js +500 -0
  94. package/commands/workflow.js +11 -5
  95. package/commands/worktree.js +299 -20
  96. package/commands/write.js +399 -0
  97. package/commands/xp.js +29 -11
  98. package/lib/auto-accept-certified.js +391 -43
  99. package/lib/autoland.js +353 -52
  100. package/lib/ax-auto-lane.js +79 -0
  101. package/lib/bench/context.js +147 -0
  102. package/lib/bench/engines.js +141 -0
  103. package/lib/bench/report.js +140 -0
  104. package/lib/bench/runner.js +512 -0
  105. package/lib/brief-ledger.js +350 -0
  106. package/lib/cloud-mission.js +259 -0
  107. package/lib/codex-flight.js +154 -0
  108. package/lib/default-runner.js +45 -0
  109. package/lib/default-verifier.js +70 -0
  110. package/lib/engine-registry.js +232 -0
  111. package/lib/experiments/daily.js +640 -0
  112. package/lib/fleet.js +2431 -38
  113. package/lib/improve-vitals-html.js +171 -0
  114. package/lib/known-commands.js +58 -0
  115. package/lib/loop-doctor.js +416 -0
  116. package/lib/member-switches.js +144 -0
  117. package/lib/memory-view.js +14 -5
  118. package/lib/mission-room.js +1 -0
  119. package/lib/mission-root.js +52 -0
  120. package/lib/mission-runtime-loop.js +7 -0
  121. package/lib/next-moves.js +327 -10
  122. package/lib/official-cli-integration.js +174 -0
  123. package/lib/one-lap-validator.js +60 -0
  124. package/lib/orb-context.js +477 -0
  125. package/lib/orb-scorecard.js +224 -0
  126. package/lib/outbound-send-gate.js +165 -0
  127. package/lib/permission-grants.js +293 -0
  128. package/lib/policy-lessons.js +52 -1
  129. package/lib/pulse.js +277 -3
  130. package/lib/receipt-block.js +168 -0
  131. package/lib/receipt-evidence.js +65 -4
  132. package/lib/review-integrity.js +147 -0
  133. package/lib/router-brain.js +352 -0
  134. package/lib/runner-command.js +33 -0
  135. package/lib/self-drive.js +258 -0
  136. package/lib/short-name.js +103 -0
  137. package/lib/spawn-env.js +18 -0
  138. package/lib/state-detection.js +56 -1
  139. package/lib/sync-status.js +59 -0
  140. package/lib/task-db.js +319 -27
  141. package/lib/task-proof.js +43 -1
  142. package/lib/task-receipt.js +93 -0
  143. package/lib/team-presence.js +260 -0
  144. package/lib/tool-result-encode.js +7 -0
  145. package/lib/trust-tiers.js +90 -0
  146. package/lib/usage.js +107 -0
  147. package/lib/voice-gate.js +163 -0
  148. package/lib/wish-audit.js +1368 -0
  149. package/lib/wish-delegate.js +1840 -0
  150. package/lib/wish-design.js +110 -0
  151. package/lib/wish-stats.js +183 -0
  152. package/lib/wish-store.js +354 -0
  153. package/lib/zip.js +221 -0
  154. package/package.json +3 -1
  155. package/templates/loops/atris/loops/LOOPS.md +55 -0
  156. package/templates/loops/atris/loops/TICK.md +24 -0
  157. package/templates/loops/atris/loops/feedback.md +22 -0
  158. package/templates/loops/atris/loops/quality.md +22 -0
  159. package/templates/loops/atris/wiki/systems/loops.md +41 -0
  160. package/utils/api.js +5 -1
  161. package/utils/auth.js +57 -21
  162. package/utils/update-check.js +27 -6
  163. package/atris/learnings.jsonl +0 -1
@@ -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
- if (result.passed === true || result.verifier_result?.passed === true || result.tick?.verifier_passed === true) return true;
20
- if (result.passed === false || result.verifier_result?.passed === false || result.tick?.verifier_passed === false) return false;
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
- // True only when every named receipt exists and none shows a failing verifier.
60
- all_passing: receipts.length > 0 && !missing.length && receipts.every((entry) => entry.verifier_passed !== false),
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
+ };
@@ -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