atris 3.64.0 → 3.66.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 +4 -0
- package/atris/skills/engines/SKILL.md +2 -2
- package/bin/atris.js +45 -2
- package/commands/agent-spawn.js +1 -0
- package/commands/autoland.js +62 -0
- package/commands/brain.js +4 -0
- package/commands/engine.js +203 -63
- package/commands/hire.js +189 -0
- package/commands/link.js +367 -0
- package/commands/loops.js +4 -1
- package/commands/mail.js +163 -0
- package/commands/member.js +8 -1
- package/commands/mission.js +3 -2
- package/commands/orb.js +1 -1
- package/commands/stream.js +22 -1
- package/commands/team.js +355 -67
- package/commands/xp.js +2 -0
- package/commands/youtube.js +1 -1
- package/lib/auto-accept-certified.js +172 -18
- package/lib/business-target.js +131 -0
- package/lib/cleanup-summary.js +129 -0
- package/lib/engine-ask.js +3 -2
- package/lib/engine-processes.js +430 -0
- package/lib/engine-registry.js +219 -18
- 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-identity.js +56 -0
- package/lib/member-result.js +303 -0
- package/lib/roster-models.js +12 -1
- package/lib/roster-stale.js +241 -0
- package/lib/runner-command.js +4 -4
- package/lib/task-list-keeper.js +25 -4
- package/lib/team-lineup.js +10 -71
- package/lib/team-presence.js +224 -0
- package/lib/text-table.js +81 -0
- 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
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// atris member result <member> [--since <iso-time>] [--json]
|
|
4
|
+
//
|
|
5
|
+
// Reads back the newest real result of a member run as one object: the run
|
|
6
|
+
// line from the roster's run record, the newest receipt in the member's
|
|
7
|
+
// logs/, the files it made under work/, and one result number when the
|
|
8
|
+
// receipt states one plainly. Read only.
|
|
9
|
+
//
|
|
10
|
+
// A member run usually works in its own git worktree, so its receipt and
|
|
11
|
+
// files land there, not in the main checkout. The run's mission id leads to
|
|
12
|
+
// that worktree; both places are read, newest file wins.
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
|
|
17
|
+
const { readRosterRuns } = require('./roster-runs');
|
|
18
|
+
|
|
19
|
+
const MEMBER_NAME_RE = /^[a-zA-Z0-9._-]+$/;
|
|
20
|
+
const RUN_DAYS = 90;
|
|
21
|
+
const RECEIPT_LINES = 40;
|
|
22
|
+
const ARTIFACT_LINES = 20;
|
|
23
|
+
const MAX_ARTIFACTS = 50;
|
|
24
|
+
const MAX_WALK_FILES = 2000;
|
|
25
|
+
const HEAD_READ_BYTES = 64 * 1024;
|
|
26
|
+
const SKIP_DIRS = new Set(['.git', 'node_modules', '__pycache__', '.venv', 'venv']);
|
|
27
|
+
|
|
28
|
+
function usage() {
|
|
29
|
+
return [
|
|
30
|
+
'usage:',
|
|
31
|
+
' atris member result <member> [--since <iso-time>] [--json]',
|
|
32
|
+
'',
|
|
33
|
+
'newest result of a member run: run record, receipt, artifacts, and one result number.',
|
|
34
|
+
'',
|
|
35
|
+
'options:',
|
|
36
|
+
' --since <iso-time> only files changed after this time (default: start of the newest run)',
|
|
37
|
+
' --json print the result as json',
|
|
38
|
+
].join('\n');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseResultArgs(args = []) {
|
|
42
|
+
let member = '';
|
|
43
|
+
let since = '';
|
|
44
|
+
let json = false;
|
|
45
|
+
let help = false;
|
|
46
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
47
|
+
const arg = String(args[index]);
|
|
48
|
+
if (arg === '--help' || arg === '-h') { help = true; continue; }
|
|
49
|
+
if (arg === '--json') { json = true; continue; }
|
|
50
|
+
if (arg === '--since') {
|
|
51
|
+
if (index + 1 >= args.length) throw new Error('--since needs an iso time');
|
|
52
|
+
since = String(args[index + 1]);
|
|
53
|
+
index += 1;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (arg.startsWith('--since=')) { since = arg.slice('--since='.length); continue; }
|
|
57
|
+
if (arg.startsWith('--')) throw new Error(`unknown option ${arg}`);
|
|
58
|
+
if (member) throw new Error(usage());
|
|
59
|
+
member = arg.trim();
|
|
60
|
+
}
|
|
61
|
+
if (help) return { help: true, json };
|
|
62
|
+
if (!member) throw new Error(usage());
|
|
63
|
+
if (!MEMBER_NAME_RE.test(member)) throw new Error(`"${member}" is not a member name`);
|
|
64
|
+
if (since && !Number.isFinite(Date.parse(since))) throw new Error(`--since "${since}" is not a time; use an iso time like 2026-10-02T18:00:00Z`);
|
|
65
|
+
return { help: false, member, since, json };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The member's newest run line. `id` is the mission id the run worked on.
|
|
69
|
+
function newestMemberRun(root, member, { now } = {}) {
|
|
70
|
+
const runs = readRosterRuns(root, { now, days: RUN_DAYS })
|
|
71
|
+
.filter((run) => run.member === member && Number.isFinite(Date.parse(run.at)));
|
|
72
|
+
if (!runs.length) return null;
|
|
73
|
+
runs.sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
|
|
74
|
+
const run = runs[runs.length - 1];
|
|
75
|
+
const startedMs = Date.parse(run.at);
|
|
76
|
+
const seconds = Number.isFinite(Number(run.seconds)) ? Number(run.seconds) : null;
|
|
77
|
+
return {
|
|
78
|
+
id: run.task || null,
|
|
79
|
+
started: new Date(startedMs).toISOString(),
|
|
80
|
+
ended: seconds === null ? null : new Date(startedMs + seconds * 1000).toISOString(),
|
|
81
|
+
status: run.outcome,
|
|
82
|
+
engine: run.engine,
|
|
83
|
+
model: run.model || null,
|
|
84
|
+
seconds,
|
|
85
|
+
...(run.detail ? { detail: run.detail } : {}),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Where the run's mission lives when that is a sibling worktree, else null.
|
|
90
|
+
function runWorktreeRoot(root, missionId, deps = {}) {
|
|
91
|
+
if (!missionId) return null;
|
|
92
|
+
try {
|
|
93
|
+
const listRolled = deps.listWorktreeRollupMissions
|
|
94
|
+
|| require('../commands/mission').listWorktreeRollupMissions;
|
|
95
|
+
const found = listRolled(root).find((mission) => mission && mission.id === missionId);
|
|
96
|
+
if (!found || !found.worktree_root || !fs.existsSync(found.worktree_root)) return null;
|
|
97
|
+
return path.resolve(found.worktree_root);
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Files under dir changed after sinceMs, newest first. Bounded walk.
|
|
104
|
+
function changedFiles(dir, sinceMs) {
|
|
105
|
+
const out = [];
|
|
106
|
+
const stack = [dir];
|
|
107
|
+
let seen = 0;
|
|
108
|
+
while (stack.length && seen < MAX_WALK_FILES) {
|
|
109
|
+
const current = stack.pop();
|
|
110
|
+
let entries;
|
|
111
|
+
try {
|
|
112
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
113
|
+
} catch {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
for (const entry of entries) {
|
|
117
|
+
if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name)) continue;
|
|
118
|
+
const full = path.join(current, entry.name);
|
|
119
|
+
if (entry.isDirectory()) { stack.push(full); continue; }
|
|
120
|
+
if (!entry.isFile()) continue;
|
|
121
|
+
seen += 1;
|
|
122
|
+
let stat;
|
|
123
|
+
try {
|
|
124
|
+
stat = fs.statSync(full);
|
|
125
|
+
} catch {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (sinceMs !== null && stat.mtimeMs <= sinceMs) continue;
|
|
129
|
+
out.push({ path: full, size: stat.size, mtimeMs: stat.mtimeMs });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return out.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// First n lines when the file reads as text, else null.
|
|
136
|
+
function headLines(file, count) {
|
|
137
|
+
let fd;
|
|
138
|
+
try {
|
|
139
|
+
fd = fs.openSync(file, 'r');
|
|
140
|
+
const buffer = Buffer.alloc(HEAD_READ_BYTES);
|
|
141
|
+
const bytes = fs.readSync(fd, buffer, 0, HEAD_READ_BYTES, 0);
|
|
142
|
+
const chunk = buffer.subarray(0, bytes);
|
|
143
|
+
if (chunk.includes(0)) return null;
|
|
144
|
+
return chunk.toString('utf8').split(/\r?\n/).slice(0, count).join('\n').replace(/\s+$/, '');
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
} finally {
|
|
148
|
+
if (fd !== undefined) try { fs.closeSync(fd); } catch {}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const COUNT_WORDS = [
|
|
153
|
+
'tests?', 'passed', 'checks?', 'files?', 'items?', 'rows?', 'leads?', 'replies', 'emails?', 'messages?',
|
|
154
|
+
'users?', 'customers?', 'signups?', 'clips?', 'shots?', 'scenes?', 'bugs?', 'errors?', 'issues?', 'tasks?',
|
|
155
|
+
'wins?', 'deals?', 'meetings?', 'calls?', 'views?', 'sales', 'orders?', 'bets?', 'pages?', 'questions?', 'answers?',
|
|
156
|
+
].join('|');
|
|
157
|
+
|
|
158
|
+
// One result number when the receipt states one plainly: a single distinct
|
|
159
|
+
// percent, else a single dollar amount, else a single count of things. Two
|
|
160
|
+
// different values of the same kind means none is the obvious one.
|
|
161
|
+
function obviousNumber(text) {
|
|
162
|
+
const body = String(text || '');
|
|
163
|
+
const kinds = [
|
|
164
|
+
{
|
|
165
|
+
kind: 'percent',
|
|
166
|
+
re: /(?<![\w.$])(\d+(?:\.\d+)?)\s?(%|percent\b)/gi,
|
|
167
|
+
value: (match) => Number(match[1]),
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
kind: 'dollars',
|
|
171
|
+
re: /\$\s?(\d[\d,]*(?:\.\d+)?)(\s?[kKmM]\b)?/g,
|
|
172
|
+
value: (match) => {
|
|
173
|
+
const base = Number(match[1].replace(/,/g, ''));
|
|
174
|
+
const unit = String(match[2] || '').trim().toLowerCase();
|
|
175
|
+
return unit === 'k' ? base * 1000 : unit === 'm' ? base * 1000000 : base;
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
kind: 'count',
|
|
180
|
+
re: new RegExp(`(?<![\\w.:/#-])(\\d[\\d,]*)\\s+(?:of\\s+\\d[\\d,]*\\s+)?(?:${COUNT_WORDS})\\b`, 'gi'),
|
|
181
|
+
value: (match) => Number(match[1].replace(/,/g, '')),
|
|
182
|
+
},
|
|
183
|
+
];
|
|
184
|
+
for (const { kind, re, value } of kinds) {
|
|
185
|
+
const found = new Map();
|
|
186
|
+
for (const match of body.matchAll(re)) {
|
|
187
|
+
const number = value(match);
|
|
188
|
+
if (!Number.isFinite(number)) continue;
|
|
189
|
+
if (!found.has(number)) found.set(number, match[0].trim());
|
|
190
|
+
}
|
|
191
|
+
if (found.size === 1) {
|
|
192
|
+
const [[number, textValue]] = [...found.entries()];
|
|
193
|
+
return { value: number, text: textValue, kind };
|
|
194
|
+
}
|
|
195
|
+
if (found.size > 1) return null;
|
|
196
|
+
}
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function memberResult(root, member, { since = '', now, deps = {} } = {}) {
|
|
201
|
+
const run = newestMemberRun(root, member, { now });
|
|
202
|
+
const worktree = run ? runWorktreeRoot(root, run.id, deps) : null;
|
|
203
|
+
const roots = [...new Set([worktree, path.resolve(root)].filter(Boolean))];
|
|
204
|
+
const memberHere = roots.some((dir) => fs.existsSync(path.join(dir, 'atris', 'team', member)));
|
|
205
|
+
if (!run && !memberHere) return { ok: false, error: `no member named ${member} here, and no run of one on record` };
|
|
206
|
+
|
|
207
|
+
const sinceIso = since ? new Date(Date.parse(since)).toISOString() : (run ? run.started : null);
|
|
208
|
+
const sinceMs = sinceIso ? Date.parse(sinceIso) : null;
|
|
209
|
+
|
|
210
|
+
const logs = roots.flatMap((dir) => changedFiles(path.join(dir, 'atris', 'team', member, 'logs'), sinceMs));
|
|
211
|
+
logs.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
212
|
+
const newestLog = logs[0] || null;
|
|
213
|
+
const receipt = newestLog ? {
|
|
214
|
+
path: newestLog.path,
|
|
215
|
+
modified: new Date(newestLog.mtimeMs).toISOString(),
|
|
216
|
+
head: headLines(newestLog.path, RECEIPT_LINES),
|
|
217
|
+
} : null;
|
|
218
|
+
|
|
219
|
+
// The member's work/ in every root; a run's own worktree also counts its
|
|
220
|
+
// top-level work/, since nothing else writes into that checkout.
|
|
221
|
+
const workDirs = roots.map((dir) => path.join(dir, 'atris', 'team', member, 'work'));
|
|
222
|
+
if (worktree) workDirs.push(path.join(worktree, 'work'));
|
|
223
|
+
const files = workDirs.flatMap((dir) => changedFiles(dir, sinceMs));
|
|
224
|
+
files.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
225
|
+
const artifacts = files.slice(0, MAX_ARTIFACTS).map((file) => ({
|
|
226
|
+
path: file.path,
|
|
227
|
+
size: file.size,
|
|
228
|
+
modified: new Date(file.mtimeMs).toISOString(),
|
|
229
|
+
head: headLines(file.path, ARTIFACT_LINES),
|
|
230
|
+
}));
|
|
231
|
+
|
|
232
|
+
const number = receipt ? obviousNumber(receipt.head) : null;
|
|
233
|
+
return {
|
|
234
|
+
ok: true,
|
|
235
|
+
value: {
|
|
236
|
+
member,
|
|
237
|
+
since: sinceIso,
|
|
238
|
+
run,
|
|
239
|
+
worktree,
|
|
240
|
+
receipt,
|
|
241
|
+
artifacts,
|
|
242
|
+
artifacts_more: Math.max(0, files.length - MAX_ARTIFACTS),
|
|
243
|
+
number: number ? number.value : null,
|
|
244
|
+
number_text: number ? number.text : null,
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function printHuman(value) {
|
|
250
|
+
const { run } = value;
|
|
251
|
+
console.log('');
|
|
252
|
+
if (run) {
|
|
253
|
+
const model = run.model ? ` (${run.model})` : '';
|
|
254
|
+
const took = run.seconds === null ? '' : `, ${Math.round(run.seconds)}s`;
|
|
255
|
+
console.log(`${value.member}: last run ${run.status}, started ${run.started}${took}, on ${run.engine}${model}`);
|
|
256
|
+
if (run.id) console.log(` run: ${run.id}`);
|
|
257
|
+
} else {
|
|
258
|
+
console.log(`${value.member}: no run on record in the last ${RUN_DAYS} days`);
|
|
259
|
+
}
|
|
260
|
+
if (value.worktree) console.log(` worked in: ${value.worktree}`);
|
|
261
|
+
console.log(` since: ${value.since || 'any time'}`);
|
|
262
|
+
if (value.receipt) {
|
|
263
|
+
console.log(`\nreceipt: ${value.receipt.path}`);
|
|
264
|
+
const lines = String(value.receipt.head || '').split('\n').slice(0, 12);
|
|
265
|
+
for (const line of lines) console.log(` ${line}`);
|
|
266
|
+
} else {
|
|
267
|
+
console.log('\nreceipt: none written since then');
|
|
268
|
+
}
|
|
269
|
+
console.log(`\nartifacts: ${value.artifacts.length}${value.artifacts_more ? ` (+${value.artifacts_more} more)` : ''}`);
|
|
270
|
+
for (const file of value.artifacts) console.log(` ${file.path} (${file.size} bytes)`);
|
|
271
|
+
console.log(`\nnumber: ${value.number_text || 'none stated plainly'}\n`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function memberResultCommand(args = [], deps = {}) {
|
|
275
|
+
const root = deps.root || process.cwd();
|
|
276
|
+
let parsed;
|
|
277
|
+
try {
|
|
278
|
+
parsed = parseResultArgs(args);
|
|
279
|
+
} catch (error) {
|
|
280
|
+
console.error(`member result: ${error.message}`);
|
|
281
|
+
return 2;
|
|
282
|
+
}
|
|
283
|
+
if (parsed.help) {
|
|
284
|
+
console.log(usage());
|
|
285
|
+
return 0;
|
|
286
|
+
}
|
|
287
|
+
const result = memberResult(root, parsed.member, { since: parsed.since, now: deps.now, deps });
|
|
288
|
+
if (!result.ok) {
|
|
289
|
+
if (parsed.json) console.log(JSON.stringify({ ok: false, error: result.error }, null, 2));
|
|
290
|
+
else console.error(`member result: ${result.error}`);
|
|
291
|
+
return 1;
|
|
292
|
+
}
|
|
293
|
+
if (parsed.json) console.log(JSON.stringify(result.value, null, 2));
|
|
294
|
+
else printHuman(result.value);
|
|
295
|
+
return 0;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
module.exports = {
|
|
299
|
+
memberResult,
|
|
300
|
+
memberResultCommand,
|
|
301
|
+
obviousNumber,
|
|
302
|
+
parseResultArgs,
|
|
303
|
+
};
|
package/lib/roster-models.js
CHANGED
|
@@ -97,6 +97,16 @@ function engineRunsView(engineId, { model = '', effort = '' } = {}, options = {}
|
|
|
97
97
|
return view;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
// "<engine> · <model label>" for a table cell: the tool, a middle dot, and the
|
|
101
|
+
// model label, or "own default" when the tool picks its model itself.
|
|
102
|
+
// Effort is left out; tables give it its own line.
|
|
103
|
+
function engineModelText(engine, model = '') {
|
|
104
|
+
const id = String(engine || '').trim();
|
|
105
|
+
if (!id) return '';
|
|
106
|
+
const label = model ? modelLabel(model) : '';
|
|
107
|
+
return `${id} · ${label || 'own default'}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
100
110
|
function runsDetail(view) {
|
|
101
111
|
const items = [{ text: view.model ? modelLabel(view.model) : 'its own default', source: view.model ? view.model_source : 'roster' }];
|
|
102
112
|
if (view.effort) items.push({ text: view.effort, source: view.effort_source });
|
|
@@ -114,7 +124,7 @@ function runsDetail(view) {
|
|
|
114
124
|
// keeps a cache of the models it can run; claude takes the names atris knows;
|
|
115
125
|
// every other engine lists its seed models.
|
|
116
126
|
|
|
117
|
-
const KNOWN_CLAUDE_MODELS = Object.freeze(['opus 5.5', 'opus 5', 'sonnet 5', 'haiku
|
|
127
|
+
const KNOWN_CLAUDE_MODELS = Object.freeze(['opus 5.5', 'opus 5', 'sonnet 5', 'haiku 5.5', 'fable 5.1']);
|
|
118
128
|
|
|
119
129
|
// ~/.codex/models_cache.json unless a path is given. Test runs never read the
|
|
120
130
|
// real file unless they point at one on purpose.
|
|
@@ -172,6 +182,7 @@ function availableModels(engines, options = {}) {
|
|
|
172
182
|
}
|
|
173
183
|
|
|
174
184
|
module.exports = {
|
|
185
|
+
engineModelText,
|
|
175
186
|
modelLabel,
|
|
176
187
|
readCodexSettings,
|
|
177
188
|
engineRunsView,
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Outdated models on the roster. A roster worker whose model has a newer
|
|
4
|
+
// version among the models this machine or the engine registry already
|
|
5
|
+
// knows gets one plain line and the command that moves it forward. A pinned
|
|
6
|
+
// model whose newest proof (a "verified <date>" on its line, or a landed run
|
|
7
|
+
// on that model) is over 30 days old gets one line too. Nothing here goes
|
|
8
|
+
// online: the model lists come from availableModels (codex's own models
|
|
9
|
+
// cache, the claude names atris knows, and each engine's registry models).
|
|
10
|
+
|
|
11
|
+
const { modelLabel, availableModels } = require('./roster-models');
|
|
12
|
+
const { parseRosterUntil, rosterMaxText, rosterToolLabel } = require('./engine-registry');
|
|
13
|
+
|
|
14
|
+
const PIN_MAX_AGE_DAYS = 30;
|
|
15
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
16
|
+
const CLAUDE_FAMILY = Object.freeze(['claude', 'fable', 'haiku']);
|
|
17
|
+
|
|
18
|
+
// Family ordering across names is ambiguous: going from a "luna" tail to a
|
|
19
|
+
// "sol" tail one version up is a tier change as much as a version change,
|
|
20
|
+
// and nothing local says which tails rank above which. So versions are only
|
|
21
|
+
// compared within an identical name prefix, the text before the first
|
|
22
|
+
// version number: "x-6-astra" and "x-6.1-sol" share the prefix "x", and two
|
|
23
|
+
// names with different prefixes (sonnet and opus) never compare. Among
|
|
24
|
+
// newer versions, one that keeps the same tail (sol, fast, flash-high) wins;
|
|
25
|
+
// otherwise the highest version, and on a tie the one the list names first.
|
|
26
|
+
// Examples stay abstract here so this file never pins a real model name.
|
|
27
|
+
// A context tag at the end ("[1m]", "(beta)") is not part of the name, and
|
|
28
|
+
// a leading "claude-" before a family word is the vendor, not the family,
|
|
29
|
+
// so "claude-x-4-6[1m]" and "x 5" land in the same family.
|
|
30
|
+
function familyLabel(model) {
|
|
31
|
+
let text = String(model || '').trim();
|
|
32
|
+
while (/\s*(?:\[[^\]]*\]|\([^)]*\))$/.test(text)) text = text.replace(/\s*(?:\[[^\]]*\]|\([^)]*\))$/, '');
|
|
33
|
+
return modelLabel(text).toLowerCase().trim().replace(/^claude[\s_-]+(?=[a-z])/, '');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function modelFamily(model) {
|
|
37
|
+
const label = familyLabel(model);
|
|
38
|
+
const match = /^([a-z][a-z/_\s-]*?)[\s_-]*(\d+(?:[.-]\d{1,2}(?!\d))*)(.*)$/.exec(label);
|
|
39
|
+
if (!match) return null;
|
|
40
|
+
const prefix = match[1].replace(/[\s_-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
41
|
+
if (!prefix) return null;
|
|
42
|
+
return {
|
|
43
|
+
prefix,
|
|
44
|
+
version: match[2].split(/[.-]/).map(Number),
|
|
45
|
+
variant: match[3].replace(/[\s_-]+/g, '-').replace(/^-+|-+$/g, ''),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function compareVersions(a, b) {
|
|
50
|
+
const size = Math.max(a.length, b.length);
|
|
51
|
+
for (let i = 0; i < size; i += 1) {
|
|
52
|
+
const diff = (a[i] || 0) - (b[i] || 0);
|
|
53
|
+
if (diff) return diff > 0 ? 1 : -1;
|
|
54
|
+
}
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function sameModel(a, b) {
|
|
59
|
+
const left = modelLabel(a).toLowerCase().trim();
|
|
60
|
+
return Boolean(left) && left === modelLabel(b).toLowerCase().trim();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The newest model in the list that beats this one, or null.
|
|
64
|
+
function newerModel(model, catalog = []) {
|
|
65
|
+
const current = modelFamily(model);
|
|
66
|
+
if (!current) return null;
|
|
67
|
+
let best = null;
|
|
68
|
+
for (const candidate of catalog || []) {
|
|
69
|
+
const family = modelFamily(candidate);
|
|
70
|
+
if (!family || family.prefix !== current.prefix) continue;
|
|
71
|
+
if (compareVersions(family.version, current.version) <= 0) continue;
|
|
72
|
+
const sameTail = family.variant === current.variant;
|
|
73
|
+
const better = !best
|
|
74
|
+
|| (sameTail && !best.sameTail)
|
|
75
|
+
|| (sameTail === best.sameTail && compareVersions(family.version, best.family.version) > 0);
|
|
76
|
+
if (better) best = { candidate, family, sameTail };
|
|
77
|
+
}
|
|
78
|
+
return best ? best.candidate : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Every model each engine is known to offer, from local files only. Claude,
|
|
82
|
+
// fable, and haiku run through the same CLI, so they share one list.
|
|
83
|
+
function modelCatalog(engines, options = {}) {
|
|
84
|
+
const catalog = {};
|
|
85
|
+
const add = (id, models) => {
|
|
86
|
+
const list = catalog[id] || (catalog[id] = []);
|
|
87
|
+
for (const model of models || []) if (model && !list.includes(model)) list.push(model);
|
|
88
|
+
};
|
|
89
|
+
const rows = availableModels(engines || [], options);
|
|
90
|
+
for (const row of rows) add(row.engine, row.models);
|
|
91
|
+
for (const engine of engines || []) add(engine.id, engine.models);
|
|
92
|
+
const claude = (rows.find((row) => row.engine === 'claude') || {}).models || [];
|
|
93
|
+
for (const id of CLAUDE_FAMILY) if (catalog[id]) add(id, claude);
|
|
94
|
+
return catalog;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function localDayText(date) {
|
|
98
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
99
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function wholeDaysSince(dayText, now) {
|
|
103
|
+
const day = parseRosterUntil(dayText);
|
|
104
|
+
if (!day) return null;
|
|
105
|
+
const date = new Date(now);
|
|
106
|
+
const today = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
|
107
|
+
return Math.round((today.getTime() - day.getTime()) / DAY_MS);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The newest proof that this engine and model answer: the line's own
|
|
111
|
+
// "verified" date or the newest landed run on the same engine and model.
|
|
112
|
+
function pinVerified(worker, runs) {
|
|
113
|
+
let best = worker.verified ? { at: worker.verified, source: 'roster line' } : null;
|
|
114
|
+
for (const run of runs || []) {
|
|
115
|
+
if (run.outcome !== 'landed' || run.engine !== worker.engine || !sameModel(run.model, worker.model)) continue;
|
|
116
|
+
const at = Date.parse(run.at);
|
|
117
|
+
if (!Number.isFinite(at)) continue;
|
|
118
|
+
const day = localDayText(new Date(at));
|
|
119
|
+
if (!best || day > best.at) best = { at: day, source: 'landed run' };
|
|
120
|
+
}
|
|
121
|
+
return best;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function quoteArg(text) {
|
|
125
|
+
const value = String(text || '');
|
|
126
|
+
return /^[A-Za-z0-9._:/@-]+$/.test(value) ? value : `"${value.replace(/"/g, '\\"')}"`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const SCOPE_FLAGS = Object.freeze({ 'all projects': '--everywhere', 'this session': '--session' });
|
|
130
|
+
|
|
131
|
+
// The assign command that moves a job's first worker to the newer model and
|
|
132
|
+
// keeps everything else on its line: effort, time cap, prep, end date.
|
|
133
|
+
function assignCommand(row, worker, model) {
|
|
134
|
+
const parts = ['atris engine assign', quoteArg(row.job), worker.engine, '--model', quoteArg(modelLabel(model))];
|
|
135
|
+
if (worker.effort) parts.push('--effort', worker.effort);
|
|
136
|
+
if (worker.max_seconds) parts.push('--max', quoteArg(rosterMaxText(worker.max_seconds).replace(/^max /, '')));
|
|
137
|
+
if (worker.prep) parts.push('--prep', quoteArg(worker.prep));
|
|
138
|
+
if (worker.until && Number(worker.ends_in_days) > 0) parts.push('--days', String(worker.ends_in_days));
|
|
139
|
+
if (SCOPE_FLAGS[row.from]) parts.push(SCOPE_FLAGS[row.from]);
|
|
140
|
+
return parts.join(' ');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function lineHint(file, line, change) {
|
|
144
|
+
const where = line ? `line ${line} of ${file || 'the roster'}` : (file || 'the roster');
|
|
145
|
+
return `${change} on ${where}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Mark every worker and job in a roster report with stale_model,
|
|
149
|
+
// newer_model, pin_age_days, and pin_verified_at, and list what needs a
|
|
150
|
+
// look in report.stale. Returns the report.
|
|
151
|
+
function attachStaleModels(report, { catalog = {}, runs = [], now = new Date() } = {}) {
|
|
152
|
+
const stale = [];
|
|
153
|
+
const today = localDayText(new Date(now));
|
|
154
|
+
for (const row of report.jobs || []) {
|
|
155
|
+
(row.workers || []).forEach((worker, index) => {
|
|
156
|
+
worker.stale_model = false;
|
|
157
|
+
worker.newer_model = null;
|
|
158
|
+
worker.pin_age_days = null;
|
|
159
|
+
worker.pin_verified_at = null;
|
|
160
|
+
const view = worker.runs;
|
|
161
|
+
if (!view || !view.model || !worker.engine || worker.why === 'expired') return;
|
|
162
|
+
const who = index === 0 ? row.job : `${row.job} backup`;
|
|
163
|
+
const tool = rosterToolLabel(worker.engine);
|
|
164
|
+
const newer = newerModel(view.model, catalog[worker.engine]);
|
|
165
|
+
if (newer) {
|
|
166
|
+
worker.stale_model = true;
|
|
167
|
+
worker.newer_model = newer;
|
|
168
|
+
const from = view.model_source === 'roster' ? '' : ` from ${view.model_source}`;
|
|
169
|
+
const renew = index === 0
|
|
170
|
+
? assignCommand(row, worker, newer)
|
|
171
|
+
: lineHint(row.file, worker.line, `change the model to ${modelLabel(newer)}`);
|
|
172
|
+
stale.push({
|
|
173
|
+
kind: 'newer model',
|
|
174
|
+
job: row.job,
|
|
175
|
+
engine: worker.engine,
|
|
176
|
+
model: view.model,
|
|
177
|
+
newer_model: newer,
|
|
178
|
+
from: row.from,
|
|
179
|
+
file: row.file,
|
|
180
|
+
line: worker.line,
|
|
181
|
+
renew,
|
|
182
|
+
text: `${who}: ${tool} runs ${modelLabel(view.model)}${from}, newer ${modelLabel(newer)} is available. renew: ${renew}`,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
if (view.model_source !== 'roster') return;
|
|
186
|
+
const proof = pinVerified({ ...worker, model: view.model }, runs);
|
|
187
|
+
if (!proof) return;
|
|
188
|
+
worker.pin_verified_at = proof.at;
|
|
189
|
+
worker.pin_age_days = wholeDaysSince(proof.at, now);
|
|
190
|
+
if (worker.pin_age_days === null || worker.pin_age_days <= PIN_MAX_AGE_DAYS || newer) return;
|
|
191
|
+
stale.push({
|
|
192
|
+
kind: 'old pin',
|
|
193
|
+
job: row.job,
|
|
194
|
+
engine: worker.engine,
|
|
195
|
+
model: view.model,
|
|
196
|
+
pin_verified_at: proof.at,
|
|
197
|
+
pin_age_days: worker.pin_age_days,
|
|
198
|
+
from: row.from,
|
|
199
|
+
file: row.file,
|
|
200
|
+
line: worker.line,
|
|
201
|
+
text: `${who}: ${tool} on ${modelLabel(view.model)} was last verified ${worker.pin_age_days} days ago (${proof.source}). smoke one prompt, then ${lineHint(row.file, worker.line, `add "verified ${today}"`)}`,
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
// The job's own fields mirror the worker that leads it, else its first.
|
|
205
|
+
const lead = (row.workers || []).find((worker) => worker.status === 'leads') || (row.workers || [])[0] || {};
|
|
206
|
+
row.stale_model = Boolean(lead.stale_model);
|
|
207
|
+
row.newer_model = lead.newer_model || null;
|
|
208
|
+
row.pin_age_days = lead.pin_age_days === undefined ? null : lead.pin_age_days;
|
|
209
|
+
}
|
|
210
|
+
// A member line that pins its own model is checked too; a member that
|
|
211
|
+
// picks automatically runs its job's worker, already checked above.
|
|
212
|
+
for (const member of report.team || []) {
|
|
213
|
+
if (member.source !== 'file' || !member.model || !member.engine) continue;
|
|
214
|
+
const newer = newerModel(member.model, catalog[member.engine]);
|
|
215
|
+
member.stale_model = Boolean(newer);
|
|
216
|
+
member.newer_model = newer;
|
|
217
|
+
if (!newer) continue;
|
|
218
|
+
const renew = `change the model to ${modelLabel(newer)} on the ${member.member} line of ${member.file || 'the roster'}`;
|
|
219
|
+
stale.push({
|
|
220
|
+
kind: 'newer model',
|
|
221
|
+
member: member.member,
|
|
222
|
+
engine: member.engine,
|
|
223
|
+
model: member.model,
|
|
224
|
+
newer_model: newer,
|
|
225
|
+
file: member.file,
|
|
226
|
+
renew,
|
|
227
|
+
text: `${member.member}: ${rosterToolLabel(member.engine)} runs ${modelLabel(member.model)}, newer ${modelLabel(newer)} is available. renew: ${renew}`,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
report.stale = stale;
|
|
231
|
+
return report;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
module.exports = {
|
|
235
|
+
PIN_MAX_AGE_DAYS,
|
|
236
|
+
attachStaleModels,
|
|
237
|
+
compareVersions,
|
|
238
|
+
modelCatalog,
|
|
239
|
+
modelFamily,
|
|
240
|
+
newerModel,
|
|
241
|
+
};
|
package/lib/runner-command.js
CHANGED
|
@@ -71,7 +71,7 @@ const RUNNER_PROFILE_DEFS = Object.freeze({
|
|
|
71
71
|
}),
|
|
72
72
|
haiku: Object.freeze({
|
|
73
73
|
bin: 'claude',
|
|
74
|
-
model: 'claude-haiku-
|
|
74
|
+
model: 'claude-haiku-5-5',
|
|
75
75
|
commandTemplate: '',
|
|
76
76
|
effortFlag: '--effort',
|
|
77
77
|
efforts: CLAUDE_EFFORTS,
|
|
@@ -230,12 +230,12 @@ function ownModelsOnlyEngineForBin(bin) {
|
|
|
230
230
|
// vendor's models drops a claude model name (a mission's stored default, a
|
|
231
231
|
// claude env pin) and rides its own default instead.
|
|
232
232
|
function runnerModelFor(engineId, model) {
|
|
233
|
-
|
|
233
|
+
// Required here, not at the top: engine-registry requires this file.
|
|
234
|
+
const { isClaudeFamilyModel, currentClaudeModel } = require('./engine-registry');
|
|
235
|
+
const value = currentClaudeModel(String(model || '').trim());
|
|
234
236
|
const name = String(engineId || '').trim().toLowerCase();
|
|
235
237
|
const def = RUNNER_PROFILE_DEFS[RUNNER_PROFILE_ALIASES[name] || name];
|
|
236
238
|
if (!value || !def || !def.ownModelsOnly) return value;
|
|
237
|
-
// Required here, not at the top: engine-registry requires this file.
|
|
238
|
-
const { isClaudeFamilyModel } = require('./engine-registry');
|
|
239
239
|
return isClaudeFamilyModel(value) ? '' : value;
|
|
240
240
|
}
|
|
241
241
|
|
package/lib/task-list-keeper.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
const fs = require('fs');
|
|
7
7
|
const path = require('path');
|
|
8
8
|
const taskDb = require('./task-db');
|
|
9
|
+
const { protectedLaneOf } = require('./auto-accept-certified');
|
|
9
10
|
|
|
10
11
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
11
12
|
const OPEN_STATUSES = new Set(['open', 'claimed', 'review']);
|
|
@@ -57,12 +58,28 @@ function actorName(actor) {
|
|
|
57
58
|
return actor || process.env.ATRIS_AGENT_ID || process.env.USER || 'task-list';
|
|
58
59
|
}
|
|
59
60
|
|
|
60
|
-
function
|
|
61
|
+
function parsedMetadata(row) {
|
|
62
|
+
let metadata = row && row.metadata;
|
|
63
|
+
if (typeof metadata === 'string') {
|
|
64
|
+
try { metadata = JSON.parse(metadata); } catch { metadata = null; }
|
|
65
|
+
}
|
|
66
|
+
return metadata && typeof metadata === 'object' ? metadata : {};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Money, deploys, security, customer, and outward work is the human's call.
|
|
70
|
+
// Sitting still is not a decision, so idle time never puts it away.
|
|
71
|
+
function isProtectedRow(row) {
|
|
72
|
+
return Boolean(protectedLaneOf({ ...row, metadata: parsedMetadata(row) }));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function decisionFor(row, now, { includeDone = true } = {}) {
|
|
61
76
|
if (!row || row.status === 'archived') return null;
|
|
62
77
|
if (row.status === 'done') {
|
|
78
|
+
if (!includeDone) return null;
|
|
63
79
|
return { reason: REASONS.done, fromDone: true, fromFailed: false };
|
|
64
80
|
}
|
|
65
81
|
if (!Object.prototype.hasOwnProperty.call(IDLE_LIMITS_MS, row.status)) return null;
|
|
82
|
+
if (OPEN_STATUSES.has(row.status) && isProtectedRow(row)) return null;
|
|
66
83
|
const at = Number(row.updated_at || row.created_at || 0);
|
|
67
84
|
if (!((now - at) > IDLE_LIMITS_MS[row.status])) return null;
|
|
68
85
|
return { reason: REASONS[row.status], fromDone: false, fromFailed: row.status === 'failed' };
|
|
@@ -112,7 +129,10 @@ function noteKeep(workspaceRoot, actor, count) {
|
|
|
112
129
|
}
|
|
113
130
|
}
|
|
114
131
|
|
|
115
|
-
|
|
132
|
+
// includeDone:false leaves finished rows on the list: the hourly tick must
|
|
133
|
+
// not hide work it just landed from the digest and mission checks that read
|
|
134
|
+
// done rows later in the same tick. Boot and `task keep` still put them away.
|
|
135
|
+
function keepTaskList(db, { workspaceRoot, actor, now = Date.now(), missions, includeDone = true } = {}) {
|
|
116
136
|
const who = actorName(actor);
|
|
117
137
|
const seen = new Set();
|
|
118
138
|
const toArchive = [];
|
|
@@ -120,7 +140,7 @@ function keepTaskList(db, { workspaceRoot, actor, now = Date.now(), missions } =
|
|
|
120
140
|
for (const row of selectCandidates(db, workspaceRoot, now)) {
|
|
121
141
|
if (!row || seen.has(row.id)) continue;
|
|
122
142
|
seen.add(row.id);
|
|
123
|
-
const decision = decisionFor(row, now);
|
|
143
|
+
const decision = decisionFor(row, now, { includeDone });
|
|
124
144
|
if (decision) {
|
|
125
145
|
toArchive.push({ id: row.id, previous_status: row.status, ...decision });
|
|
126
146
|
continue;
|
|
@@ -171,7 +191,7 @@ function keepTaskList(db, { workspaceRoot, actor, now = Date.now(), missions } =
|
|
|
171
191
|
return { put_away: putAway, reaped };
|
|
172
192
|
}
|
|
173
193
|
|
|
174
|
-
function keepWorkspaceTaskList(cwd = process.cwd(), { actor } = {}) {
|
|
194
|
+
function keepWorkspaceTaskList(cwd = process.cwd(), { actor, includeDone = true } = {}) {
|
|
175
195
|
const dbPath = taskDb.getDbPath();
|
|
176
196
|
if (!dbPath || !fs.existsSync(dbPath)) {
|
|
177
197
|
return { put_away: [], reaped: [], skipped: 'no_db' };
|
|
@@ -181,6 +201,7 @@ function keepWorkspaceTaskList(cwd = process.cwd(), { actor } = {}) {
|
|
|
181
201
|
workspaceRoot: taskDb.workspaceRoot(cwd),
|
|
182
202
|
actor,
|
|
183
203
|
now: Date.now(),
|
|
204
|
+
includeDone,
|
|
184
205
|
});
|
|
185
206
|
}
|
|
186
207
|
|