atris 3.68.9 → 3.69.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.
@@ -0,0 +1,235 @@
1
+ 'use strict';
2
+
3
+ // `atris member tidy`: put away member copies of the repo (git worktrees under
4
+ // <arena>/.agent-worktrees/<repo>) whose work already landed. There is no
5
+ // deletion logic here on purpose. git does the removing with
6
+ // `git worktree remove` (never --force), which refuses modified or untracked
7
+ // files, then `git branch -d` (never -D). Everything this file decides is
8
+ // about which copies to leave alone:
9
+ // - work not landed: branch tip is not an ancestor of origin's default
10
+ // branch tip, asked of origin itself on every run. Listed with its age, never touched.
11
+ // - any git error while deciding: skipped.
12
+ // - a live process inside the copy (lsof). If the scan itself fails, every
13
+ // copy is skipped.
14
+ // - a copy whose real path is outside the real worktrees folder (a symlink).
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const { spawnSync } = require('child_process');
19
+
20
+ function git(args, cwd) {
21
+ const result = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 60000 });
22
+ return {
23
+ ok: result.status === 0,
24
+ status: result.status,
25
+ stdout: String(result.stdout || '').trim(),
26
+ stderr: String(result.stderr || result.error?.message || '').trim(),
27
+ };
28
+ }
29
+
30
+ function isUnder(child, parent) {
31
+ const rel = path.relative(parent, child);
32
+ return Boolean(rel) && !rel.startsWith('..') && !path.isAbsolute(rel);
33
+ }
34
+
35
+ function firstLine(text) {
36
+ return String(text || '').split('\n').map((line) => line.trim()).find(Boolean) || 'no reason given';
37
+ }
38
+
39
+ function formatAge(ms) {
40
+ const minutes = Math.max(0, Math.floor(ms / 60000));
41
+ if (minutes < 60) return `${minutes}m old`;
42
+ const hours = Math.floor(minutes / 60);
43
+ if (hours < 48) return `${hours}h old`;
44
+ return `${Math.floor(hours / 24)}d old`;
45
+ }
46
+
47
+ function mainRepoRoot(cwd) {
48
+ const common = git(['rev-parse', '--path-format=absolute', '--git-common-dir'], cwd);
49
+ if (!common.ok) return null;
50
+ return path.basename(common.stdout) === '.git' ? path.dirname(common.stdout) : common.stdout;
51
+ }
52
+
53
+ function defaultWorktreesDir(repoRoot) {
54
+ return path.join(path.dirname(repoRoot), '.agent-worktrees', path.basename(repoRoot));
55
+ }
56
+
57
+ function parseWorktreeList(text) {
58
+ const entries = [];
59
+ let current = null;
60
+ for (const line of String(text || '').split('\n')) {
61
+ if (line.startsWith('worktree ')) {
62
+ current = { path: line.slice('worktree '.length), branch: null };
63
+ entries.push(current);
64
+ } else if (current && line.startsWith('branch refs/heads/')) {
65
+ current.branch = line.slice('branch refs/heads/'.length);
66
+ }
67
+ }
68
+ return entries;
69
+ }
70
+
71
+ // Ask origin itself which branch it ships from and where that branch is right
72
+ // now. The local origin/HEAD note goes stale when the default branch changes,
73
+ // so it is never trusted. Returns the exact commit to compare against.
74
+ function originDefaultTip(repoRoot) {
75
+ const remote = git(['ls-remote', '--symref', 'origin', 'HEAD'], repoRoot);
76
+ if (!remote.ok) return { ok: false, reason: `could not ask origin for its main branch: ${firstLine(remote.stderr)}` };
77
+ const branch = remote.stdout.match(/^ref: refs\/heads\/(\S+)\s+HEAD$/m);
78
+ if (!branch) return { ok: false, reason: 'could not tell which branch origin ships from' };
79
+ const fetched = git(['fetch', '--quiet', 'origin', `refs/heads/${branch[1]}`], repoRoot);
80
+ if (!fetched.ok) return { ok: false, reason: `could not fetch origin: ${firstLine(fetched.stderr)}` };
81
+ // Judge against what was actually fetched, not what origin advertised a
82
+ // moment earlier: the branch can move in between.
83
+ const tip = git(['rev-parse', '--verify', 'FETCH_HEAD^{commit}'], repoRoot);
84
+ if (!tip.ok || !/^[0-9a-f]{40,64}$/.test(tip.stdout.trim())) return { ok: false, reason: 'could not read the fetched main branch' };
85
+ return { ok: true, branch: branch[1], tip: tip.stdout.trim() };
86
+ }
87
+
88
+ // Every path any process has open (cwd included). Any failure means we cannot
89
+ // tell what is busy, so the caller skips all copies.
90
+ function scanOpenPaths(lsofBin = 'lsof') {
91
+ const result = spawnSync(lsofBin, ['-n', '-P', '-w', '-F', 'n'], {
92
+ encoding: 'utf8',
93
+ timeout: 60000,
94
+ maxBuffer: 256 * 1024 * 1024,
95
+ });
96
+ if (result.error || result.status !== 0) {
97
+ return { ok: false, reason: firstLine(result.error?.message || result.stderr || `lsof exited ${result.status}`) };
98
+ }
99
+ const paths = [];
100
+ for (const line of String(result.stdout || '').split('\n')) {
101
+ if (line.startsWith('n/')) paths.push(line.slice(1));
102
+ }
103
+ return { ok: true, paths };
104
+ }
105
+
106
+ function hasLiveProcess(realCopy, openPaths) {
107
+ return openPaths.some((p) => p === realCopy || p.startsWith(realCopy + path.sep));
108
+ }
109
+
110
+ function tidyMemberCopies(options = {}) {
111
+ const now = options.now || Date.now();
112
+ const dryRun = Boolean(options.dryRun);
113
+ const repoRoot = options.repoRoot || mainRepoRoot(options.cwd || process.cwd());
114
+ const result = { dryRun, worktreesDir: null, copies: [], removed: 0, kept: 0, aborted: null };
115
+ if (!repoRoot) {
116
+ result.aborted = 'not inside a git repo';
117
+ return result;
118
+ }
119
+ const worktreesDir = path.resolve(options.worktreesDir || defaultWorktreesDir(repoRoot));
120
+ result.worktreesDir = worktreesDir;
121
+ let realDir;
122
+ try {
123
+ realDir = fs.realpathSync(worktreesDir);
124
+ } catch {
125
+ return result;
126
+ }
127
+
128
+ const listed = git(['worktree', 'list', '--porcelain'], repoRoot);
129
+ if (!listed.ok) {
130
+ result.aborted = `git could not list copies: ${firstLine(listed.stderr)}`;
131
+ return result;
132
+ }
133
+ const copies = parseWorktreeList(listed.stdout)
134
+ .filter((entry) => isUnder(entry.path, worktreesDir) || isUnder(entry.path, realDir));
135
+
136
+ const keep = (copy, reason) => {
137
+ result.copies.push({ ...copy, action: 'kept', reason });
138
+ result.kept += 1;
139
+ };
140
+
141
+ const base = originDefaultTip(repoRoot);
142
+ const scan = scanOpenPaths(options.lsofBin);
143
+ let abortReason = null;
144
+ if (!base.ok) abortReason = base.reason;
145
+ else if (!scan.ok) abortReason = `could not check for running programs: ${scan.reason}`;
146
+ if (abortReason) result.aborted = abortReason;
147
+
148
+ const selfReal = (() => {
149
+ try { return fs.realpathSync(options.cwd || process.cwd()); } catch { return null; }
150
+ })();
151
+
152
+ for (const entry of copies) {
153
+ const copy = { name: path.basename(entry.path), path: entry.path, branch: entry.branch };
154
+ let realCopy;
155
+ try {
156
+ realCopy = fs.realpathSync(entry.path);
157
+ } catch {
158
+ keep(copy, 'folder is missing');
159
+ continue;
160
+ }
161
+ if (!isUnder(realCopy, realDir)) {
162
+ keep(copy, 'points outside the copies folder, left alone');
163
+ continue;
164
+ }
165
+ if (abortReason) {
166
+ keep(copy, abortReason);
167
+ continue;
168
+ }
169
+ if (!entry.branch) {
170
+ keep(copy, 'not on a branch');
171
+ continue;
172
+ }
173
+ const ancestor = git(['merge-base', '--is-ancestor', `refs/heads/${entry.branch}`, base.tip], repoRoot);
174
+ if (ancestor.status === 1) {
175
+ let age = '';
176
+ try {
177
+ const stat = fs.statSync(realCopy);
178
+ age = `, ${formatAge(now - (stat.birthtimeMs || stat.mtimeMs))}`;
179
+ } catch {}
180
+ keep(copy, `work not landed yet${age}`);
181
+ continue;
182
+ }
183
+ if (!ancestor.ok) {
184
+ keep(copy, `git could not tell if it landed: ${firstLine(ancestor.stderr)}`);
185
+ continue;
186
+ }
187
+ if ((selfReal && (selfReal === realCopy || isUnder(selfReal, realCopy)))
188
+ || hasLiveProcess(realCopy, scan.paths)) {
189
+ keep(copy, 'landed, but something is still running inside it');
190
+ continue;
191
+ }
192
+ if (dryRun) {
193
+ result.copies.push({ ...copy, action: 'would remove', reason: 'work landed' });
194
+ result.removed += 1;
195
+ continue;
196
+ }
197
+ const removed = git(['worktree', 'remove', entry.path], repoRoot);
198
+ if (!removed.ok) {
199
+ keep(copy, `landed, but git refused: ${firstLine(removed.stderr)}`);
200
+ continue;
201
+ }
202
+ const branchGone = git(['branch', '-d', entry.branch], repoRoot);
203
+ result.copies.push({
204
+ ...copy,
205
+ action: 'removed',
206
+ reason: branchGone.ok ? 'work landed, branch deleted' : `work landed, branch kept: ${firstLine(branchGone.stderr)}`,
207
+ });
208
+ result.removed += 1;
209
+ }
210
+ return result;
211
+ }
212
+
213
+ function formatTidy(result) {
214
+ const lines = [];
215
+ if (!result.worktreesDir) {
216
+ lines.push(`tidy: ${result.aborted || 'nothing to do'}`);
217
+ return lines.join('\n');
218
+ }
219
+ if (result.aborted) lines.push(`tidy: ${result.aborted}, so every copy is kept`);
220
+ const width = Math.max(0, ...result.copies.map((copy) => copy.action.length));
221
+ for (const copy of result.copies) {
222
+ lines.push(`${copy.action.padEnd(width)} ${copy.name} ${copy.reason}`);
223
+ }
224
+ const total = result.copies.length;
225
+ const verb = result.dryRun ? 'would remove' : 'removed';
226
+ lines.push(total
227
+ ? `tidy: ${verb} ${result.removed}, kept ${result.kept}, of ${total} ${total === 1 ? 'copy' : 'copies'} in ${result.worktreesDir}`
228
+ : `tidy: no member copies in ${result.worktreesDir}`);
229
+ return lines.join('\n');
230
+ }
231
+
232
+ module.exports = {
233
+ tidyMemberCopies,
234
+ formatTidy,
235
+ };
@@ -0,0 +1,286 @@
1
+ 'use strict';
2
+
3
+ // `atris member up`: a small foreground supervisor that keeps the team
4
+ // working. Each check it looks at every runnable member, and when one has no
5
+ // live run and a task with a runnable check fits it, starts
6
+ // `atris member go <name> --needs-check` so the member claims and runs that
7
+ // task. A member with no such task stays idle; it never falls back to its
8
+ // own mission or goal text. The clock, the spawner, the member list and the
9
+ // task picker are passed in, so tests drive it with fake time.
10
+
11
+ const { taskCheck } = require('./member-task-pick');
12
+ const { followBoard } = require('./member-board');
13
+
14
+ const CHECK_MS = 60 * 1000;
15
+ const IDLE_CHECK_MS = 5 * 60 * 1000;
16
+ const SUMMARY_MS = 10 * 60 * 1000;
17
+ const IDLE_AFTER_EMPTY = 3;
18
+ const DEFAULT_MAX = 6;
19
+ // Exit code `member go --needs-check` uses when it found no task with a check
20
+ // to claim (another run took it first). Not a failure, just idle.
21
+ const IDLE_EXIT = 75;
22
+
23
+ function dayOf(ms) {
24
+ const d = new Date(ms);
25
+ return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
26
+ }
27
+
28
+ function oneLine(text) {
29
+ return String(text || '').replace(/\s+/g, ' ').trim();
30
+ }
31
+
32
+ // A task only starts a member when it names a check that can be run.
33
+ function hasRunnableCheck(task) {
34
+ const check = taskCheck(task);
35
+ return Boolean(check.command) && check.runnable;
36
+ }
37
+
38
+ // deps:
39
+ // listMembers() -> [{ name, parked, human, sleeping, live }]
40
+ // pickTask(name, takenIds) -> { id, title } | null (looks only, never claims)
41
+ // spawn(name, onExit) -> any (onExit(code) when it ends)
42
+ // now() -> ms
43
+ // print(line)
44
+ function createMemberUp(deps, options = {}) {
45
+ const max = Math.max(1, Number(options.max) || DEFAULT_MAX);
46
+ const dryRun = options.dryRun === true;
47
+ const print = deps.print || ((line) => console.log(line));
48
+ // only: when set, these members are the whole team for this supervisor.
49
+ // Nobody else is started or counted, and unknown names are skipped.
50
+ const only = Array.isArray(options.only) && options.only.length
51
+ ? new Set(options.only.map((name) => String(name).trim()).filter(Boolean))
52
+ : null;
53
+ const skippedUnknown = new Set();
54
+ const state = new Map();
55
+ const running = new Map();
56
+ let doneToday = 0;
57
+ let doneDay = dayOf(deps.now());
58
+ let lastSummaryAt = deps.now();
59
+ // Members working right now: the runs this supervisor started plus any
60
+ // live run started some other way. Both count toward --max.
61
+ let liveElsewhere = new Set();
62
+ const working = () => new Set([...running.keys(), ...liveElsewhere]).size;
63
+
64
+ const seat = (name) => {
65
+ if (!state.has(name)) state.set(name, { empty: 0, idle: false, nextCheckAt: 0 });
66
+ return state.get(name);
67
+ };
68
+
69
+ function finish(name, code, result = {}) {
70
+ if (!running.has(name)) return;
71
+ const run = running.get(name);
72
+ running.delete(name);
73
+ const now = deps.now();
74
+ if (dayOf(now) !== doneDay) {
75
+ doneDay = dayOf(now);
76
+ doneToday = 0;
77
+ }
78
+ const s = seat(name);
79
+ s.empty = 0;
80
+ s.nextCheckAt = 0;
81
+ if (code === IDLE_EXIT) {
82
+ s.idle = true;
83
+ print(idleLine(name));
84
+ } else if (code === 0 && result.ok !== false && run.finished?.ok !== false) {
85
+ doneToday += 1;
86
+ const changed = oneLine(result.summary || run.finished?.text.replace(/^ok\b[: ,]*/, '')
87
+ || run.changed || 'no change summary reported');
88
+ print(`${name} done ok: ${changed}`);
89
+ } else {
90
+ const reason = oneLine(result.reason || run.finished?.text.replace(/^failed\b[: ,]*/, '')
91
+ || (code === 0 ? 'member reported failure' : code == null ? 'run ended without a result' : `exit ${code}`));
92
+ print(`${name} failed: ${reason}`);
93
+ }
94
+ }
95
+
96
+ const idleLine = (name) => `${name} is idle, no task with a check`;
97
+
98
+ function progress(event) {
99
+ const run = running.get(event.member);
100
+ if (!run || Date.parse(event.at) < run.startedAt) return;
101
+ if (event.kind === 'finished') {
102
+ run.finished = event;
103
+ return;
104
+ }
105
+ if (event.kind !== 'step') return;
106
+ if (event.text.startsWith('editing ')) run.changed = event.text;
107
+ const time = new Date(event.at);
108
+ const hhmm = `${String(time.getHours()).padStart(2, '0')}:${String(time.getMinutes()).padStart(2, '0')}`;
109
+ print(` ${event.member} ${hhmm} ${oneLine(event.text)}`);
110
+ }
111
+
112
+ function summary() {
113
+ const idle = Array.from(state.values()).filter((s) => s.idle).length;
114
+ return `working ${working()}, idle ${idle}, done today ${doneToday}`;
115
+ }
116
+
117
+ // One pass over the team. Returns the members it started (or would start).
118
+ function check() {
119
+ const now = deps.now();
120
+ const started = [];
121
+ const taken = new Set(Array.from(running.values()).map((run) => run.taskId).filter(Boolean));
122
+ const team = deps.listMembers();
123
+ const members = only ? team.filter((m) => m && only.has(m.name)) : team;
124
+ if (only) {
125
+ const known = new Set(team.map((m) => m && m.name).filter(Boolean));
126
+ for (const name of only) {
127
+ if (known.has(name) || skippedUnknown.has(name)) continue;
128
+ skippedUnknown.add(name);
129
+ print(`${name} is not on the team, skipping`);
130
+ }
131
+ }
132
+ liveElsewhere = new Set(members.filter((m) => m && m.name && m.live).map((m) => m.name));
133
+ for (const member of members) {
134
+ const name = member && member.name;
135
+ if (!name || member.parked || member.human || member.sleeping) continue;
136
+ if (running.has(name) || member.live) continue;
137
+ if (working() + (dryRun ? started.length : 0) >= max) break;
138
+ const s = seat(name);
139
+ if (now < s.nextCheckAt) continue;
140
+ const picked = deps.pickTask(name, taken);
141
+ const task = picked && hasRunnableCheck(picked) ? picked : null;
142
+ if (!task) {
143
+ s.empty += 1;
144
+ if (!s.idle && !dryRun) print(idleLine(name));
145
+ s.idle = true;
146
+ if (s.empty >= IDLE_AFTER_EMPTY) s.nextCheckAt = now + IDLE_CHECK_MS;
147
+ continue;
148
+ }
149
+ const title = oneLine(task.title);
150
+ if (task.id) taken.add(task.id);
151
+ if (dryRun) {
152
+ print(`would start ${name} on: ${title}`);
153
+ started.push({ name, task });
154
+ continue;
155
+ }
156
+ if (s.idle) print(`${name} woke up for: ${title}`);
157
+ s.idle = false;
158
+ s.empty = 0;
159
+ s.nextCheckAt = 0;
160
+ running.set(name, { taskId: task.id || null, startedAt: now });
161
+ try {
162
+ deps.spawn(name, (code, result) => {
163
+ deps.flushProgress?.();
164
+ finish(name, code, result);
165
+ });
166
+ } catch (error) {
167
+ running.delete(name);
168
+ print(`${name} failed to start: ${oneLine(error && error.message || error)}`);
169
+ continue;
170
+ }
171
+ print(`${name} started on: ${title}`);
172
+ started.push({ name, task });
173
+ }
174
+ if (!dryRun && now - lastSummaryAt >= SUMMARY_MS) {
175
+ lastSummaryAt = now;
176
+ print(summary());
177
+ }
178
+ return started;
179
+ }
180
+
181
+ return {
182
+ check,
183
+ progress,
184
+ summary,
185
+ running: () => Array.from(running.keys()),
186
+ working,
187
+ max,
188
+ only: only ? Array.from(only) : null,
189
+ idle: () => Array.from(state.entries()).filter(([, s]) => s.idle).map(([name]) => name),
190
+ };
191
+ }
192
+
193
+ // The child's final one-line JSON carries the checked task outcome, even when
194
+ // the CLI exits zero after releasing a failed task. Never turn that into done.
195
+ function memberUpResult(stdout, stderr = '', signal = '') {
196
+ for (const line of String(stdout || '').trim().split('\n').reverse()) {
197
+ let result;
198
+ try { result = JSON.parse(line); } catch { continue; }
199
+ if (!result || typeof result !== 'object' || Array.isArray(result)) continue;
200
+ if (!result.task) {
201
+ if (result.mode === 'idle') return { ok: false, reason: result.reason };
202
+ if (result.error) return { ok: false, reason: result.error };
203
+ continue;
204
+ }
205
+ if (result.task.outcome !== 'review') return { ok: false, reason: result.task.reason || 'the check did not pass' };
206
+ return { ok: true, summary: result.work_step?.summary || '' };
207
+ }
208
+ return { ok: false, reason: signal ? `stopped by ${signal.toLowerCase()}`
209
+ : oneLine(stderr) || 'run ended without a checked task result' };
210
+ }
211
+
212
+ // Reads `member up` flags: --max N, --only a,b and --dry-run.
213
+ // Returns { max, only, dryRun } or { error }.
214
+ function parseMemberUpArgs(args = []) {
215
+ const list = args.map((arg) => String(arg == null ? '' : arg));
216
+ const valueOf = (flag) => {
217
+ const at = list.indexOf(flag);
218
+ if (at >= 0) return list[at + 1] == null ? '' : list[at + 1];
219
+ const inline = list.find((arg) => arg.startsWith(`${flag}=`));
220
+ return inline == null ? null : inline.slice(flag.length + 1);
221
+ };
222
+ const out = { max: undefined, only: null, dryRun: list.includes('--dry-run') };
223
+ const maxText = valueOf('--max');
224
+ if (maxText != null) {
225
+ const max = Number(maxText);
226
+ if (!(Number.isInteger(max) && max > 0)) return { error: '--max needs a whole number above zero.' };
227
+ out.max = max;
228
+ }
229
+ const onlyText = valueOf('--only');
230
+ if (onlyText != null) {
231
+ const names = onlyText.split(',').map((name) => name.trim()).filter((name) => name && !name.startsWith('-'));
232
+ if (!names.length) return { error: '--only needs member names, like --only navigator,executor' };
233
+ out.only = Array.from(new Set(names));
234
+ }
235
+ return out;
236
+ }
237
+
238
+ // Runs checks every minute until stop() is called. `sleep(ms)` must resolve
239
+ // after ms or as soon as the returned stop fires.
240
+ function runMemberUp(deps, options = {}) {
241
+ // Begin following before any child starts, so its first step cannot be lost.
242
+ let follower;
243
+ const up = createMemberUp({ ...deps, flushProgress: () => follower?.scan() }, options);
244
+ if (!options.dryRun) follower = followBoard({
245
+ dir: options.progressDir,
246
+ pollMs: options.progressPollMs,
247
+ orderByTime: true,
248
+ emit: (event) => up.progress(event),
249
+ });
250
+ let stopped = false;
251
+ let wake = null;
252
+ const sleep = deps.sleep || ((ms) => new Promise((resolve) => {
253
+ const timer = setTimeout(resolve, ms);
254
+ wake = () => { clearTimeout(timer); resolve(); };
255
+ }));
256
+ const done = (async () => {
257
+ while (!stopped) {
258
+ up.check();
259
+ if (stopped) break;
260
+ await sleep(options.checkMs || CHECK_MS);
261
+ }
262
+ return up;
263
+ })();
264
+ return {
265
+ up,
266
+ done,
267
+ scanProgress: () => follower?.scan(),
268
+ stop() {
269
+ stopped = true;
270
+ follower?.stop();
271
+ if (wake) wake();
272
+ },
273
+ };
274
+ }
275
+
276
+ module.exports = {
277
+ CHECK_MS,
278
+ DEFAULT_MAX,
279
+ IDLE_CHECK_MS,
280
+ IDLE_EXIT,
281
+ SUMMARY_MS,
282
+ createMemberUp,
283
+ memberUpResult,
284
+ parseMemberUpArgs,
285
+ runMemberUp,
286
+ };
@@ -0,0 +1,84 @@
1
+ 'use strict';
2
+
3
+ // The three plain member verbs: run, wake, sleep. This file only decides
4
+ // which older command a verb means; the handlers that do the work are passed
5
+ // in, so tests can check the routing without starting anything.
6
+ //
7
+ // atris member run "<job>" picks the member and runs the job
8
+ // atris member run <name> "<job>" runs that member, as before
9
+ // atris member wake 5 keeps up to 5 members working
10
+ // atris member wake a b keeps just a and b working
11
+ // atris member wake a one wake for a, as before (--keep keeps it working)
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+
16
+ const SLUG = /^[a-z0-9][a-z0-9._-]*$/i;
17
+ const IGNITE_FLAGS = ['--ignite', '--wait', '--sync'];
18
+
19
+ const WAKE_USAGE = [
20
+ 'wake members three ways:',
21
+ ' atris member wake 5 keep up to 5 members working',
22
+ ' atris member wake navigator executor keep just these members working',
23
+ ' atris member wake navigator one work step now (add --keep to keep it working)',
24
+ ];
25
+
26
+ // A member is a folder in atris/team with a MEMBER.md (or an older flat
27
+ // <name>.md card).
28
+ function isTeamMember(root, name) {
29
+ const clean = String(name == null ? '' : name).trim();
30
+ if (!SLUG.test(clean)) return false;
31
+ const team = path.join(root, 'atris', 'team');
32
+ return fs.existsSync(path.join(team, clean, 'MEMBER.md')) || fs.existsSync(path.join(team, `${clean}.md`));
33
+ }
34
+
35
+ // The words before the first flag.
36
+ function leadingWords(args) {
37
+ const words = [];
38
+ for (const arg of args) {
39
+ const text = String(arg == null ? '' : arg);
40
+ if (text.startsWith('-')) break;
41
+ words.push(text);
42
+ }
43
+ return words;
44
+ }
45
+
46
+ // handlers: { root, run(name, ...args), dispatch(...args) }
47
+ function memberRunVerb(args, handlers) {
48
+ const first = args[0];
49
+ if (first == null || String(first).startsWith('-') || isTeamMember(handlers.root, first)
50
+ || (handlers.aliasOf && isTeamMember(handlers.root, handlers.aliasOf(first)))) {
51
+ return handlers.run(first, ...args.slice(1));
52
+ }
53
+ const ignite = args.some((arg) => IGNITE_FLAGS.includes(arg));
54
+ return handlers.dispatch(...args, ...(ignite ? [] : ['--ignite']));
55
+ }
56
+
57
+ // handlers: { wake(name, ...args), up(...args), print(line) }
58
+ async function memberWakeVerb(args, handlers) {
59
+ const print = handlers.print || ((line) => console.log(line));
60
+ if (!args.length) {
61
+ for (const line of WAKE_USAGE) print(line);
62
+ return { ok: true, usage: true };
63
+ }
64
+ const words = leadingWords(args);
65
+ const flags = args.slice(words.length);
66
+ const keep = flags.includes('--keep');
67
+ const rest = flags.filter((flag) => flag !== '--keep');
68
+ if (words.length === 1 && /^[1-9]\d*$/.test(words[0])) {
69
+ return handlers.up('--max', words[0], ...rest);
70
+ }
71
+ if (words.length >= 2 || (words.length === 1 && keep)) {
72
+ return handlers.up('--only', words.join(','), ...rest);
73
+ }
74
+ const result = await handlers.wake(args[0], ...args.slice(1));
75
+ if (words.length === 1 && !args.includes('--json')) {
76
+ print(`to keep ${words[0]} working: atris member wake ${words[0]} --keep`);
77
+ }
78
+ return result;
79
+ }
80
+
81
+ module.exports = {
82
+ memberRunVerb,
83
+ memberWakeVerb,
84
+ };
@@ -386,6 +386,34 @@ function buildRunnerCommand({ promptFile, allowedTools, model, effort } = {}) {
386
386
  return cmd;
387
387
  }
