atris 3.65.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/atris/skills/engines/SKILL.md +2 -2
- package/bin/atris.js +28 -2
- 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 +1 -1
- 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
|
@@ -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
|
@@ -5,7 +5,7 @@ const knownCommands = ['init', 'guide', 'doctor', 'doc-health', 'install', 'work
|
|
|
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
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'];
|
|
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
|
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
|
|