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
package/lib/land-green.js
CHANGED
|
@@ -23,6 +23,9 @@ const { runGit } = require('./git-spawn');
|
|
|
23
23
|
|
|
24
24
|
const DEFAULT_VERIFY = 'npm test';
|
|
25
25
|
const DEFAULT_VERIFY_TIMEOUT_MS = 20 * 60 * 1000;
|
|
26
|
+
// Stop the walk after this many consecutive branches that landed nothing, so
|
|
27
|
+
// a queue of erroring branches does not burn a scratch checkout per branch.
|
|
28
|
+
const DEFAULT_MAX_MISSES = 4;
|
|
26
29
|
const FAILING_LINE = /^(?:✖|not ok)\s+(.+?)(?:\s+\(\d+(?:\.\d+)?ms\))?\s*$/;
|
|
27
30
|
|
|
28
31
|
function gitOut(args, cwd) {
|
|
@@ -291,6 +294,7 @@ function landStaleGreenBranches(root, {
|
|
|
291
294
|
ttlDays,
|
|
292
295
|
base: baseOverride = '',
|
|
293
296
|
limit = 1,
|
|
297
|
+
maxMisses = DEFAULT_MAX_MISSES,
|
|
294
298
|
now = Date.now(),
|
|
295
299
|
verifyCommand = DEFAULT_VERIFY,
|
|
296
300
|
verifyTimeoutMs = DEFAULT_VERIFY_TIMEOUT_MS,
|
|
@@ -307,19 +311,29 @@ function landStaleGreenBranches(root, {
|
|
|
307
311
|
.filter((b) => b.state === 'active' && b.stale)
|
|
308
312
|
.sort((a, b) => b.activityHours - a.activityHours);
|
|
309
313
|
const verdicts = [];
|
|
310
|
-
|
|
314
|
+
// Only landed and red verdicts spend the per-tick budget: an erroring or
|
|
315
|
+
// ineligible branch must not starve the rest of the queue. A bounded run of
|
|
316
|
+
// misses still stops the walk so a fully broken queue does not burn a
|
|
317
|
+
// scratch checkout per branch per tick.
|
|
318
|
+
let spent = 0;
|
|
319
|
+
let misses = 0;
|
|
311
320
|
for (const candidate of candidates) {
|
|
312
|
-
if (
|
|
313
|
-
verdicts.push(verdictFor(candidate.name, live.base, { action: 'skipped', reason: 'per_tick_limit' }));
|
|
321
|
+
if (spent >= limit || misses >= maxMisses) {
|
|
322
|
+
verdicts.push(verdictFor(candidate.name, live.base, { action: 'skipped', reason: spent >= limit ? 'per_tick_limit' : 'attempt_cap' }));
|
|
314
323
|
continue;
|
|
315
324
|
}
|
|
316
|
-
attempts += 1;
|
|
317
325
|
let verdict;
|
|
318
326
|
try {
|
|
319
327
|
verdict = landOneBranch(root, live.base, candidate.name, { verifyCommand, verifyTimeoutMs, dryRun, push, runVerifyFn, env });
|
|
320
328
|
} catch (err) {
|
|
321
329
|
verdict = verdictFor(candidate.name, live.base, { action: 'error', reason: 'exception', detail: String((err && err.message) || err).slice(0, 300) });
|
|
322
330
|
}
|
|
331
|
+
if (verdict.action === 'landed' || verdict.action === 'red') {
|
|
332
|
+
spent += 1;
|
|
333
|
+
misses = 0;
|
|
334
|
+
} else {
|
|
335
|
+
misses += 1;
|
|
336
|
+
}
|
|
323
337
|
if (verdict.action === 'red' && openFlags) {
|
|
324
338
|
try {
|
|
325
339
|
const flagged = openRedFlag(root, verdict, { runCli });
|
|
@@ -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
|
@@ -124,7 +124,7 @@ function runsDetail(view) {
|
|
|
124
124
|
// keeps a cache of the models it can run; claude takes the names atris knows;
|
|
125
125
|
// every other engine lists its seed models.
|
|
126
126
|
|
|
127
|
-
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']);
|
|
128
128
|
|
|
129
129
|
// ~/.codex/models_cache.json unless a path is given. Test runs never read the
|
|
130
130
|
// real file unless they point at one on purpose.
|
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
|
|
package/lib/trust-tiers.js
CHANGED
|
@@ -7,6 +7,10 @@ const HISTORY_FILES = [
|
|
|
7
7
|
path.join('.atris', 'state', 'career_xp_receipts.jsonl'),
|
|
8
8
|
path.join('.atris', 'state', 'scorecards.jsonl'),
|
|
9
9
|
];
|
|
10
|
+
// Where a legacy history row's builder is recovered from: the row only names
|
|
11
|
+
// its task, and these name who built that task.
|
|
12
|
+
const EPISODES_FILE = path.join('.atris', 'state', 'task_episodes.jsonl');
|
|
13
|
+
const PROJECTION_FILE = path.join('.atris', 'state', 'tasks.projection.json');
|
|
10
14
|
|
|
11
15
|
const PASS_OUTCOMES = new Set(['accepted', 'approved', 'done', 'pass', 'passed', 'success', 'succeeded']);
|
|
12
16
|
const FAIL_OUTCOMES = new Set(['bounced', 'fail', 'failed', 'rejected', 'revised', 'rework_requested']);
|
|
@@ -15,17 +19,34 @@ function normalizedActor(value) {
|
|
|
15
19
|
return String(value || '').trim().toLowerCase();
|
|
16
20
|
}
|
|
17
21
|
|
|
18
|
-
|
|
19
|
-
|
|
22
|
+
// Trust belongs to whoever BUILT the work, never to whoever approved it.
|
|
23
|
+
// History rows record `actor` as the approving reviewer (one human on 1,029
|
|
24
|
+
// of 1,057 receipts in one busy project), so keying on it, or on fields the
|
|
25
|
+
// rows never carried, left every builder at zero history and "still earning
|
|
26
|
+
// trust" forever. `actor` is deliberately not read here.
|
|
27
|
+
function stampedBuilder(row) {
|
|
28
|
+
return normalizedActor(
|
|
29
|
+
row?.builder
|
|
30
|
+
|| row?.built_by
|
|
31
|
+
|| row?.claimed_by
|
|
32
|
+
|| row?.metadata?.built_by
|
|
33
|
+
|| row?.metadata?.executed_by,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function rowTaskId(row) {
|
|
38
|
+
return String(row?.source_task_id || row?.task_id || '').trim();
|
|
20
39
|
}
|
|
21
40
|
|
|
22
41
|
function rowPassed(row) {
|
|
23
42
|
for (const value of [row?.passed, row?.verify_passed, row?.metadata?.verify_passed]) {
|
|
24
43
|
if (typeof value === 'boolean') return value;
|
|
25
44
|
}
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
45
|
+
for (const value of [row?.outcome, row?.status, row?.rl_label]) {
|
|
46
|
+
const outcome = String(value || '').trim().toLowerCase();
|
|
47
|
+
if (PASS_OUTCOMES.has(outcome)) return true;
|
|
48
|
+
if (FAIL_OUTCOMES.has(outcome)) return false;
|
|
49
|
+
}
|
|
29
50
|
return null;
|
|
30
51
|
}
|
|
31
52
|
|
|
@@ -37,6 +58,53 @@ function rowTime(row) {
|
|
|
37
58
|
return null;
|
|
38
59
|
}
|
|
39
60
|
|
|
61
|
+
function fileStamp(file) {
|
|
62
|
+
try {
|
|
63
|
+
const stat = fs.statSync(file);
|
|
64
|
+
return `${stat.size}:${stat.mtimeMs}`;
|
|
65
|
+
} catch {
|
|
66
|
+
return 'missing';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function eachJsonLine(file, fn) {
|
|
71
|
+
let text;
|
|
72
|
+
try {
|
|
73
|
+
text = fs.readFileSync(file, 'utf8');
|
|
74
|
+
} catch {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
for (const line of text.split(/\r?\n/)) {
|
|
78
|
+
if (!line.trim()) continue;
|
|
79
|
+
try {
|
|
80
|
+
const row = JSON.parse(line);
|
|
81
|
+
if (row && typeof row === 'object' && !Array.isArray(row)) fn(row);
|
|
82
|
+
} catch {
|
|
83
|
+
// A torn line in the episode log only costs that one lookup.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// task id -> builder, from the episode log (every review snapshot carries the
|
|
89
|
+
// task's built_by / claimed_by) and the live projection.
|
|
90
|
+
function readTaskBuilders(root) {
|
|
91
|
+
const builders = new Map();
|
|
92
|
+
const remember = (taskId, task) => {
|
|
93
|
+
const id = String(taskId || '').trim();
|
|
94
|
+
const builder = normalizedActor(task?.metadata?.built_by || task?.claimed_by);
|
|
95
|
+
if (id && builder) builders.set(id, builder);
|
|
96
|
+
};
|
|
97
|
+
eachJsonLine(path.join(root, EPISODES_FILE), (episode) => remember(episode.task_id, episode.state));
|
|
98
|
+
try {
|
|
99
|
+
const projection = JSON.parse(fs.readFileSync(path.join(root, PROJECTION_FILE), 'utf8'));
|
|
100
|
+
const tasks = Array.isArray(projection) ? projection : projection?.tasks;
|
|
101
|
+
for (const task of Array.isArray(tasks) ? tasks : []) remember(task?.id, task);
|
|
102
|
+
} catch {
|
|
103
|
+
// No projection: episode builders still apply.
|
|
104
|
+
}
|
|
105
|
+
return builders;
|
|
106
|
+
}
|
|
107
|
+
|
|
40
108
|
function readHistory(root) {
|
|
41
109
|
const rows = [];
|
|
42
110
|
let sequence = 0;
|
|
@@ -62,16 +130,58 @@ function readHistory(root) {
|
|
|
62
130
|
return rows;
|
|
63
131
|
}
|
|
64
132
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
133
|
+
// One outcome per review episode: a receipt and a scorecard written for the
|
|
134
|
+
// same episode are the same verdict, not two.
|
|
135
|
+
function builderOutcomes(root) {
|
|
68
136
|
const history = readHistory(root);
|
|
69
|
-
if (!history) return
|
|
137
|
+
if (!history) return null;
|
|
138
|
+
let builders = null;
|
|
139
|
+
const byBuilder = new Map();
|
|
140
|
+
const seenEpisodes = new Set();
|
|
141
|
+
for (const entry of history) {
|
|
142
|
+
const passed = rowPassed(entry.row);
|
|
143
|
+
if (passed === null) continue;
|
|
144
|
+
const episode = String(entry.row.source_episode_id || '').trim();
|
|
145
|
+
if (episode) {
|
|
146
|
+
if (seenEpisodes.has(episode)) continue;
|
|
147
|
+
seenEpisodes.add(episode);
|
|
148
|
+
}
|
|
149
|
+
let builder = stampedBuilder(entry.row);
|
|
150
|
+
if (!builder && rowTaskId(entry.row)) {
|
|
151
|
+
if (!builders) builders = readTaskBuilders(root);
|
|
152
|
+
builder = builders.get(rowTaskId(entry.row)) || '';
|
|
153
|
+
}
|
|
154
|
+
if (!builder) continue;
|
|
155
|
+
if (!byBuilder.has(builder)) byBuilder.set(builder, []);
|
|
156
|
+
byBuilder.get(builder).push({ ...entry, passed });
|
|
157
|
+
}
|
|
158
|
+
return byBuilder;
|
|
159
|
+
}
|
|
70
160
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
161
|
+
// Called once per waiting task on every status and tick, against a 12 MB
|
|
162
|
+
// episode log; memoize per root until one of the files changes.
|
|
163
|
+
const outcomeCache = new Map();
|
|
164
|
+
|
|
165
|
+
function cachedBuilderOutcomes(root) {
|
|
166
|
+
const resolved = path.resolve(root);
|
|
167
|
+
const stamp = [...HISTORY_FILES, EPISODES_FILE, PROJECTION_FILE]
|
|
168
|
+
.map((relative) => fileStamp(path.join(resolved, relative)))
|
|
169
|
+
.join('|');
|
|
170
|
+
const hit = outcomeCache.get(resolved);
|
|
171
|
+
if (hit && hit.stamp === stamp) return hit.value;
|
|
172
|
+
const value = builderOutcomes(resolved);
|
|
173
|
+
outcomeCache.set(resolved, { stamp, value });
|
|
174
|
+
return value;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function computeTrustTier(builder, root = process.cwd()) {
|
|
178
|
+
const target = normalizedActor(builder);
|
|
179
|
+
if (!target) return 'probation';
|
|
180
|
+
const byBuilder = cachedBuilderOutcomes(root);
|
|
181
|
+
if (!byBuilder) return 'probation';
|
|
182
|
+
|
|
183
|
+
const outcomes = (byBuilder.get(target) || [])
|
|
184
|
+
.slice()
|
|
75
185
|
.sort((a, b) => {
|
|
76
186
|
if (a.time !== null && b.time !== null && a.time !== b.time) return a.time - b.time;
|
|
77
187
|
if (a.time !== null && b.time === null) return 1;
|
|
@@ -87,4 +197,11 @@ function computeTrustTier(actor, root = process.cwd()) {
|
|
|
87
197
|
return 'probation';
|
|
88
198
|
}
|
|
89
199
|
|
|
90
|
-
|
|
200
|
+
// The builder of a task: the stamped builder first, then whoever claimed it.
|
|
201
|
+
// Never the approver.
|
|
202
|
+
function taskTrustSubject(task) {
|
|
203
|
+
const metadata = task?.metadata || {};
|
|
204
|
+
return normalizedActor(metadata.built_by || task?.claimed_by || metadata.executed_by) || null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
module.exports = { computeTrustTier, taskTrustSubject };
|