atris 3.61.0 → 3.63.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.
Files changed (53) hide show
  1. package/README.md +37 -5
  2. package/atris/features/README.md +8 -0
  3. package/atris/skills/README.md +1 -0
  4. package/atris/skills/commerce/SKILL.md +96 -0
  5. package/atris/skills/engines/SKILL.md +9 -9
  6. package/bin/atris.js +12 -0
  7. package/commands/autoland.js +36 -5
  8. package/commands/autopilot.js +105 -6
  9. package/commands/buy.js +242 -0
  10. package/commands/engine.js +96 -21
  11. package/commands/host.js +56 -4
  12. package/commands/land.js +129 -8
  13. package/commands/member.js +68 -14
  14. package/commands/mission.js +116 -4
  15. package/commands/one-lap.js +14 -10
  16. package/commands/stream.js +56 -22
  17. package/commands/task.js +7 -0
  18. package/commands/team.js +82 -10
  19. package/commands/transactions.js +115 -0
  20. package/commands/worktree.js +39 -0
  21. package/commands/youtube.js +58 -26
  22. package/lib/commerce.js +75 -0
  23. package/lib/engine-ask.js +112 -9
  24. package/lib/engine-registry.js +300 -39
  25. package/lib/fleet.js +444 -26
  26. package/lib/functional-owner.js +15 -6
  27. package/lib/host.js +296 -170
  28. package/lib/known-commands.js +1 -1
  29. package/lib/member-engine.js +25 -1
  30. package/lib/member-park.js +138 -0
  31. package/lib/member-scaffold.js +39 -0
  32. package/lib/member-type.js +27 -0
  33. package/lib/roster-markdown.js +1 -1
  34. package/lib/roster-prep.js +289 -0
  35. package/lib/roster-runs.js +389 -0
  36. package/lib/roster-suggest.js +145 -0
  37. package/lib/router-brain.js +111 -3
  38. package/lib/runner-command.js +37 -2
  39. package/lib/self-drive.js +26 -2
  40. package/lib/state-detection.js +3 -3
  41. package/lib/task-db.js +38 -3
  42. package/lib/team-lineup.js +7 -2
  43. package/lib/todo.js +1 -1
  44. package/lib/wish-audit.js +1 -1
  45. package/package.json +1 -1
  46. package/scripts/det/cpu-hotspots.js +216 -0
  47. package/scripts/det/ytnotes +169 -23
  48. package/scripts/det/ytquote-repair.js +18 -4
  49. package/scripts/det/ytrail-bench.js +214 -0
  50. package/scripts/det/ytrail-eval.js +128 -56
  51. package/templates/members/host/MEMBER.md +5 -3
  52. package/templates/members/host/MISSION.md +1 -1
  53. package/templates/members/host/skills/host/SKILL.md +64 -16
package/commands/land.js CHANGED
@@ -1,6 +1,6 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
- const { runGit } = require('../lib/git-spawn');
3
+ const gitSpawn = require('../lib/git-spawn');
4
4
  const { compactErrorPayload, printCliJson } = require('../lib/cli-json');
5
5
  const { listWorktrees, statusCounts } = require('./worktree');
6
6
 
@@ -9,6 +9,14 @@ const DEFAULT_STALE_HOURS = 48;
9
9
  const WORKTREE_REAP_GRACE_MS = 60 * 60 * 1000;
10
10
  const PROTECTED_BRANCHES = new Set(['main', 'master']);
11
11
 
12
+ // Through the module object so tests can count git spawns with a spy.
13
+ function runGit(args, opts) {
14
+ return gitSpawn.runGit(args, opts);
15
+ }
16
+
17
+ const CHERRY_CACHE_FILE = path.join('.atris', 'state', 'cherry-cache.json');
18
+ const CHERRY_CACHE_MAX = 2000;
19
+
12
20
  // Pluralize a count + noun ("1 change", "2 changes").