388
388
 
389
+ // Whether the installed codex prints JSONL events and can write its final
390
+ // answer to a file. Asked once per program; an older codex without the flags
391
+ // keeps today's plain launch.
392
+ const codexJsonSupport = new Map();
393
+ function codexSupportsJsonEvents(bin) {
394
+ if (codexJsonSupport.has(bin)) return codexJsonSupport.get(bin);
395
+ let supported = false;
396
+ try {
397
+ const { spawnSync } = require('child_process');
398
+ const res = spawnSync(bin, ['exec', '--help'], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'pipe'] });
399
+ const help = `${res.stdout || ''}${res.stderr || ''}`;
400
+ supported = res.status === 0 && /--json\b/.test(help) && /--output-last-message\b/.test(help);
401
+ } catch {}
402
+ codexJsonSupport.set(bin, supported);
403
+ return supported;
404
+ }
405
+
406
+ // The codex launch with live JSONL events on stdout and the final answer in
407
+ // lastMessageFile, or null when this runner is not a codex exec that takes them.
408
+ function codexJsonEventsCommand(cmd, lastMessageFile) {
409
+ const bin = resolveClaudeRunnerBin();
410
+ if (ownModelsOnlyEngineForBin(bin) !== 'codex') return null;
411
+ const lead = `${shellWord(bin)} exec`;
412
+ if (!String(cmd).startsWith(`${lead} `)) return null;
413
+ if (!codexSupportsJsonEvents(bin)) return null;
414
+ return `${lead} --json --output-last-message ${shellWord(lastMessageFile)}${cmd.slice(lead.length)}`;
415
+ }
416
+
389
417
  // Whether this engine's launch can carry a pinned model, and which effort
