atris 3.65.0 → 3.67.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 +27 -0
- package/atris/skills/engines/SKILL.md +2 -2
- package/bin/atris.js +41 -3
- package/commands/agent-client.js +266 -0
- package/commands/auth.js +225 -21
- package/commands/autoland.js +62 -0
- package/commands/brain.js +4 -0
- package/commands/engine.js +23 -1
- package/commands/link.js +367 -0
- package/commands/loops.js +4 -1
- package/commands/member.js +8 -1
- package/commands/orb.js +1 -1
- package/commands/xp.js +2 -0
- package/commands/youtube.js +1 -1
- package/lib/auto-accept-certified.js +89 -9
- package/lib/cleanup-summary.js +129 -0
- package/lib/engine-ask.js +3 -2
- package/lib/engine-registry.js +203 -15
- package/lib/idle-review-sweep.js +130 -0
- package/lib/known-commands.js +2 -2
- package/lib/land-green.js +18 -4
- package/lib/member-result.js +303 -0
- package/lib/roster-models.js +1 -1
- package/lib/runner-command.js +4 -4
- package/lib/task-list-keeper.js +25 -4
- package/lib/trust-tiers.js +131 -14
- package/lib/verify-worktree-rewrite.js +115 -0
- package/lib/voice-gate.js +2 -0
- package/package.json +1 -1
- package/scripts/det/ytnotes +1 -1
- package/utils/agent-client.js +363 -0
- package/utils/api.js +20 -2
- package/utils/auth.js +88 -3
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// One plain line per cleanup run, in the daily journal's "## <project>
|
|
4
|
+
// overnight" section: the hourly autoland tick writes its own line there and
|
|
5
|
+
// so do project jobs (project-obelisk's review sweep and state sync). The
|
|
6
|
+
// session start screen reads the latest one back, so the owner sees what the
|
|
7
|
+
// background work did without opening anything.
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
|
|
12
|
+
const CLEANUP_WORDS = /\b(landed|land on|closed|put away|parked|need you|reran|archived)\b/i;
|
|
13
|
+
|
|
14
|
+
function localDate(now) {
|
|
15
|
+
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function journalFile(root, now) {
|
|
19
|
+
return path.join(root, 'atris', 'logs', String(now.getFullYear()), `${localDate(now)}.md`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function sectionName(root) {
|
|
23
|
+
return `${path.basename(path.resolve(root)).replace(/^project-/, '')} overnight`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Same text twice in one day is written once.
|
|
27
|
+
function appendOvernightNote(root, line, now = new Date()) {
|
|
28
|
+
const text = String(line || '').trim();
|
|
29
|
+
if (!text || !fs.existsSync(path.join(root, 'atris'))) return false;
|
|
30
|
+
const file = journalFile(root, now);
|
|
31
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
32
|
+
const body = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
|
|
33
|
+
const bullet = `- ${text}`;
|
|
34
|
+
if (body.split('\n').some((l) => l.trim() === bullet)) return false;
|
|
35
|
+
const marker = `## ${sectionName(root)}`;
|
|
36
|
+
const idx = body.split('\n').findIndex((l) => l.trim() === marker);
|
|
37
|
+
let next;
|
|
38
|
+
if (idx === -1) {
|
|
39
|
+
next = `${body}${body && !body.endsWith('\n') ? '\n' : ''}\n${marker}\n${bullet}\n`;
|
|
40
|
+
} else {
|
|
41
|
+
const lines = body.split('\n');
|
|
42
|
+
let end = idx + 1;
|
|
43
|
+
while (end < lines.length && !lines[end].startsWith('## ')) end += 1;
|
|
44
|
+
let insertAt = end;
|
|
45
|
+
while (insertAt > idx + 1 && !lines[insertAt - 1].trim()) insertAt -= 1;
|
|
46
|
+
lines.splice(insertAt, 0, bullet);
|
|
47
|
+
next = lines.join('\n');
|
|
48
|
+
}
|
|
49
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
50
|
+
fs.writeFileSync(tmp, next);
|
|
51
|
+
fs.renameSync(tmp, file);
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function overnightBullets(text) {
|
|
56
|
+
const bullets = [];
|
|
57
|
+
let inSection = false;
|
|
58
|
+
for (const line of String(text || '').split('\n')) {
|
|
59
|
+
if (line.startsWith('## ')) {
|
|
60
|
+
inSection = /\bovernight\s*$/i.test(line.trim());
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (inSection && line.trim().startsWith('- ')) bullets.push(line.trim().slice(2).trim());
|
|
64
|
+
}
|
|
65
|
+
return bullets;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parkedBranchCount(root) {
|
|
69
|
+
try {
|
|
70
|
+
return fs.readFileSync(path.join(root, 'atris', 'reports', 'parked-branches.md'), 'utf8')
|
|
71
|
+
.split('\n').filter((l) => /^- \d{4}-\d{2}-\d{2} · /.test(l)).length;
|
|
72
|
+
} catch {
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function shorten(text, max) {
|
|
78
|
+
if (text.length <= max) return text;
|
|
79
|
+
const cut = text.slice(0, max - 1);
|
|
80
|
+
const stop = Math.max(cut.lastIndexOf('; '), cut.lastIndexOf(', '));
|
|
81
|
+
return `${(stop > max * 0.5 ? cut.slice(0, stop) : cut).replace(/[\s,;.]+$/, '')}...`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// The newest cleanup line from today's journal (yesterday's if today has
|
|
85
|
+
// none), plus the parked-branch count when the line does not already say it.
|
|
86
|
+
// Two small file reads; never throws.
|
|
87
|
+
function latestCleanupLine(root, { now = new Date(), max = 150 } = {}) {
|
|
88
|
+
try {
|
|
89
|
+
let line = '';
|
|
90
|
+
for (const offset of [0, 1]) {
|
|
91
|
+
const day = new Date(now);
|
|
92
|
+
day.setDate(day.getDate() - offset);
|
|
93
|
+
let text = '';
|
|
94
|
+
try { text = fs.readFileSync(journalFile(root, day), 'utf8'); } catch { continue; }
|
|
95
|
+
const bullets = overnightBullets(text).filter((b) => CLEANUP_WORDS.test(b));
|
|
96
|
+
if (bullets.length) { line = bullets[bullets.length - 1]; break; }
|
|
97
|
+
}
|
|
98
|
+
const parked = parkedBranchCount(root);
|
|
99
|
+
if (parked && !/parked/i.test(line)) {
|
|
100
|
+
const note = `${parked} agent branch${parked === 1 ? '' : 'es'} parked for you`;
|
|
101
|
+
line = line ? `${shorten(line.replace(/\.$/, ''), max - note.length - 2)}; ${note}` : `${note}.`;
|
|
102
|
+
}
|
|
103
|
+
return line ? shorten(line, max) : '';
|
|
104
|
+
} catch {
|
|
105
|
+
return '';
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function plural(n, word, many = `${word}s`) {
|
|
110
|
+
return `${n} ${n === 1 ? word : many}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// The autoland tick's own line. Null when the tick did nothing worth a line.
|
|
114
|
+
function tickSummaryLine({ landed = 0, closed = 0, putAway = 0, needYou = 0 } = {}) {
|
|
115
|
+
const parts = [];
|
|
116
|
+
if (landed) parts.push(`landed ${plural(landed, 'finished item')}`);
|
|
117
|
+
if (closed) parts.push(`closed ${plural(closed, 'idle item')} that could never land on their own, each with a plain reason`);
|
|
118
|
+
if (putAway) parts.push(`put away ${plural(putAway, 'done or long-idle item')}`);
|
|
119
|
+
if (!parts.length) return null;
|
|
120
|
+
if (needYou) parts.push(`${needYou} need you (money, deploys, security, or customers)`);
|
|
121
|
+
const text = parts.join('; ');
|
|
122
|
+
return `Autoland: ${text.charAt(0).toUpperCase()}${text.slice(1)}.`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = {
|
|
126
|
+
appendOvernightNote,
|
|
127
|
+
latestCleanupLine,
|
|
128
|
+
tickSummaryLine,
|
|
129
|
+
};
|
package/lib/engine-ask.js
CHANGED
|
@@ -11,6 +11,7 @@ const {
|
|
|
11
11
|
} = require('./runner-command');
|
|
12
12
|
const {
|
|
13
13
|
canonicalEngineName,
|
|
14
|
+
currentClaudeModel,
|
|
14
15
|
engineFailureHealthStatus,
|
|
15
16
|
setEngineHealth,
|
|
16
17
|
} = require('./engine-registry');
|
|
@@ -241,7 +242,7 @@ function assertAskModelSupported(engine, model) {
|
|
|
241
242
|
|
|
242
243
|
function resolveAskModel(engine, modelName = '') {
|
|
243
244
|
const requested = String(modelName || '').trim();
|
|
244
|
-
if (requested) return requested;
|
|
245
|
+
if (requested) return currentClaudeModel(requested);
|
|
245
246
|
if (engine !== 'claude' && engine !== 'fable' && engine !== 'haiku') return '';
|
|
246
247
|
return RUNNER_PROFILE_DEFS[engine]?.model || DEFAULT_CLAUDE_RUNNER_MODEL;
|
|
247
248
|
}
|
|
@@ -291,7 +292,7 @@ function buildReadOnlyEngineInvocation(engineName, prompt, modelName = '', {
|
|
|
291
292
|
return {
|
|
292
293
|
engine,
|
|
293
294
|
bin: profile.bin,
|
|
294
|
-
args: ['exec', ...(model ? ['-m', model] : []), ...effortArgs, '--sandbox', 'read-only', '--ephemeral', '--ignore-user-config', '--ignore-rules', '--color', 'never', request],
|
|
295
|
+
args: ['exec', ...(model ? ['-m', model] : []), ...effortArgs, '--sandbox', 'read-only', '--ephemeral', '--skip-git-repo-check', '--ignore-user-config', '--ignore-rules', '--color', 'never', request],
|
|
295
296
|
};
|
|
296
297
|
}
|
|
297
298
|
if (engine === 'cursor') {
|
package/lib/engine-registry.js
CHANGED
|
@@ -137,13 +137,20 @@ function engineFailureHealthStatus(result) {
|
|
|
137
137
|
return engineStallReason(result) ? 'error' : null;
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
// hosted: the engine runs on Atris's own servers and bills the person's atris
|
|
141
|
+
// tokens. With no roster pick the router never puts a hosted engine ahead of
|
|
142
|
+
// a tool installed here; atris-fast is the last resort for search and review
|
|
143
|
+
// (not build, see hostedFallbacks), and only for someone logged in to atris.
|
|
144
|
+
// role_order: the engine's place for one job when it differs from its
|
|
145
|
+
// fallback_order. Haiku searches first wherever the claude CLI is installed.
|
|
140
146
|
const ENGINE_SEED_META = Object.freeze({
|
|
141
|
-
'atris-fast': Object.freeze({ tier: 'fast', roles: Object.freeze(['navigator']), models: Object.freeze(['atris fast']), duty: 'learning', fallback_order: 10 }),
|
|
147
|
+
'atris-fast': Object.freeze({ tier: 'fast', roles: Object.freeze(['navigator']), models: Object.freeze(['atris fast']), duty: 'learning', fallback_order: 10, hosted: true }),
|
|
142
148
|
// roster_only_roles: jobs a roster pick may give this engine, but the router
|
|
143
|
-
// never hands it on its own. Claude
|
|
144
|
-
//
|
|
145
|
-
// review when picked; it stays out of roles, so every review
|
|
146
|
-
// from roles (the router, one lap, wish audit, the chart) is
|
|
149
|
+
// never hands it on its own. Claude can own search when picked; with no
|
|
150
|
+
// pick, search goes to haiku, then other installed tools, then atris-fast.
|
|
151
|
+
// Codex can own review when picked; it stays out of roles, so every review
|
|
152
|
+
// list built from roles (the router, one lap, wish audit, the chart) is
|
|
153
|
+
// unchanged.
|
|
147
154
|
codex: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), roster_only_roles: Object.freeze(['validator']), models: Object.freeze(['codex']), fallback_order: 10 }),
|
|
148
155
|
claude: Object.freeze({ tier: 'max', roles: Object.freeze(['validator', 'executor', 'navigator']), roster_only_roles: Object.freeze(['navigator']), models: Object.freeze(['opus 5.5', 'opus 5', 'fable', 'haiku']), fallback_order: 20 }),
|
|
149
156
|
cursor: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), models: Object.freeze(['composer 2.5', 'grok 4.6', 'kimi 3']), fallback_order: 30 }),
|
|
@@ -151,8 +158,8 @@ const ENGINE_SEED_META = Object.freeze({
|
|
|
151
158
|
devin: Object.freeze({ tier: 'max', roles: Object.freeze(['executor']), roster_only_roles: Object.freeze(['navigator']), models: Object.freeze(['built-in router']), duty: 'errands', fallback_order: 40 }),
|
|
152
159
|
grok: Object.freeze({ tier: 'pro', roles: Object.freeze(['executor']), models: Object.freeze(['grok 4.7 fast', 'grok 4.7']), fallback_order: 45 }),
|
|
153
160
|
fable: Object.freeze({ tier: 'max', roles: Object.freeze(['validator', 'executor']), models: Object.freeze(['opus 5.5', 'opus 5', 'fable', 'haiku']), duty: 'leader', fallback_order: 50 }),
|
|
154
|
-
composer: Object.freeze({ tier: 'fast', roles: Object.freeze(['navigator', 'executor']), models: Object.freeze(['composer 2.5']), fallback_order: 60 }),
|
|
155
|
-
haiku: Object.freeze({ tier: 'fast', roles: Object.freeze(['validator', 'navigator']),
|
|
161
|
+
composer: Object.freeze({ tier: 'fast', roles: Object.freeze(['navigator', 'executor']), models: Object.freeze(['composer 2.5']), fallback_order: 60, hosted: true }),
|
|
162
|
+
haiku: Object.freeze({ tier: 'fast', roles: Object.freeze(['validator', 'navigator']), role_order: Object.freeze({ navigator: 5 }), models: Object.freeze(['haiku']), fallback_order: 70 }),
|
|
156
163
|
agy: Object.freeze({
|
|
157
164
|
tier: 'pro',
|
|
158
165
|
roles: Object.freeze(['executor']),
|
|
@@ -203,6 +210,16 @@ function binInstalled(bin) {
|
|
|
203
210
|
return probe.status === 0 && Boolean(String(probe.stdout || '').trim());
|
|
204
211
|
}
|
|
205
212
|
|
|
213
|
+
// A saved not_installed is checked again on the real clock (never a test's
|
|
214
|
+
// routing clock) once its last check, or the run that found it missing, is
|
|
215
|
+
// a minute old. One `command -v` per missing tool per minute, no network.
|
|
216
|
+
const MISSING_RECHECK_MS = 60000;
|
|
217
|
+
|
|
218
|
+
function missingCheckDue(health) {
|
|
219
|
+
const stamp = Date.parse((health && (health.checked_at || health.last_failure_ts)) || '');
|
|
220
|
+
return !Number.isFinite(stamp) || stamp + MISSING_RECHECK_MS <= Date.now();
|
|
221
|
+
}
|
|
222
|
+
|
|
206
223
|
function canonicalEngineName(name) {
|
|
207
224
|
const trimmed = String(name || '').trim();
|
|
208
225
|
if (!trimmed) return '';
|
|
@@ -256,17 +273,30 @@ function normalizeEngineEntry(id, saved = {}, now) {
|
|
|
256
273
|
// Policy over probes: a saved health status is the routing truth, verbatim.
|
|
257
274
|
// Only an engine the registry has never seen gets one seeding probe; after
|
|
258
275
|
// that, installed-state changes flow through `atris engine doctor` (or
|
|
259
|
-
// `atris engine health`), never through the resolve path.
|
|
276
|
+
// `atris engine health`), never through the resolve path. One exception:
|
|
277
|
+
// a missing tool is looked for again once its check is a minute old, so a
|
|
278
|
+
// tool installed next week is picked up with no repair step.
|
|
260
279
|
let status;
|
|
261
280
|
let installed;
|
|
281
|
+
let checkedAt = '';
|
|
262
282
|
if (ENGINE_HEALTH_STATUSES.includes(savedStatus)) {
|
|
263
283
|
status = savedStatus;
|
|
264
284
|
installed = typeof saved.installed === 'boolean' ? saved.installed : status !== 'not_installed';
|
|
285
|
+
if (status === 'not_installed') {
|
|
286
|
+
checkedAt = String(savedHealth.checked_at || '');
|
|
287
|
+
if (missingCheckDue(savedHealth)) {
|
|
288
|
+
installed = binInstalled(def.bin);
|
|
289
|
+
status = installed ? 'ready' : 'not_installed';
|
|
290
|
+
checkedAt = new Date().toISOString();
|
|
291
|
+
}
|
|
292
|
+
}
|
|
265
293
|
} else {
|
|
266
294
|
installed = binInstalled(def.bin);
|
|
267
295
|
status = installed ? 'ready' : 'not_installed';
|
|
296
|
+
if (!installed) checkedAt = new Date().toISOString();
|
|
268
297
|
}
|
|
269
298
|
let health = { status };
|
|
299
|
+
if (status === 'not_installed' && checkedAt) health.checked_at = checkedAt;
|
|
270
300
|
if (status !== 'ready' && savedHealth.last_failure_ts) health.last_failure_ts = String(savedHealth.last_failure_ts);
|
|
271
301
|
if (status === 'cooling') {
|
|
272
302
|
// A cooldown that has passed reads as ready, so the engine comes back
|
|
@@ -559,7 +589,12 @@ function rosterPickExpired(pick, now = new Date()) {
|
|
|
559
589
|
// read normalizes again, so older saved picks and hand edits work too.
|
|
560
590
|
const CLAUDE_FAMILY_ENGINES = Object.freeze(['claude', 'fable', 'haiku']);
|
|
561
591
|
const CLAUDE_MODEL_ALIASES = Object.freeze(['opus', 'sonnet', 'haiku', 'fable', 'opusplan', 'default']);
|
|
562
|
-
|
|
592
|
+
// A retired model runs as its replacement, so a saved pick keeps working
|
|
593
|
+
// after the upgrade. Retiring the next one is one line here.
|
|
594
|
+
const RETIRED_CLAUDE_MODELS = Object.freeze({
|
|
595
|
+
'claude-haiku-4-5': 'claude-haiku-5-5',
|
|
596
|
+
'claude-haiku-4-5-20251001': 'claude-haiku-5-5',
|
|
597
|
+
});
|
|
563
598
|
const CLAUDE_MODEL_SPELLING = /^(opus|sonnet|haiku|fable)[\s_-]*(\d+)(?:[\s._-]+(\d+))?$/;
|
|
564
599
|
const CLAUDE_LONG_CONTEXT = /\s*\[1m\]$/;
|
|
565
600
|
|
|
@@ -574,6 +609,30 @@ function normalizeGrokModel(raw) {
|
|
|
574
609
|
throw new Error(`grok does not know the model "${raw}". use grok 4.7 fast, grok 4.7, or a full grok- id`);
|
|
575
610
|
}
|
|
576
611
|
|
|
612
|
+
// The model to run for a claude id: its replacement when it is retired,
|
|
613
|
+
// else the id as given. Keeps a "[1m]" tail.
|
|
614
|
+
function currentClaudeModel(value) {
|
|
615
|
+
const raw = String(value || '').trim();
|
|
616
|
+
const suffix = CLAUDE_LONG_CONTEXT.test(raw) ? '[1m]' : '';
|
|
617
|
+
const base = raw.replace(CLAUDE_LONG_CONTEXT, '').trim();
|
|
618
|
+
const next = RETIRED_CLAUDE_MODELS[base.toLowerCase()];
|
|
619
|
+
return next ? `${next}${suffix}` : raw;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// The retired models a roster file's text still names, by id or spelled
|
|
623
|
+
// out, each once, so the roster can say they were upgraded.
|
|
624
|
+
function retiredModelsNamed(text) {
|
|
625
|
+
const found = [];
|
|
626
|
+
for (const id of Object.keys(RETIRED_CLAUDE_MODELS)) {
|
|
627
|
+
const [, family, major, minor] = /^claude-([a-z]+)-(\d+)-(\d+)/.exec(id) || [];
|
|
628
|
+
if (!family) continue;
|
|
629
|
+
const spelled = new RegExp(`\\b(?:claude[\\s_-]*)?${family}[\\s_-]*${major}[\\s._-]+${minor}(?![\\d.])`, 'i');
|
|
630
|
+
const base = `claude-${family}-${major}-${minor}`;
|
|
631
|
+
if (spelled.test(String(text || '')) && !found.includes(base)) found.push(base);
|
|
632
|
+
}
|
|
633
|
+
return found;
|
|
634
|
+
}
|
|
635
|
+
|
|
577
636
|
function normalizeRosterModel(engineId, value) {
|
|
578
637
|
const raw = String(value || '').trim();
|
|
579
638
|
if (!raw) return '';
|
|
@@ -588,10 +647,10 @@ function normalizeRosterModel(engineId, value) {
|
|
|
588
647
|
const match = CLAUDE_MODEL_SPELLING.exec(lower.replace(/^claude[\s_-]*/, ''));
|
|
589
648
|
if (match) {
|
|
590
649
|
const id = `claude-${match[1]}-${match[2]}${match[3] ? `-${match[3]}` : ''}`;
|
|
591
|
-
return `${
|
|
650
|
+
return currentClaudeModel(`${id}${suffix}`);
|
|
592
651
|
}
|
|
593
|
-
if (lower.startsWith('claude-')) return `${base}${suffix}
|
|
594
|
-
throw new Error(`${engineId} does not know the model "${raw}". use opus, sonnet, haiku, fable, opusplan, default, a version like opus 5.5, sonnet 5, haiku
|
|
652
|
+
if (lower.startsWith('claude-')) return currentClaudeModel(`${base}${suffix}`);
|
|
653
|
+
throw new Error(`${engineId} does not know the model "${raw}". use opus, sonnet, haiku, fable, opusplan, default, a version like opus 5.5, sonnet 5, haiku 5.5, or fable 5.1, or a full claude- id; add [1m] for the long context window`);
|
|
595
654
|
}
|
|
596
655
|
|
|
597
656
|
// A saved pick's model, normalized the same way assign does. null means the
|
|
@@ -1903,38 +1962,48 @@ function resolveEngineForRoleRanked(role, root = process.cwd(), options = {}) {
|
|
|
1903
1962
|
const engines = registry.engines
|
|
1904
1963
|
.filter((engine) => engine.roles.includes(normalizedRole))
|
|
1905
1964
|
.filter((engine) => !routerSkipsRole(engine, normalizedRole))
|
|
1965
|
+
.filter((engine) => !engineHosted(engine))
|
|
1906
1966
|
.filter((engine) => engineReadyAt(engine, options.now))
|
|
1967
|
+
.map((engine) => withRoleOrder(engine, normalizedRole))
|
|
1907
1968
|
.sort((a, b) => {
|
|
1908
1969
|
const byOrder = Number(a.fallback_order) - Number(b.fallback_order);
|
|
1909
1970
|
return byOrder || String(a.id).localeCompare(String(b.id));
|
|
1910
1971
|
});
|
|
1911
1972
|
// Ranking reads the router's history off disk (task receipts, mission
|
|
1912
1973
|
// events, dispatch receipts). A valid roster pick decides without it, so
|
|
1913
|
-
// rank only when something asks for the full ranked list.
|
|
1974
|
+
// rank only when something asks for the full ranked list. Hosted engines
|
|
1975
|
+
// join after the ranking, so no track record can lift one over a tool
|
|
1976
|
+
// installed here.
|
|
1914
1977
|
let rankedCache = null;
|
|
1915
1978
|
const rank = () => {
|
|
1916
1979
|
if (!rankedCache) {
|
|
1917
|
-
|
|
1980
|
+
const ranked = routerBrain.rankEnginesDetailed(engines, {
|
|
1918
1981
|
root,
|
|
1919
1982
|
taskType: options.taskType || options.task_type || normalizedRole,
|
|
1920
1983
|
lowStakes: options.lowStakes,
|
|
1921
1984
|
stakes: options.stakes,
|
|
1922
1985
|
});
|
|
1986
|
+
rankedCache = { ...ranked, candidates: [...ranked.candidates, ...hostedFallbacks(registry, normalizedRole, options)] };
|
|
1923
1987
|
}
|
|
1924
1988
|
return rankedCache;
|
|
1925
1989
|
};
|
|
1926
1990
|
const choice = rosterChoice(normalizedRole, withProjectRoster(registry, root, options), options);
|
|
1991
|
+
const label = rosterJobLabel(options.job ? rosterJobKey(options.job) || normalizedRole : normalizedRole);
|
|
1927
1992
|
if (choice) {
|
|
1928
1993
|
// The job's own workers go first, in order, then the router's list.
|
|
1929
1994
|
const after = choice.walk.slice(choice.lead_index + 1).filter((step) => !step.skip).map((step) => step.raw);
|
|
1930
1995
|
const lead = [choice.engine, ...after].filter((engine, index, list) => list.findIndex((other) => other.id === engine.id) === index);
|
|
1931
1996
|
const leadIds = new Set(lead.map((engine) => engine.id));
|
|
1997
|
+
const first = choice.walk[0];
|
|
1998
|
+
const missing = choice.lead_index > 0 && first.skip === 'not ready' && engineMissing(registry, first.worker.engine)
|
|
1999
|
+
? first.worker.engine : '';
|
|
1932
2000
|
const result = {
|
|
1933
2001
|
engine: choice.engine,
|
|
1934
2002
|
reason: choice.reason,
|
|
1935
2003
|
source: choice.source,
|
|
1936
2004
|
job: choice.job,
|
|
1937
2005
|
team: choice.team,
|
|
2006
|
+
notice: showSwitchNotice(switchNotice({ label: choice.job, role: normalizedRole, missing, engine: choice.engine }), options),
|
|
1938
2007
|
};
|
|
1939
2008
|
let rankedList = null;
|
|
1940
2009
|
Object.defineProperty(result, 'ranked', {
|
|
@@ -1951,7 +2020,122 @@ function resolveEngineForRoleRanked(role, root = process.cwd(), options = {}) {
|
|
|
1951
2020
|
return result;
|
|
1952
2021
|
}
|
|
1953
2022
|
const ranked = rank();
|
|
1954
|
-
|
|
2023
|
+
const engine = ranked.candidates[0] || null;
|
|
2024
|
+
const reason = engine && engineHosted(engine) && !engines.length
|
|
2025
|
+
? `router picked ${engine.id} because no tool installed here can take ${normalizedRole} work.`
|
|
2026
|
+
: routerPickExplanation(ranked);
|
|
2027
|
+
const notice = switchNotice({
|
|
2028
|
+
label,
|
|
2029
|
+
role: normalizedRole,
|
|
2030
|
+
missing: missingPickedTool(normalizedRole, withProjectRoster(registry, root, options), options),
|
|
2031
|
+
engine,
|
|
2032
|
+
registry,
|
|
2033
|
+
canLogIn: !atrisLoggedIn(options),
|
|
2034
|
+
});
|
|
2035
|
+
return { engine, reason, source: 'router', team: [], ranked: ranked.candidates, notice: showSwitchNotice(notice, options) };
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
// --- When a job runs somewhere other than its pick -----------------------
|
|
2039
|
+
|
|
2040
|
+
function engineHosted(engine) {
|
|
2041
|
+
const seed = engine && ENGINE_SEED_META[engine.id];
|
|
2042
|
+
return Boolean(seed && seed.hosted);
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
// The engine with its place for this one job, when its seed names one.
|
|
2046
|
+
function withRoleOrder(engine, role) {
|
|
2047
|
+
const seed = ENGINE_SEED_META[engine.id];
|
|
2048
|
+
const order = seed && seed.role_order ? seed.role_order[role] : undefined;
|
|
2049
|
+
return Number.isInteger(order) ? { ...engine, fallback_order: order } : engine;
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
// True when this machine is logged in to atris: an ATRIS_TOKEN, or the token
|
|
2053
|
+
// `atris login` saves. One file read, no process and no network, so routing
|
|
2054
|
+
// stays probe-free. options.loggedIn decides in tests.
|
|
2055
|
+
function atrisLoggedIn(options = {}) {
|
|
2056
|
+
if (typeof options.loggedIn === 'boolean') return options.loggedIn;
|
|
2057
|
+
if (String(process.env.ATRIS_TOKEN || '').trim()) return true;
|
|
2058
|
+
try {
|
|
2059
|
+
const saved = JSON.parse(fs.readFileSync(path.join(stateHome(), '.atris', 'credentials.json'), 'utf8'));
|
|
2060
|
+
return Boolean(saved && saved.token);
|
|
2061
|
+
} catch {
|
|
2062
|
+
return false;
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
// The hosted engines that may take this job, after every installed tool:
|
|
2067
|
+
// atris-fast for search and review, composer for search. None without a
|
|
2068
|
+
// login, since a hosted run bills the person's atris tokens. None for build:
|
|
2069
|
+
// the hosted turn's file relay here is read-only (a 2026-10-07 probe asked
|
|
2070
|
+
// it to write a file and the relay refused every write), so a hosted build
|
|
2071
|
+
// would answer in chat and change nothing.
|
|
2072
|
+
function hostedFallbacks(registry, role, options = {}) {
|
|
2073
|
+
if (role === 'executor' || !atrisLoggedIn(options)) return [];
|
|
2074
|
+
return registry.engines
|
|
2075
|
+
.filter((engine) => engineHosted(engine))
|
|
2076
|
+
.filter((engine) => engine.id === 'atris-fast' || (engine.roles.includes(role) && !routerSkipsRole(engine, role)))
|
|
2077
|
+
.filter((engine) => engineReadyAt(engine, options.now))
|
|
2078
|
+
.sort((a, b) => Number(a.fallback_order) - Number(b.fallback_order));
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
function engineMissing(registry, engineId) {
|
|
2082
|
+
const engine = registry.engines.find((entry) => entry.id === engineId);
|
|
2083
|
+
return Boolean(engine && engine.health && engine.health.status === 'not_installed');
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
// The tool the first roster pick in line names for this job, when that tool
|
|
2087
|
+
// is not installed here. '' when no pick names a missing tool.
|
|
2088
|
+
function missingPickedTool(role, registry, options = {}) {
|
|
2089
|
+
const machine = options.machineRosterPicks || readMachineRoster(options);
|
|
2090
|
+
const session = options.sessionRosterPicks || readSessionRosterLayer(options).picks;
|
|
2091
|
+
for (const key of rosterJobKeysFor(role, options)) {
|
|
2092
|
+
for (const value of [session && session[key], registry.roster && registry.roster[key], machine && machine[key]]) {
|
|
2093
|
+
const pick = rosterPickObject(value);
|
|
2094
|
+
if (!pick || pickRoleForKey(key, pick) !== role) continue;
|
|
2095
|
+
const [first] = pickWorkers(pick);
|
|
2096
|
+
if (!first || first.error || rosterPickExpired(first, options.now)) continue;
|
|
2097
|
+
return engineMissing(registry, first.engine) ? first.engine : '';
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
return '';
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
function noticeToolName(engineId) {
|
|
2104
|
+
if (engineId === 'atris-fast') return 'atris 2.5 fast';
|
|
2105
|
+
if (CLAUDE_FAMILY_ENGINES.includes(engineId) && engineId !== 'haiku') return 'claude code';
|
|
2106
|
+
return engineId;
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
// One plain line when a job runs somewhere other than its pick because the
|
|
2110
|
+
// picked tool is missing, or lands on a hosted engine. '' when nothing
|
|
2111
|
+
// switched. With no pick, search's own pick is haiku on claude code.
|
|
2112
|
+
function switchNotice({ label, role, missing = '', engine = null, registry = null, canLogIn = false }) {
|
|
2113
|
+
const missingName = missing ? (CLAUDE_FAMILY_ENGINES.includes(missing) ? 'claude code' : noticeToolName(missing)) : '';
|
|
2114
|
+
if (!engine) {
|
|
2115
|
+
if (!registry) return '';
|
|
2116
|
+
const what = missingName ? `${missingName} is not installed and no other tool here can take it` : 'no tool here can take it';
|
|
2117
|
+
if (role === 'executor') return `${label}: ${what}. install claude code or codex; atris 2.5 fast cannot edit files yet.`;
|
|
2118
|
+
return `${label}: ${what}. install claude code${canLogIn ? ', or run atris login so atris 2.5 fast can take it' : ''}.`;
|
|
2119
|
+
}
|
|
2120
|
+
const hosted = engineHosted(engine);
|
|
2121
|
+
const tokens = hosted ? ' (uses your atris tokens)' : '';
|
|
2122
|
+
if (missingName) return `${label}: ${missingName} is not installed, so ${noticeToolName(engine.id)} took it${tokens}.`;
|
|
2123
|
+
if (!hosted || !registry) return '';
|
|
2124
|
+
const why = role === 'navigator' && engineMissing(registry, 'claude') ? 'claude code is not installed' : 'no tool here can take it';
|
|
2125
|
+
return `${label}: ${why}, so ${noticeToolName(engine.id)} took it${tokens}.`;
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
// Each notice prints once per run, on stderr, however many times a job
|
|
2129
|
+
// resolves. ATRIS_SWITCH_NOTICE=0 silences it.
|
|
2130
|
+
const SHOWN_NOTICES = new Set();
|
|
2131
|
+
|
|
2132
|
+
function showSwitchNotice(notice, options = {}) {
|
|
2133
|
+
if (!notice) return '';
|
|
2134
|
+
if (options.quiet !== true && process.env.ATRIS_SWITCH_NOTICE !== '0' && !SHOWN_NOTICES.has(notice)) {
|
|
2135
|
+
SHOWN_NOTICES.add(notice);
|
|
2136
|
+
console.error(notice);
|
|
2137
|
+
}
|
|
2138
|
+
return notice;
|
|
1955
2139
|
}
|
|
1956
2140
|
|
|
1957
2141
|
// The ordered team for one job: every worker that can run right now, lead
|
|
@@ -2176,6 +2360,7 @@ function engineDoctorReport(root = process.cwd()) {
|
|
|
2176
2360
|
let health = engine.health && engine.health.status ? engine.health : { status: installed ? 'ready' : 'not_installed' };
|
|
2177
2361
|
if (installed && health.status === 'not_installed') health = { status: 'ready' };
|
|
2178
2362
|
if (!installed && health.status === 'ready') health = { status: 'not_installed' };
|
|
2363
|
+
if (!installed && health.status === 'not_installed') health = { ...health, checked_at: new Date().toISOString() };
|
|
2179
2364
|
return { ...engine, installed, health };
|
|
2180
2365
|
});
|
|
2181
2366
|
const next = { ...registry, updated_at: new Date().toISOString(), engines };
|
|
@@ -2200,6 +2385,9 @@ module.exports = {
|
|
|
2200
2385
|
setRosterPick,
|
|
2201
2386
|
confirmRoster,
|
|
2202
2387
|
normalizeRosterModel,
|
|
2388
|
+
currentClaudeModel,
|
|
2389
|
+
retiredModelsNamed,
|
|
2390
|
+
RETIRED_CLAUDE_MODELS,
|
|
2203
2391
|
isClaudeFamilyModel,
|
|
2204
2392
|
rosterModelLabel,
|
|
2205
2393
|
parseRosterUntil,
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Finished work that can never land on its own should not wait forever.
|
|
4
|
+
// Every hourly autoland tick: a review row idle past the grace period whose
|
|
5
|
+
// check this machine cannot rerun from main is closed with a plain reason.
|
|
6
|
+
// Landable rows are left for the landing pass, protected lanes (money,
|
|
7
|
+
// deploys, security, customer, outward) are left for the human, and anything
|
|
8
|
+
// still moving (touched inside the grace period) is left alone.
|
|
9
|
+
|
|
10
|
+
const taskDb = require('./task-db');
|
|
11
|
+
const { evaluateAutoAccept, protectedLaneOf, verifyRunnability } = require('./auto-accept-certified');
|
|
12
|
+
const { plainLandingReason } = require('./voice-gate');
|
|
13
|
+
|
|
14
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
15
|
+
const DEFAULT_IDLE_CLOSE_DAYS = 7;
|
|
16
|
+
|
|
17
|
+
// Reasons that mean "no machine can ever check this from main as it stands".
|
|
18
|
+
// A human or the builder can still reopen it with a check that runs.
|
|
19
|
+
const UNVERIFIABLE_REASONS = new Set([
|
|
20
|
+
'verify_command_not_allowed',
|
|
21
|
+
'verify_only_on_agent_branch',
|
|
22
|
+
'verify_failed_on_main',
|
|
23
|
+
'verify_workdir_missing',
|
|
24
|
+
'verify_worktree_missing',
|
|
25
|
+
'strict_verify_missing',
|
|
26
|
+
'no_verify_command',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
function idleCloseMs(policy = {}) {
|
|
30
|
+
const days = Number(policy && policy.idle_close_days);
|
|
31
|
+
return (Number.isFinite(days) && days > 0 ? days : DEFAULT_IDLE_CLOSE_DAYS) * DAY_MS;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const CLOSE_WHY = {
|
|
35
|
+
verify_command_not_allowed: 'its check is a kind this machine may not run on its own',
|
|
36
|
+
verify_only_on_agent_branch: "its check runs a file that only exists on the agent's branch, never on main",
|
|
37
|
+
verify_failed_on_main: 'its check fails when rerun from the main project, so the work never reached main',
|
|
38
|
+
verify_workdir_missing: 'the folder its check ran in is gone',
|
|
39
|
+
verify_worktree_missing: 'the agent copy its check ran in is gone',
|
|
40
|
+
strict_verify_missing: 'it has no check to rerun',
|
|
41
|
+
no_verify_command: 'it has no check to rerun',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function closeReason(reason, days) {
|
|
45
|
+
const why = CLOSE_WHY[reason] || plainLandingReason(reason) || reason;
|
|
46
|
+
return `Closed after ${days} days waiting, because ${why}, so it could never land on its own. Reopen it with a check that runs from the main project if the work still matters.`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function toMs(value) {
|
|
50
|
+
if (value === null || value === undefined || value === '') return 0;
|
|
51
|
+
const n = Number(value);
|
|
52
|
+
if (Number.isFinite(n)) return n;
|
|
53
|
+
const parsed = Date.parse(value);
|
|
54
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Idle means nobody worked on it, not that no machine touched it: the hourly
|
|
58
|
+
// re-check stamps updated_at on every row whose check runs, which would keep
|
|
59
|
+
// a never-landable row "fresh" forever. Count only people and builders.
|
|
60
|
+
function lastActivityAt(task, now = Date.now()) {
|
|
61
|
+
const metadata = task.metadata || {};
|
|
62
|
+
const at = Math.max(
|
|
63
|
+
toMs(task.created_at),
|
|
64
|
+
toMs(task.claimed_at),
|
|
65
|
+
toMs(metadata.agent_reviewed_at),
|
|
66
|
+
toMs(metadata.stage_updated_at),
|
|
67
|
+
toMs(metadata.planned_at),
|
|
68
|
+
toMs(metadata.human_revision_at),
|
|
69
|
+
toMs(metadata.backlogged_at),
|
|
70
|
+
);
|
|
71
|
+
return at > 0 ? at : now;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Pure decision for one projection row. `liveReasons` carries the landing
|
|
75
|
+
// pass's executed verdicts from this tick (verify_failed_on_main can only be
|
|
76
|
+
// known by running the check), keyed by ref.
|
|
77
|
+
function idleDecision(task, { root, policy = {}, now = Date.now(), liveReasons = new Map() } = {}) {
|
|
78
|
+
if (!task || task.status !== 'review') return { action: 'skip', why: 'not_review' };
|
|
79
|
+
const approval = String(task.review?.approval_status || task.metadata?.approval_status || 'pending');
|
|
80
|
+
if (approval !== 'pending' && approval !== 'agent_certified') return { action: 'skip', why: 'decided' };
|
|
81
|
+
const lane = protectedLaneOf(task);
|
|
82
|
+
if (lane) return { action: 'human', lane };
|
|
83
|
+
const idleMs = now - lastActivityAt(task, now);
|
|
84
|
+
if (!(idleMs > idleCloseMs(policy))) return { action: 'wait', why: 'still_in_grace' };
|
|
85
|
+
const ref = task.display_id || task.legacy_ref || task.id;
|
|
86
|
+
let reason = liveReasons.get(ref) || null;
|
|
87
|
+
if (!reason) {
|
|
88
|
+
const evaluation = evaluateAutoAccept(
|
|
89
|
+
{ ...task, workspace_root: root },
|
|
90
|
+
{ strictVerify: policy.strict_verify !== false, acceptAll: Boolean(policy.accept_all), executeVerify: false },
|
|
91
|
+
);
|
|
92
|
+
if (evaluation.eligible) return { action: 'land', why: 'landing_pass_takes_it' };
|
|
93
|
+
reason = verifyRunnability({ ...task, workspace_root: root }) || evaluation.reason;
|
|
94
|
+
}
|
|
95
|
+
if (!UNVERIFIABLE_REASONS.has(reason)) return { action: 'wait', why: reason };
|
|
96
|
+
return { action: 'close', reason, days: Math.floor(idleMs / DAY_MS) };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function sweepIdleReviews(root, { tasks, policy = {}, now = Date.now(), liveReasons = new Map(), dryRun = false, actor = 'autoland' } = {}) {
|
|
100
|
+
const closed = [];
|
|
101
|
+
const humanWaiting = [];
|
|
102
|
+
const due = [];
|
|
103
|
+
for (const task of tasks || []) {
|
|
104
|
+
const decision = idleDecision(task, { root, policy, now, liveReasons });
|
|
105
|
+
const ref = task.display_id || task.legacy_ref || task.id;
|
|
106
|
+
if (decision.action === 'human') humanWaiting.push({ ref, lane: decision.lane });
|
|
107
|
+
if (decision.action === 'close') due.push({ id: task.id, ref, title: task.title || '', reason: decision.reason, text: closeReason(decision.reason, decision.days) });
|
|
108
|
+
}
|
|
109
|
+
if (dryRun || !due.length) return { closed: dryRun ? due : closed, due: due.length, human_waiting: humanWaiting, dry_run: dryRun };
|
|
110
|
+
|
|
111
|
+
const db = taskDb.open();
|
|
112
|
+
db.exec('BEGIN IMMEDIATE');
|
|
113
|
+
try {
|
|
114
|
+
for (const item of due) {
|
|
115
|
+
const result = taskDb.archiveTask(db, { id: item.id, actor, reason: item.text, skipLogs: true });
|
|
116
|
+
if (result.archived) closed.push(item);
|
|
117
|
+
}
|
|
118
|
+
db.exec('COMMIT');
|
|
119
|
+
} catch (error) {
|
|
120
|
+
try { db.exec('ROLLBACK'); } catch { /* surface the original error */ }
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
return { closed, due: due.length, human_waiting: humanWaiting, dry_run: false };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = {
|
|
127
|
+
idleDecision,
|
|
128
|
+
lastActivityAt,
|
|
129
|
+
sweepIdleReviews,
|
|
130
|
+
};
|
package/lib/known-commands.js
CHANGED
|
@@ -4,8 +4,8 @@ const knownCommands = ['init', 'guide', 'doctor', 'doc-health', 'install', 'work
|
|
|
4
4
|
'activate', '_activate', 'agent', 'team', 'chat', 'fast', 'ax', 'console', 'serve', 'login', 'logout', 'whoami', 'switch', 'use', 'accounts', '_resolve', '_profile-email', '_switch-session', 'shell-init', 'update', 'upgrade', 'version', 'help', 'next', 'atris',
|
|
5
5
|
'clean', 'close', 'harvest', 'verify', 'recover', 'search', 'scout', 'skill', 'member', 'codex-goal', 'app', 'apps', 'learn', 'lesson', 'taste', 'teach', 'plugin', 'experiments', 'bench', 'router', 'tree', 'receipt', 'proof', 'openclaw', 'pull', 'push', 'watch', 'cloud', 'live', 'align', 'terminal', 'computer', 'diff', 'business', 'sync', 'youtube', 'x-search',
|
|
6
6
|
'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'decide', 'agents', 'probe', 'worktree', 'land', 'caretaker', 'autoland', 'drive', 'aeo', 'slop', 'voice', 'strings', 'write', 'security-review', 'secure', 'deck', 'site', 'theme', 'card', 'reel', 'improve', 'study', 'rainmaker', 'xp', 'play', 'gm', 'game', 'x', 'recap', 'report', 'signup', 'clarity', 'interview', 'meet', 'moves', 'unknowns', 'avail', 'sync-checkout',
|
|
7
|
-
'ci', 'github', 'vercel', 'supabase', 'linear', 'stripe', 'balance', 'usage', 'api-key', 'topup', 'buy', 'transactions', 'hire', 'hires', 'mail', 'design', 'mcp', 'gmail', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
|
|
8
|
-
'fork', 'browse', 'publish', 'pack', 'sleep', 'wake', 'feedback', 'errors', 'wiki', 'code-review', 'cr', 'soul', 'fleet', 'fleet-report', 'loops', 'self-improve', 'compile', 'spaceship', 'truth', 'sign', 'engine', 'engines', 'playbook', 'feed', 'brief', 'social', 'host', 'people', 'follow', 'unfollow', 'friends', 'msg', 'inbox', 'invite', 'join', 'rsi'];
|
|
7
|
+
'ci', 'github', 'vercel', 'supabase', 'linear', 'stripe', 'balance', 'usage', 'api-key', 'agent-client', 'topup', 'buy', 'transactions', 'hire', 'hires', 'mail', 'design', 'mcp', 'gmail', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
|
|
8
|
+
'fork', 'browse', 'publish', 'pack', 'sleep', 'wake', 'feedback', 'errors', 'wiki', 'code-review', 'cr', 'soul', 'fleet', 'fleet-report', 'loops', 'self-improve', 'compile', 'spaceship', 'truth', 'sign', 'engine', 'engines', 'link', 'playbook', 'feed', 'brief', 'social', 'host', 'people', 'follow', 'unfollow', 'friends', 'msg', 'inbox', 'invite', 'join', 'rsi'];
|
|
9
9
|
|
|
10
10
|
// Damerau-Levenshtein edit distance between two short strings. Counts an
|
|
11
11
|
// adjacent transposition (e.g. "taks" -> "task") as a single edit, since
|