13
21
  function countLabel(n, word) {
14
22
  return `${n} ${word}${n === 1 ? '' : 's'}`;
@@ -157,10 +165,11 @@ function checkoutKeepLine(name, bound, now) {
157
165
  function listBranches(root, base = '') {
158
166
  // With a base, ask git for ahead counts in the same single spawn
159
167
  // (%(ahead-behind:) needs git >= 2.41; on failure we retry without it and
160
- // collectBoard falls back to per-branch aheadCount).
168
+ // collectBoard falls back to per-branch aheadCount). The branch sha comes
169
+ // along in the same spawn so the cherry cache needs no per-branch lookup.
161
170
  const format = base
162
- ? `%(refname:short)%09%(committerdate:unix)%09%(ahead-behind:${base})`
163
- : '%(refname:short)%09%(committerdate:unix)';
171
+ ? `%(refname:short)%09%(committerdate:unix)%09%(objectname)%09%(ahead-behind:${base})`
172
+ : '%(refname:short)%09%(committerdate:unix)%09%(objectname)';
164
173
  const result = runGit(['for-each-ref', 'refs/heads', `--format=${format}`], { cwd: root, check: false });
165
174
  if (result.status !== 0) {
166
175
  if (base) return listBranches(root);
@@ -170,8 +179,9 @@ function listBranches(root, base = '') {
170
179
  .split(/\r?\n/)
171
180
  .filter(Boolean)
172
181
  .map((line) => {
173
- const [name, date, aheadBehind] = line.split('\t');
182
+ const [name, date, sha, aheadBehind] = line.split('\t');
174
183
  const entry = { name, lastCommitUnix: Number(date) };
184
+ if (sha) entry.sha = sha;
175
185
  if (aheadBehind !== undefined) {
176
186
  const ahead = Number(aheadBehind.split(' ')[0]);
177
187
  if (Number.isFinite(ahead)) entry.ahead = ahead;
@@ -206,6 +216,82 @@ function cherryStats(root, base, ref) {
206
216
  return { landedElsewhere, unique };
207
217
  }
208
218
 
219
+ // `git cherry <base> <ref>` depends only on the two commit shas, so its
220
+ // counts are cached per (base sha, ref sha) under .atris/state. The cache is
221
+ // only written where .atris already exists; a missing or corrupt file means
222
+ // recompute. Entries for refs not seen this run are dropped on save.
223
+ function cherryCachePath(root) {
224
+ return path.join(root, CHERRY_CACHE_FILE);
225
+ }
226
+
227
+ function loadCherryCache(root) {
228
+ const cache = { file: cherryCachePath(root), entries: {}, used: {}, dirty: false, writable: false };
229
+ try {
230
+ cache.writable = fs.statSync(path.join(root, '.atris')).isDirectory();
231
+ } catch {
232
+ cache.writable = false;
233
+ }
234
+ if (!cache.writable) return cache;
235
+ try {
236
+ const parsed = JSON.parse(fs.readFileSync(cache.file, 'utf8'));
237
+ if (parsed && parsed.version === 1 && parsed.entries && typeof parsed.entries === 'object') {
238
+ cache.entries = parsed.entries;
239
+ }
240
+ } catch {
241
+ cache.entries = {};
242
+ }
243
+ return cache;
244
+ }
245
+
246
+ function validCherryEntry(entry) {
247
+ return entry && Number.isInteger(entry.landedElsewhere) && Number.isInteger(entry.unique)
248
+ && entry.landedElsewhere >= 0 && entry.unique >= 0;
249
+ }
250
+
251
+ function cachedCherryStats(cache, cwd, base, baseSha, ref, refSha) {
252
+ if (!cache || !baseSha || !refSha) return cherryStats(cwd, base, ref);
253
+ const key = `${baseSha}:${refSha}`;
254
+ const hit = cache.entries[key];
255
+ if (validCherryEntry(hit)) {
256
+ cache.used[key] = hit;
257
+ return { landedElsewhere: hit.landedElsewhere, unique: hit.unique };
258
+ }
259
+ // Cherry the exact commits the key names, so a ref that moves mid-run
260
+ // can never file one pair's result under another pair's key.
261
+ const result = runGit(['cherry', baseSha, refSha], { cwd, check: false });
262
+ if (result.status !== 0) return { landedElsewhere: 0, unique: 0 };
263
+ const stats = { landedElsewhere: 0, unique: 0 };
264
+ for (const line of result.stdout.split(/\r?\n/).filter(Boolean)) {
265
+ if (line.startsWith('- ')) stats.landedElsewhere += 1;
266
+ else if (line.startsWith('+ ')) stats.unique += 1;
267
+ }
268
+ cache.used[key] = { ...stats };
269
+ cache.dirty = true;
270
+ return stats;
271
+ }
272
+
273
+ function saveCherryCache(cache) {
274
+ if (!cache || !cache.writable) return;
275
+ const usedKeys = Object.keys(cache.used);
276
+ // Rewrite when something new was computed or something stale can go.
277
+ if (!cache.dirty && usedKeys.length === Object.keys(cache.entries).length) return;
278
+ const entries = {};
279
+ for (const key of usedKeys.slice(-CHERRY_CACHE_MAX)) entries[key] = cache.used[key];
280
+ try {
281
+ fs.mkdirSync(path.dirname(cache.file), { recursive: true });
282
+ const tmp = `${cache.file}.${process.pid}.tmp`;
283
+ fs.writeFileSync(tmp, `${JSON.stringify({ version: 1, entries })}\n`);
284
+ fs.renameSync(tmp, cache.file);
285
+ } catch {
286
+ // The cache is only a speedup; a failed write costs a recompute next time.
287
+ }
288
+ }
289
+
290
+ function resolveSha(root, ref) {
291
+ const result = runGit(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], { cwd: root, check: false });
292
+ return result.status === 0 ? result.stdout.trim() : '';
293
+ }
294
+
209
295
  // Board: every branch and worktree with unlanded state, classified.
210
296
  // landed, no commits ahead of base; the branch pointer is residue
211
297
  // active, has unlanded commits, younger than TTL
@@ -220,6 +306,8 @@ function collectBoard(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_S
220
306
 
221
307
  const branches = [];
222
308
  const lastCommitMsByBranch = new Map();
309
+ const cherryCache = loadCherryCache(root);
310
+ const baseSha = resolveSha(root, base);
223
311
  for (const branch of listBranches(root, base)) {
224
312
  if (PROTECTED_BRANCHES.has(branch.name)) continue;
225
313
  const lastCommitMs = Number(branch.lastCommitUnix) * 1000;
@@ -227,7 +315,9 @@ function collectBoard(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_S
227
315
  const ahead = branch.ahead !== undefined ? branch.ahead : aheadCount(root, base, branch.name);
228
316
  const age = ageDays(branch.lastCommitUnix, now);
229
317
  const hours = ageHours(branch.lastCommitUnix, now);
230
- const cherry = ahead > 0 ? cherryStats(root, base, branch.name) : { landedElsewhere: 0, unique: 0 };
318
+ const cherry = ahead > 0
319
+ ? cachedCherryStats(cherryCache, root, base, baseSha, branch.name, branch.sha)
320
+ : { landedElsewhere: 0, unique: 0 };
231
321
  let state = 'active';
232
322
  if (ahead === 0 || (cherry.landedElsewhere > 0 && cherry.unique === 0)) state = 'landed';
233
323
  else if (age > ttlDays) state = 'due';
@@ -271,7 +361,7 @@ function collectBoard(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_S
271
361
  const cnt = runGit(['rev-list', '--count', `${base}..HEAD`], { cwd: wt.path, check: false });
272
362
  ahead = cnt.status === 0 ? Number(cnt.stdout.trim()) || 0 : 0;
273
363
  if (ahead > 0) {
274
- unlandedCommits = cherryStats(wt.path, base, 'HEAD').unique;
364
+ unlandedCommits = cachedCherryStats(cherryCache, wt.path, base, baseSha, 'HEAD', wt.head).unique;
275
365
  const ts = runGit(['log', '-1', '--format=%ct', 'HEAD'], { cwd: wt.path, check: false });
276
366
  if (ts.status === 0) ageDaysVal = ageDays(Number(ts.stdout.trim()), now);
277
367
  if (!light) subjects = commitSubjects(wt.path, base, 'HEAD');
@@ -309,6 +399,8 @@ function collectBoard(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_S
309
399
  branch.stale = branch.state === 'active' && branch.activityHours >= staleHours;
310
400
  }
311
401
 
402
+ saveCherryCache(cherryCache);
403
+
312
404
  const due = branches.filter((b) => b.state === 'due');
313
405
  const landed = branches.filter((b) => b.state === 'landed');
314
406
  const active = branches.filter((b) => b.state === 'active');
@@ -333,6 +425,33 @@ function collectBoard(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_S
333
425
  };
334
426
  }
335
427
 
428
+ // One landing board per process per repo. Boot, stream, and the team view
429
+ // each ask for the board more than once in a single command; the first call
430
+ // pays for git, later calls within BOARD_MEMO_MAX_AGE_MS share the result.
431
+ // A full board also answers light requests (light is a subset). Long-lived
432
+ // callers (stream follow) clear the memo before each poll.
433
+ const BOARD_MEMO_MAX_AGE_MS = 10000;
434
+ const boardMemo = new Map();
435
+
436
+ function boardMemoKey(root, { ttlDays = DEFAULT_TTL_DAYS, staleHours = DEFAULT_STALE_HOURS, base = '' } = {}) {
437
+ return JSON.stringify([canonicalPath(root), ttlDays, staleHours, base]);
438
+ }
439
+
440
+ function sharedBoard(root, opts = {}) {
441
+ const key = boardMemoKey(root, opts);
442
+ const light = Boolean(opts.light);
443
+ const nowMs = Date.now();
444
+ const hit = boardMemo.get(key);
445
+ if (hit && nowMs - hit.at < BOARD_MEMO_MAX_AGE_MS && (light || !hit.light)) return hit.board;
446
+ const board = collectBoard(root, { ...opts, light });
447
+ boardMemo.set(key, { board, light, at: Date.now() });
448
+ return board;
449
+ }
450
+
451
+ function clearBoardMemo() {
452
+ boardMemo.clear();
453
+ }
454
+
336
455
  // Counts for the boot banner and digest. This intentionally pays the same
337
456
  // classification cost as the landing board so "in the air" never includes
338
457
  // merged branch residue.
@@ -340,7 +459,7 @@ function landSummary(cwd = process.cwd(), ttlDays = DEFAULT_TTL_DAYS) {
340
459
  const root = repoRoot(cwd);
341
460
  if (!root || !hasCommits(root)) return null;
342
461
  try {
343
- const board = collectBoard(root, { ttlDays, light: true });
462
+ const board = sharedBoard(root, { ttlDays, light: true });
344
463
  return {
345
464
  branches: board.summary.active + board.summary.due,
346
465
  due: board.summary.due,
@@ -918,9 +1037,11 @@ function landCommand(args = []) {
918
1037
  }
919
1038
 
920
1039
  module.exports = {
1040
+ clearBoardMemo,
921
1041
  collectBoard,
922
1042
  countLabel,
923
1043
  landCommand,
924
1044
  landSummary,
925
1045
  reap,
1046
+ sharedBoard,
926
1047
  };
@@ -7,11 +7,12 @@ const { loadCredentials } = require('../utils/auth');
7
7
  const { resolveBackendRoot } = require('../utils/backend-root');
8
8
  const { apiRequestJson } = require('../utils/api');
9
9
  const { runAliveTick } = require('../lib/member-alive');
10
+ const { memberRefusalReason } = require('../lib/member-type');
10
11
  const { defaultObjectiveRunner } = require('../lib/default-runner');
11
12
  const { readJson, writeJson } = require('../lib/json-file');
12
13
  const { sleepSync } = require('../lib/sleep-sync');
13
14
  const { hasFlag, readFlag, readNumberFlag } = require('../lib/arg-parser');
14
- const { ensureMemberBundle, memberBundlePresent } = require('../lib/member-scaffold');
15
+ const { ensureMemberBundle, memberBundlePresent, updateMemberSkills } = require('../lib/member-scaffold');
15
16
  const { memberProcessPrompt, MEMBER_PROCESS_PATH } = require('../lib/member-context');
16
17
 
17
18
  function findWorkspaceBusinessId(startDir = process.cwd()) {
@@ -306,6 +307,28 @@ function nearestMemberSlug(name, slugs) {
306
307
  return bestScore <= Math.max(2, Math.floor(needle.length / 3)) ? best : null;
307
308
  }
308
309
 
310
+ // A person is never run as an agent. Reads the member's own type and returns
311
+ // the plain refusal sentence, or '' when the member can run.
312
+ function memberRefusalFor(name, memberFile) {
313
+ try {
314
+ return memberRefusalReason(name, parseFrontmatter(fs.readFileSync(memberFile, 'utf8')) || {});
315
+ } catch {
316
+ return '';
317
+ }
318
+ }
319
+
320
+ // Same as requireMemberDir, then stops with a non-zero exit for a human or
321
+ // odd-typed member. Every path that runs a member as an agent starts here.
322
+ function requireRunnableMember(name) {
323
+ const paths = requireMemberDir(name);
324
+ const reason = memberRefusalFor(name, paths.memberFile);
325
+ if (reason) {
326
+ console.error(reason);
327
+ process.exit(1);
328
+ }
329
+ return paths;
330
+ }
331
+
309
332
  function requireMemberDir(name) {
310
333
  if (!name) {
311
334
  console.error('Usage: atris member <goal|tick|review> <name> ...');
@@ -1077,7 +1100,14 @@ function memberRun(name, ...args) {
1077
1100
  }
1078
1101
 
1079
1102
  const asJson = hasFlag(args, '--json');
1080
- const paths = requireMemberDir(name);
1103
+ const paths = requireRunnableMember(name);
1104
+ // Parking only hides a member from the team views; a run by name still goes.
1105
+ const { isParkedText } = require('../lib/member-park');
1106
+ try {
1107
+ if (isParkedText(fs.readFileSync(paths.memberFile, 'utf8'))) {
1108
+ console.error(`${name} is parked; running anyway`);
1109
+ }
1110
+ } catch { /* an unreadable card is requireMemberDir's problem, not this notice's */ }
1081
1111
  const requestedVerifier = readFlag(args, '--verify', '') || memberRunConfiguredVerifier(paths);
1082
1112
  const missionText = memberRunMissionText(args);
1083
1113
  const hasMissionOverride = Boolean(readFlag(args, '--mission', '') || readFlag(args, '--mission-id', ''));
@@ -4095,6 +4125,8 @@ function memberList(...flags) {
4095
4125
  const asJson = flags.includes('--json') || process.argv.includes('--json');
4096
4126
  const teamDir = path.join(process.cwd(), 'atris', 'team');
4097
4127
  const members = findAllMembers(teamDir);
4128
+ const { isMemberParked } = require('../lib/member-park');
4129
+ const parked = new Set(members.filter((m) => isMemberParked(process.cwd(), m.name)).map((m) => m.name));
4098
4130
 
4099
4131
  if (asJson) {
4100
4132
  console.log(JSON.stringify({
@@ -4102,6 +4134,7 @@ function memberList(...flags) {
4102
4134
  count: members.length,
4103
4135
  members: members.map((m) => ({
4104
4136
  name: m.name,
4137
+ ...(parked.has(m.name) ? { parked: true } : {}),
4105
4138
  role: m.role,
4106
4139
  format: m.format,
4107
4140
  skills: m.format === 'directory' ? m.skillCount : null,
@@ -4149,12 +4182,14 @@ function memberList(...flags) {
4149
4182
  clipCell(m.format === 'directory' ? 'dir' : 'flat', fmtW) +
4150
4183
  clipCell(skills, skillW) +
4151
4184
  clipCell(context, ctxW) +
4152
- (m.version || '-')
4185
+ (m.version || '-') +
4186
+ (parked.has(m.name) ? ' parked' : '')
4153
4187
  );
4154
4188
  }
4155
4189
 
4156
4190
  console.log('');
4157
- console.log(`${members.length} ${members.length === 1 ? 'member' : 'members'} found.`);
4191
+ const parkedNote = parked.size ? ` (${parked.size} parked; unpark with atris team unpark <name>)` : '';
4192
+ console.log(`${members.length} ${members.length === 1 ? 'member' : 'members'} found.${parkedNote}`);
4158
4193
  }
4159
4194
 
4160
4195
  // --- CREATE subcommand ---
@@ -4209,6 +4244,8 @@ name: ${name}
4209
4244
  role: ${role}
4210
4245
  description: ${description || `Handles ${role.toLowerCase()} tasks`}
4211
4246
  version: 1.0.0
4247
+ # ai or human; a human member is a person and Atris never acts as them
4248
+ type: ai
4212
4249
 
4213
4250
  skills: []
4214
4251
 
@@ -4296,10 +4333,10 @@ tools: []
4296
4333
  }
4297
4334
 
4298
4335
  function printMemberInstallUsage(stream = console.log) {
4299
- stream('Usage: atris member install <slug>');
4336
+ stream('Usage: atris member install <slug> [--update]');
4300
4337
  }
4301
4338
 
4302
- function memberInstall(slug) {
4339
+ function memberInstall(slug, update = false) {
4303
4340
  if (slug === '--help' || slug === '-h') {
4304
4341
  printMemberInstallUsage();
4305
4342
  return;
@@ -4314,9 +4351,22 @@ function memberInstall(slug) {
4314
4351
  }
4315
4352
 
4316
4353
  const memberDir = path.join(process.cwd(), 'atris', 'team', slug);
4354
+ const templateDir = path.join(__dirname, '..', 'templates', 'members', slug);
4355
+ if (update) {
4356
+ if (!fs.existsSync(templateDir)) {
4357
+ console.error(`No member template for ${slug}.`);
4358
+ process.exit(1);
4359
+ }
4360
+ const updated = updateMemberSkills(templateDir, memberDir);
4361
+ if (updated.length === 0) {
4362
+ console.log(`MEMBER ${slug} already up to date`);
4363
+ } else {
4364
+ for (const rel of updated) console.log(`atris/team/${slug}/${rel.split(path.sep).join('/')}`);
4365
+ }
4366
+ return;
4367
+ }
4317
4368
  const already = memberBundlePresent(memberDir);
4318
4369
  fs.mkdirSync(path.join(memberDir, 'logs'), { recursive: true });
4319
- const templateDir = path.join(__dirname, '..', 'templates', 'members', slug);
4320
4370
  const copied = [];
4321
4371
  if (fs.existsSync(templateDir)) {
4322
4372
  const copyMissing = (source, relative = '') => {
@@ -7838,6 +7888,8 @@ function wakeMemberEngine(name, root = process.cwd()) {
7838
7888
 
7839
7889
  async function runMemberWake(name, { execute = false, confirmed = false, force = false, domainInput = {} } = {}) {
7840
7890
  const paths = requireMemberDir(name);
7891
+ const refusal = memberRefusalFor(name, paths.memberFile);
7892
+ if (refusal) throw new Error(refusal);
7841
7893
  const runtimeKind = paths.runtimeKind || memberRuntimeKind(name);
7842
7894
  if (runtimeKind === 'auto-improver') {
7843
7895
  return runAutoImproverWake(name, paths, { execute, confirmed });
@@ -8151,6 +8203,7 @@ function clipText(value, max = 70) {
8151
8203
  }
8152
8204
 
8153
8205
  async function memberWake(name, ...args) {
8206
+ if (name) requireRunnableMember(name);
8154
8207
  const asJson = hasFlag(args, '--json');
8155
8208
  const execute = hasFlag(args, '--execute') && !hasFlag(args, '--dry-run');
8156
8209
  const confirmed = hasFlag(args, '--confirm-autonomy-policy');
@@ -8198,14 +8251,15 @@ function collectFleetActivationRows(root = process.cwd()) {
8198
8251
  const goalPlane = memberGoalPlaneStatus(slug, paths);
8199
8252
  const activeMission = memberLiveMission(slug, missionMap);
8200
8253
  const latestMission = memberLatestMission(slug, missionMap);
8201
- const skipped = !goalPlane.goal && !activeMission;
8254
+ const refusal = memberRefusalFor(slug, paths.memberFile);
8255
+ const skipped = Boolean(refusal) || (!goalPlane.goal && !activeMission);
8202
8256
  return {
8203
8257
  member: slug,
8204
8258
  goal: goalPlane.goal || null,
8205
8259
  active_mission: activeMission || null,
8206
8260
  latest_mission: latestMission || null,
8207
8261
  skipped,
8208
- skip_reason: skipped ? 'no_goal_and_no_active_mission' : null,
8262
+ skip_reason: refusal ? 'not_ai' : skipped ? 'no_goal_and_no_active_mission' : null,
8209
8263
  };
8210
8264
  })
8211
8265
  .sort((a, b) => a.member.localeCompare(b.member));
@@ -8238,7 +8292,7 @@ async function memberLoopAll(kind, ...args) {
8238
8292
  for (const row of rows) {
8239
8293
  if (row.skipped) {
8240
8294
  skipped.push(row);
8241
- if (!asJson) console.log(`Skip ${row.member}: no goal and no active mission.`);
8295
+ if (!asJson) console.log(`Skip ${row.member}: ${row.skip_reason === 'not_ai' ? 'not an AI member.' : 'no goal and no active mission.'}`);
8242
8296
  continue;
8243
8297
  }
8244
8298
  if (!asJson) console.log(`Activate ${row.member}: ${row.goal ? 'goal' : `mission ${row.active_mission?.id || 'active'}`}`);
@@ -8650,7 +8704,7 @@ function memberLoopRecordResult(name, paths, lease, run) {
8650
8704
  // Driver: the phases above do the work; this reads as setup -> gate -> run -> record.
8651
8705
  async function memberLoop(name, ...args) {
8652
8706
  if (name === '--all' || name === 'all') return memberLoopAll('loop', ...args);
8653
- requireMemberDir(name);
8707
+ requireRunnableMember(name);
8654
8708
  const asJson = hasFlag(args, '--json');
8655
8709
  const aliveMode = hasFlag(args, '--alive');
8656
8710
  const execute = hasFlag(args, '--execute');
@@ -8714,7 +8768,7 @@ async function memberLoop(name, ...args) {
8714
8768
  }
8715
8769
 
8716
8770
  async function memberTick(name, ...args) {
8717
- const paths = requireMemberDir(name);
8771
+ const paths = requireRunnableMember(name);
8718
8772
  const asJson = hasFlag(args, '--json');
8719
8773
  const force = hasFlag(args, '--force');
8720
8774
  const goalId = readFlag(args, '--goal', '');
@@ -9332,7 +9386,7 @@ async function memberCommand(subcommand, ...args) {
9332
9386
  case 'new':
9333
9387
  return memberCreate(args[0], ...args.slice(1));
9334
9388
  case 'install':
9335
- return memberInstall(args[0]);
9389
+ return memberInstall(args[0], args.includes('--update'));
9336
9390
  case 'activate':
9337
9391
  return memberActivate(args[0]);
9338
9392
  case 'upgrade':
@@ -9394,7 +9448,7 @@ async function memberCommand(subcommand, ...args) {
9394
9448
  console.log('');
9395
9449
  console.log('Subcommands:');
9396
9450
  console.log(' create <name> Scaffold a new team member (MEMBER.md + dirs) [--push]');
9397
- console.log(' install <slug> Install the full MEMBER bundle if missing; never overwrite');
9451
+ console.log(' install <slug> [--update] Install missing files, or refresh template skills');
9398
9452
  console.log(' chat <name> "..." Send a message to this member\'s desk in Atris Desktop');
9399
9453
  console.log(' list [--json] Show all team members');
9400
9454
  console.log(' activate <name> Symlink member skills, show context and permissions');
@@ -22,6 +22,7 @@ const {
22
22
  const {
23
23
  resolveClaudeRunnerModel,
24
24
  resolvePinnedRunnerModel,
25
+ runnerModelFor,
25
26
  resolveClaudeRunnerBin,
26
27
  resolveClaudeRunnerCommandTemplate,
27
28
  buildRunnerCommand,
@@ -32,8 +33,11 @@ const {
32
33
  const {
33
34
  resolveEngineForRoleWithPreference,
34
35
  engineFailureHealthStatus,
36
+ engineStallReason,
37
+ recordEngineRunHealth,
35
38
  setEngineHealth,
36
39
  } = require('../lib/engine-registry');
40
+ const { appendRosterRun, parseRunUsage } = require('../lib/roster-runs');
37
41
  const {
38
42
  FUNCTIONAL_MEMBER_TOPICS,
39
43
  listWorkspaceMemberSlugs,
@@ -391,9 +395,29 @@ function rosterTickPins(engine) {
391
395
  return {
392
396
  ...(engine && engine.roster_effort ? { roster_effort: engine.roster_effort } : {}),
393
397
  ...(engine && engine.roster_max_seconds ? { roster_max_seconds: engine.roster_max_seconds } : {}),
398
+ ...(engine && engine.roster_prep ? { roster_prep: engine.roster_prep } : {}),
394
399
  };
395
400
  }
396
401
 
402
+ // A tick whose roster worker asks for prep ("prep: search") runs the same
403
+ // prep pass fleet runs before the worker starts, and the worker's prompt
404
+ // gets the brief. No prep line means the prompt goes out as it was.
405
+ async function missionTickPrep(root, { mission, runtimeMission, job, prompt, ask } = {}) {
406
+ const prepJob = String(runtimeMission && runtimeMission.roster_prep || '').trim();
407
+ if (!prepJob) return { prompt, prep: null };
408
+ const { runPrepPass, withPrepBrief } = require('../lib/roster-prep');
409
+ const prep = await runPrepPass({
410
+ prepJob,
411
+ forJob: job || 'build',
412
+ task: { display_id: (mission && mission.id) || '', title: (mission && (mission.objective || mission.slug)) || '' },
413
+ prompt,
414
+ root,
415
+ record: { member: (mission && mission.owner) || '' },
416
+ ...(ask ? { ask } : {}),
417
+ });
418
+ return { prompt: withPrepBrief(prompt, prep), prep };
419
+ }
420
+
397
421
  // A tick's time limit: the time left on the mission, capped by the roster
398
422
  // line's "max 20 min" when it sets one, else by the default tick cap.
399
423
  function missionTickTimeoutMs(mission, remainingMs) {
@@ -427,6 +451,7 @@ function resolveMissionTickRunner(mission, root = process.cwd(), options = {}) {
427
451
  requested_engine: null,
428
452
  engine_fallback_reason: null,
429
453
  member_engine_reason: member.reason,
454
+ roster_job: member.job || 'build',
430
455
  };
431
456
  }
432
457
  }
@@ -445,10 +470,59 @@ function resolveMissionTickRunner(mission, root = process.cwd(), options = {}) {
445
470
  };
446
471
  }
447
472
 
473
+ // A stalled tick benches its engine for the cooldown, so the next tick's
474
+ // roster pick is the backup; no retry inside the tick is needed.
448
475
  function recordMissionEngineTickOutcome(engineId, result, root = process.cwd()) {
449
476
  if (!engineId) return null;
450
- const status = result && result.status === 'ran' ? 'ready' : engineFailureHealthStatus(result);
451
- return status ? setEngineHealth(engineId, status, root) : null;
477
+ if (result && result.status === 'ran') return setEngineHealth(engineId, 'ready', root);
478
+ if (!engineFailureHealthStatus(result)) return null;
479
+ return recordEngineRunHealth(engineId, result, root);
480
+ }
481
+
482
+ // One run-record line per engine tick: how long the worker ran, and how it
483
+ // ended. A tick that ran and passed (or had no verifier) landed; a failing
484
+ // verifier or a real error failed; a time cap or dropped connection stalled;
485
+ // a closed credit window is credit out. Never throws into the tick.
486
+ function missionTickRunOutcome(result, verifierResult, maxSeconds) {
487
+ if (result && result.status === 'ran') {
488
+ return verifierResult && verifierResult.passed === false
489
+ ? { outcome: 'failed', detail: 'the verifier failed' }
490
+ : { outcome: 'landed' };
491
+ }
492
+ if (engineFailureHealthStatus(result) === 'credit_out') return { outcome: 'credit out', detail: 'the engine hit its usage limit' };
493
+ const stall = engineStallReason({ ...result, ...(maxSeconds ? { max_seconds: maxSeconds } : {}) });
494
+ if (stall) return { outcome: 'stalled', detail: stall.text };
495
+ if (/wall-exceeded/.test(String(result && result.reason || ''))) return { outcome: 'failed', detail: 'the mission ran out of time' };
496
+ const summary = String(result && result.claude && result.claude.summary || '').split('\n')[0].trim();
497
+ return { outcome: 'failed', detail: summary && summary !== 'error' ? summary : String(result && result.reason || 'the tick failed').replace(/-/g, ' ') };
498
+ }
499
+
500
+ function recordMissionTickRosterRun(root, { mission, runtimeMission, engineId, job, result, verifierResult, startedAt, endedMs, usage, prep = null } = {}) {
501
+ if (!engineId) return null;
502
+ try {
503
+ const { engineRunsView } = require('../lib/roster-models');
504
+ const { prepRecordFields } = require('../lib/roster-prep');
505
+ const run = runtimeMission || mission || {};
506
+ const startedMs = Date.parse(startedAt);
507
+ const maxSeconds = Number(run.roster_max_seconds) || 0;
508
+ return appendRosterRun(root, {
509
+ at: startedAt,
510
+ job: job || 'build',
511
+ member: (mission && mission.owner) || '',
512
+ engine: engineId,
513
+ model: run.model || engineRunsView(engineId).model || '',
514
+ effort: run.roster_effort || '',
515
+ max_seconds: maxSeconds,
516
+ seconds: Number.isFinite(startedMs) ? Math.max(0, (endedMs - startedMs) / 1000) : undefined,
517
+ task: (mission && mission.id) || '',
518
+ source: 'mission',
519
+ ...prepRecordFields(prep),
520
+ ...(usage || {}),
521
+ ...missionTickRunOutcome(result, verifierResult, maxSeconds),
522
+ });
523
+ } catch {
524
+ return null;
525
+ }
452
526
  }
453
527
 
454
528
  function runnerModelPatch(runner, model) {
@@ -495,6 +569,8 @@ function applyMissionRunnerProfile(runner) {
495
569
  // default model pin. Every other engine's CLI rejects a claude model name, so
496
570
  // it only ever takes an explicit pin (the mission's model or the roster
497
571
  // line's); '' lets the engine ride its own default.
572
+ // A mission often still carries the claude model it was created with; an
573
+ // engine that runs only its own models (codex) drops it.
498
574
  const CLAUDE_FAMILY_RUNNER_ENGINES = new Set(['claude', 'fable', 'haiku']);
499
575
 
500
576
  function resolveMissionTickRunnerModel(mission) {
@@ -502,7 +578,7 @@ function resolveMissionTickRunnerModel(mission) {
502
578
  if (!engine || CLAUDE_FAMILY_RUNNER_ENGINES.has(engine)) {
503
579
  return resolveClaudeRunnerModel(mission);
504
580
  }
505
- return resolvePinnedRunnerModel(mission);
581
+ return runnerModelFor(engine, resolvePinnedRunnerModel(mission));
506
582
  }
507
583
 
508
584
  function exitMissionError(message, code = 1, asJson = false) {
@@ -8904,6 +8980,7 @@ function spawnGenericRunnerTick(mission, opts) {
8904
8980
  stop_reason: null,
8905
8981
  stderr: errStr,
8906
8982
  parse_errors: 0,
8983
+ run_usage: parseRunUsage({ stdout, stderr }),
8907
8984
  });
8908
8985
  });
8909
8986
 
@@ -9008,6 +9085,7 @@ function spawnClaudeTick(mission, opts) {
9008
9085
  let numTurns = null;
9009
9086
  let rateLimitInfo = null;
9010
9087
  let stopReason = null;
9088
+ let usage = null;
9011
9089
  let parseErrors = 0;
9012
9090
  let stderr = '';
9013
9091
  let timedOut = false;
@@ -9047,6 +9125,7 @@ function spawnClaudeTick(mission, opts) {
9047
9125
  if (typeof ev.duration_api_ms === 'number') durationApiMs = ev.duration_api_ms;
9048
9126
  if (typeof ev.num_turns === 'number') numTurns = ev.num_turns;
9049
9127
  if (ev.stop_reason) stopReason = ev.stop_reason;
9128
+ if (ev.usage && typeof ev.usage === 'object') usage = ev.usage;
9050
9129
  }
9051
9130
  } catch {
9052
9131
  parseErrors++;
@@ -9083,6 +9162,7 @@ function spawnClaudeTick(mission, opts) {
9083
9162
  rate_limit_info: rateLimitInfo,
9084
9163
  stderr: errStr,
9085
9164
  parse_errors: parseErrors,
9165
+ run_usage: parseRunUsage({ usage, cost_usd: costEstimate }),
9086
9166
  });
9087
9167
  });
9088
9168
 
@@ -9571,6 +9651,9 @@ async function executeMissionRunTicksPhase(context) {
9571
9651
  const tickStart = stampIso();
9572
9652
  const tickWorktreeBefore = gitWorktreeSnapshot(cwd);
9573
9653
  let result = { status: 'skipped', reason: 'unknown', tick_index: tickIdx, ran: false, started_at: tickStart };
9654
+ // Set when a roster engine really ran this tick: when it stopped and the
9655
+ // usage it printed. The run record is written after the verifier.
9656
+ let tickWorkerRun = null;
9574
9657
  let cachedStep = findCachedMissionStepReceipt(cwd, { missionId: mission.id, tickIndex: tickIdx });
9575
9658
  let cachedVerifierResult = cachedStep ? cachedStep.verifier_result : null;
9576
9659
  const tickSelection = resolveMissionTickRunner(runtimeMission, cwd);
@@ -9707,6 +9790,7 @@ async function executeMissionRunTicksPhase(context) {
9707
9790
  backend_unavailable: isTransientAtris2BackendError(turn.error) || undefined,
9708
9791
  receipt_text: String(turn.text || '').slice(0, 4000),
9709
9792
  };
9793
+ tickWorkerRun = { endedMs: Date.now(), usage: {} };
9710
9794
  if (controller.signal.aborted) { pauseReason = 'aborted-during-atris2'; break; }
9711
9795
  if (turn.error === 'not-logged-in') { pauseReason = 'auth-required'; break; }
9712
9796
  if (wallExpired) {
@@ -9724,6 +9808,17 @@ async function executeMissionRunTicksPhase(context) {
9724
9808
  const pingDrain = consumeMissionPings(mission, cwd);
9725
9809
  mission = pingDrain.mission;
9726
9810
  runtimeMission = runtimeView(mission);
9811
+ let tickPrep = null;
9812
+ let workerPrompt = prompt;
9813
+ if (tickEngineId) {
9814
+ ({ prompt: workerPrompt, prep: tickPrep } = await missionTickPrep(cwd, {
9815
+ mission,
9816
+ runtimeMission: tickRuntimeMission,
9817
+ job: tickSelection.roster_job || 'build',
9818
+ prompt,
9819
+ }));
9820
+ }
9821
+ const workerStartedAt = tickPrep ? stampIso() : tickStart;
9727
9822
  const restoreTickRunnerProfile = tickEngineId ? applyMissionRunnerProfile(tickEngineId) : () => {};
9728
9823
  let claudeResult;
9729
9824
  let sessionBusyRetried = false;
@@ -9734,7 +9829,7 @@ async function executeMissionRunTicksPhase(context) {
9734
9829
  tickRuntimeMission,
9735
9830
  (maxWallSeconds - ((Date.now() - startedAt) / 1000)) * 1000,
9736
9831
  ),
9737
- prompt,
9832
+ prompt: workerPrompt,
9738
9833
  model: resolveMissionTickRunnerModel(tickRuntimeMission),
9739
9834
  });
9740
9835
  try {
@@ -9754,6 +9849,7 @@ async function executeMissionRunTicksPhase(context) {
9754
9849
  } finally {
9755
9850
  restoreTickRunnerProfile();
9756
9851
  }
9852
+ tickWorkerRun = { endedMs: Date.now(), usage: (claudeResult && claudeResult.run_usage) || {}, prep: tickPrep, startedAt: workerStartedAt };
9757
9853
  result.claude = {
9758
9854
  ok: claudeResult.ok,
9759
9855
  reason: claudeResult.reason,
@@ -9860,6 +9956,20 @@ async function executeMissionRunTicksPhase(context) {
9860
9956
  }
9861
9957
  pauseReason = context.pauseReason;
9862
9958
  context.currentTick = null;
9959
+ if (tickEngineId && !cachedStep && tickWorkerRun) {
9960
+ recordMissionTickRosterRun(cwd, {
9961
+ mission,
9962
+ runtimeMission: tickRuntimeMission,
9963
+ engineId: tickEngineId,
9964
+ job: tickSelection.roster_job || 'build',
9965
+ result,
9966
+ verifierResult,
9967
+ startedAt: tickWorkerRun.startedAt || tickStart,
9968
+ endedMs: tickWorkerRun.endedMs,
9969
+ usage: tickWorkerRun.usage,
9970
+ prep: tickWorkerRun.prep || null,
9971
+ });
9972
+ }
9863
9973
  let receiptPath = cachedStep ? cachedStep.receipt_path : null;
9864
9974
 
9865
9975
  // Review-lane drain: always-on loops sweep the agent-safe review actions
@@ -11792,6 +11902,8 @@ module.exports = {
11792
11902
  missionTickTimeoutMs,
11793
11903
  engineFailureHealthStatus,
11794
11904
  recordMissionEngineTickOutcome,
11905
+ recordMissionTickRosterRun,
11906
+ missionTickPrep,
11795
11907
  tickMadeProgress,
11796
11908
  consecutiveNoProgressTicks,
11797
11909
  consecutiveIdenticalSummaryTicks,