390
418
  // words it takes. The roster checks both when it reads a line.
391
419
  function engineTakesModel(engineId) {
@@ -401,6 +429,7 @@ function engineEffortLevels(engineId) {
401
429
 
402
430
  module.exports = {
403
431
  EFFORT_WORDS,
432
+ codexJsonEventsCommand,
404
433
  engineTakesModel,
405
434
  engineEffortLevels,
406
435
  DEFAULT_CLAUDE_RUNNER_MODEL,
package/lib/task-db.js CHANGED
@@ -537,7 +537,11 @@ function getTask(db, id) {
537
537
  WHERE id = ?
538
538
  `).get(id);
539
539
  if (!row) return null;
540
- return { ...row, metadata: row.metadata ? safeJSON(row.metadata) : null };
540
+ const metadata = row.metadata ? safeJSON(row.metadata) : null;
541
+ // Stored metadata that does not parse to an object is flagged, so readers
542
+ // that must fail closed can tell it apart from no metadata at all.
543
+ const unreadable = Boolean(row.metadata) && (!metadata || typeof metadata !== 'object' || Array.isArray(metadata));
544
+ return { ...row, metadata, ...(unreadable ? { metadata_unreadable: true } : {}) };
541
545
  }
542
546
 
543
547
  // Atomic claim. Returns { claimed: true, row } only if THIS call won the row.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.68.9",
3
+ "version": "3.69.0",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof.",
5
5
  "main": "bin/atris.js",
6
6
  "bin": {