atris 3.69.0 → 3.71.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 (79) hide show
  1. package/README.md +40 -1
  2. package/atris/skills/README.md +1 -0
  3. package/atris/skills/campaigns/SKILL.md +81 -0
  4. package/atris/skills/meta/SKILL.md +52 -8
  5. package/ax +226 -42
  6. package/bin/atris.js +26 -6
  7. package/commands/autoland.js +176 -7
  8. package/commands/autopilot.js +71 -11
  9. package/commands/campaign.js +759 -0
  10. package/commands/compile.js +19 -8
  11. package/commands/doctor.js +58 -0
  12. package/commands/dream.js +18 -3
  13. package/commands/engine.js +147 -5
  14. package/commands/improve.js +119 -9
  15. package/commands/init.js +18 -1
  16. package/commands/interview.js +15 -3
  17. package/commands/land.js +22 -6
  18. package/commands/link.js +1 -0
  19. package/commands/member.js +433 -99
  20. package/commands/mission.js +332 -41
  21. package/commands/report.js +42 -8
  22. package/commands/run.js +9 -2
  23. package/commands/scout.js +9 -1
  24. package/commands/task.js +132 -16
  25. package/commands/team.js +212 -4
  26. package/commands/unknowns.js +15 -1
  27. package/commands/verify.js +46 -0
  28. package/commands/worktree.js +148 -18
  29. package/commands/write.js +12 -1
  30. package/commands/youtube.js +17 -2
  31. package/lib/accept-verify-gate.js +1 -0
  32. package/lib/auto-accept-certified.js +1 -0
  33. package/lib/autoland.js +13 -4
  34. package/lib/campaign-cadence.js +131 -0
  35. package/lib/campaign-core.js +330 -0
  36. package/lib/campaign-holdout.js +65 -0
  37. package/lib/campaign-loop.js +242 -0
  38. package/lib/campaign-missions.js +237 -0
  39. package/lib/campaign-new.js +251 -0
  40. package/lib/campaign-protect.js +75 -0
  41. package/lib/campaign-reading.js +147 -0
  42. package/lib/campaign-schema.js +196 -0
  43. package/lib/campaign-store.js +380 -0
  44. package/lib/campaign-sync.js +302 -0
  45. package/lib/campaign-trace.js +300 -0
  46. package/lib/conductor-artifacts.js +15 -1
  47. package/lib/daily-log.js +11 -1
  48. package/lib/dispatch-scout.js +18 -0
  49. package/lib/engine-ask.js +35 -4
  50. package/lib/engine-registry.js +2 -0
  51. package/lib/engine-resource.js +518 -0
  52. package/lib/engine-validate.js +189 -9
  53. package/lib/fleet.js +125 -16
  54. package/lib/init-commit.js +80 -0
  55. package/lib/intents.js +8 -0
  56. package/lib/known-commands.js +1 -1
  57. package/lib/land-green.js +58 -7
  58. package/lib/landing-proof.js +352 -0
  59. package/lib/member-autoland.js +479 -10
  60. package/lib/member-board.js +86 -10
  61. package/lib/member-map-fold.js +126 -0
  62. package/lib/member-map-inbox.js +80 -0
  63. package/lib/member-progress.js +48 -1
  64. package/lib/member-report.js +433 -0
  65. package/lib/member-scaffold.js +1 -2
  66. package/lib/member-self-next-job.js +97 -0
  67. package/lib/member-switches.js +7 -0
  68. package/lib/member-task-pick.js +41 -14
  69. package/lib/member-up.js +249 -16
  70. package/lib/roster-prep.js +25 -2
  71. package/lib/runner-command.js +5 -0
  72. package/lib/task-db.js +63 -11
  73. package/lib/task-explanation.js +7 -0
  74. package/lib/task-list-keeper.js +8 -1
  75. package/lib/task-receipt.js +6 -1
  76. package/mcp/atris-mcp/index.mjs +212 -10
  77. package/package.json +2 -1
  78. package/scripts/det/engine-reading.js +37 -0
  79. package/scripts/det/ytnotes +29 -5
@@ -51,8 +51,7 @@ function ensureMemberLog(memberDir, { name, role, description, source = 'cli' }
51
51
  description ? `- mission: ${description}` : '',
52
52
  `- source: ${source}`,
53
53
  '- status: ready_for_room',
54
- '',
55
- ].filter(Boolean).join('\n');
54
+ ].filter(Boolean).join('\n') + '\n';
56
55
  fs.writeFileSync(logPath, content, 'utf8');
57
56
  return logPath;
58
57
  }
@@ -567,7 +566,16 @@ function memberLatestMission(name, missionMap = memberRunMissionMap()) {
567
566
 
568
567
  function memberLiveMission(name, missionMap = memberRunMissionMap()) {
569
568
  return memberOwnedMissions(name, missionMap)
570
- .find((mission) => MEMBER_LIVE_MISSION_STATUSES.has(String(mission?.status || '').toLowerCase())) || null;
569
+ .find((mission) => {
570
+ if (!MEMBER_LIVE_MISSION_STATUSES.has(String(mission?.status || '').toLowerCase())) return false;
571
+ // A closed window can leave a running row behind. Only a known dead
572
+ // process proves it is free; missing process records still hold it.
573
+ try {
574
+ const root = mission.worktree_root || mission.worktree?.path || process.cwd();
575
+ const health = require('./mission').missionDriverHealth(mission, root);
576
+ return !health.pid || health.alive;
577
+ } catch { return true; }
578
+ }) || null;
571
579
  }
572
580
 
573
581
  function missionShortName(mission, max = 36) {
@@ -915,6 +923,20 @@ function memberRunBudgetSeconds(args = []) {
915
923
  return null;
916
924
  }
917
925
 
926
+ // A job with no time given gets 15 minutes, or the roster's limit for this
927
+ // member's engine plus ten minutes for the review, whichever is longer.
928
+ function memberRunDefaultWallSeconds(name, cwd = process.cwd()) {
929
+ let cap = 0;
930
+ try {
931
+ const picked = require('../lib/member-engine').memberRosterEngine(name, cwd);
932
+ cap = Number(picked && picked.engine && picked.engine.roster_max_seconds) || 0;
933
+ } catch {}
934
+ // No roster cap means the tick runs on the default tick cap, so the run
935
+ // needs that much plus review time, or the wall kills it first.
936
+ const tickSeconds = cap || require('./mission').missionTickTimeoutMs({}, Infinity) / 1000;
937
+ return Math.max(900, tickSeconds + 600);
938
+ }
939
+
918
940
  function pushFlagValue(out, args, name) {
919
941
  const value = readFlag(args, name, '');
920
942
  if (value) out.push(name, String(value));
@@ -1040,7 +1062,7 @@ function memberRunCli(args, cwd, { live = false, missionId = '' } = {}) {
1040
1062
  : null;
1041
1063
  const receipt = receiptName ? readJson(path.join(runsDir, receiptName), null) : null;
1042
1064
  return { status: result.status, payload: receipt?.result?.kind === 'mission_run_summary'
1043
- ? { ran_ticks: receipt.result.ran_ticks } : null, stderr: '', errorReported: result.status !== 0 };
1065
+ ? { ran_ticks: receipt.result.ran_ticks, ticks: receipt.result.ticks } : null, stderr: '', errorReported: result.status !== 0 };
1044
1066
  }
1045
1067
  let payload = null;
1046
1068
  try {
@@ -1235,7 +1257,16 @@ function memberRun(name, ...args) {
1235
1257
  return memberRunWith(name, args);
1236
1258
  }
1237
1259
 
1238
- function memberRunWith(name, args, { quiet = false } = {}) {
1260
+ // One plain line for why the reviewing engine refused the work.
1261
+ function reviewRefusalLine(refusal) {
1262
+ if (refusal.timed_out) return 'the reviewer ran out of time';
1263
+ const text = String(refusal.output || refusal.reason || '');
1264
+ const verdict = text.match(/VERDICT:\s*FAIL\s*[;:]\s*(.+)/i);
1265
+ const line = verdict ? verdict[1] : (text.split(/\r?\n/).find((l) => l.trim()) || refusal.reason || 'no reason given');
1266
+ return line.trim().slice(0, 200);
1267
+ }
1268
+
1269
+ function memberRunWith(name, args, { quiet = false, closeTaskIds = [] } = {}) {
1239
1270
  if (!name || name === '--help' || name === '-h' || hasFlag(args, '--help') || hasFlag(args, '-h')) {
1240
1271
  console.log('usage: atris member run <name> ["mission text"] [--verify "<cmd>"] [--minutes N|--hours N] [--json]');
1241
1272
  console.log('pick for me: atris member run "fix the flaky login test" (the best member takes it and runs it)');
@@ -1314,6 +1345,7 @@ function memberRunWith(name, args, { quiet = false } = {}) {
1314
1345
  console.log(`watch: tail -f ${progressDir}/${String(name).replace(/[^A-Za-z0-9._-]/g, '_')}.log`);
1315
1346
  console.log('board: atris member board --watch');
1316
1347
  console.log('stop: atris member stop <name>');
1348
+ console.log(`where work goes: ${require('../lib/member-autoland').landingDestination(mission.worktree?.path || process.cwd())}`);
1317
1349
  }
1318
1350
 
1319
1351
  const cwd = mission.worktree?.path || process.cwd();
@@ -1338,7 +1370,7 @@ function memberRunWith(name, args, { quiet = false } = {}) {
1338
1370
  if (!readFlag(runArgs, '--max-ticks', '')) {
1339
1371
  runArgs.push('--max-ticks', budgetSeconds ? String(Math.max(4, Math.ceil(budgetSeconds / 300))) : '1');
1340
1372
  }
1341
- if (!readFlag(runArgs, '--max-wall', '')) runArgs.push('--max-wall', String(budgetSeconds || 900));
1373
+ if (!readFlag(runArgs, '--max-wall', '')) runArgs.push('--max-wall', String(budgetSeconds || memberRunDefaultWallSeconds(name, cwd)));
1342
1374
  const caller = callerRuntime(args);
1343
1375
  const runnerSwap = memberRunRunnerSwap(mission, args, caller);
1344
1376
  if (runnerSwap) runArgs.push('--engine', runnerSwap.to);
@@ -1351,7 +1383,12 @@ function memberRunWith(name, args, { quiet = false } = {}) {
1351
1383
  const waitingOnNativeGoal = caller === 'codex' && payload.requires_native_goal_start === true
1352
1384
  && memberRunUsesCallerSession(finalMission.runner);
1353
1385
  const noWorkStepExpected = hasFlag(runArgs, '--detach') || hasFlag(runArgs, '--dry-run');
1354
- const ok = (run.status === 0 && payload.ok !== false && (started || noWorkStepExpected)) || waitingOnNativeGoal;
1386
+ // The work ran but its check failed: the run failed, even though a work
1387
+ // step happened. Scripts and the caller read this from the exit code.
1388
+ const lastRanTick = [...(Array.isArray(payload.ticks) ? payload.ticks : [])].reverse().find((tick) => tick && tick.ran === true);
1389
+ const checkFailed = started && Boolean(String(finalMission.verifier || '').trim())
1390
+ && lastRanTick?.verifier_passed === false && finalMission.status !== 'complete';
1391
+ const ok = ((run.status === 0 && payload.ok !== false && (started || noWorkStepExpected)) || waitingOnNativeGoal) && !checkFailed;
1355
1392
  const reviewAsk = memberRunReviewAsk(name, payload, finalMission, cwd);
1356
1393
  const result = { ...payload, ok, started, state: finalMission.status, mission_id: mission.id,
1357
1394
  mission: finalMission, node_modules_linked: nodeModulesLinked, node_modules_source: nodeModulesSource,
@@ -1382,10 +1419,16 @@ function memberRunWith(name, args, { quiet = false } = {}) {
1382
1419
  const summary = ticks.map((tick) => tick?.summary || tick?.claude?.summary).filter(Boolean).pop() || '';
1383
1420
  const workerEngine = ticks.map((tick) => tick?.engine_id).filter(Boolean).pop()
1384
1421
  || runnerSwap?.to || readFlag(args, '--runner', '') || '';
1385
- result.autoland = require('../lib/member-autoland').autolandMemberRun({ mission: finalMission, cwd, summary, workerEngine });
1422
+ result.autoland = require('../lib/member-autoland').autolandMemberRun({ mission: finalMission, cwd, summary, workerEngine, closeTaskIds });
1386
1423
  if (!asJson && !quiet) console.log(result.autoland.line);
1387
1424
  }
1388
- if (!ok) {
1425
+ if (checkFailed) {
1426
+ result.check_failed = true;
1427
+ const refusal = lastRanTick?.referee_result?.passed === false ? lastRanTick.referee_result : null;
1428
+ result.error = refusal
1429
+ ? `turned down in review: ${reviewRefusalLine(refusal)}`
1430
+ : `the work ran but its check failed: ${finalMission.verifier || 'completion check'}`;
1431
+ } else if (!ok) {
1389
1432
  result.error = result.ask || payload.detail || payload.error || payload.reason || payload.next_action
1390
1433
  || run.stderr || 'mission did not run a work step';
1391
1434
  }
@@ -3313,6 +3356,19 @@ function dateFromLogPath(relativePath) {
3313
3356
  return match ? match[1] : null;
3314
3357
  }
3315
3358
 
3359
+ // Mission driver logs, goals files, and the wiki log have no date in their
3360
+ // name; without one they skipped the 7-day window and months-old lines kept
3361
+ // counting as this week's pain. The last write date stands in for them.
3362
+ function logDateForScan(relativePath, filePath) {
3363
+ const named = dateFromLogPath(relativePath);
3364
+ if (named) return named;
3365
+ try {
3366
+ return fs.statSync(filePath).mtime.toISOString().slice(0, 10);
3367
+ } catch {
3368
+ return null;
3369
+ }
3370
+ }
3371
+
3316
3372
  function compareLogEvidenceRecentFirst(a, b) {
3317
3373
  const dateA = a?.date || '';
3318
3374
  const dateB = b?.date || '';
@@ -3377,7 +3433,11 @@ function isAutoImproverGeneratedLogLine(line, relativePath = '') {
3377
3433
  if (/^[-*]?\s*Next tick will verify failed, halting\.?$/i.test(text)) return true;
3378
3434
  if (/^##\s+\d{1,2}:\d{2}\s+·\s+Task failed$/i.test(text)) return true;
3379
3435
  if (/^[-*]?\s*(status|action|state|verifier)\s*:\s*(failed|blocked)\s*$/i.test(text)) return true;
3380
- if (/^[-*]?\s*(title|proof):\s*/i.test(text)) return true;
3436
+ // Mission lines state a goal; their words ("leftover error text", "done failed") are not failures.
3437
+ // Goal files say the same in JSON ("stop_rule": "Stop if ... risk is unclear").
3438
+ if (/^[-*]?\s*"?(title|proof|proof_target|mission|objective|acceptance|stop_rule)"?\s*:\s*/i.test(text)) return true;
3439
+ // Driver logs print "error": null on every clean run.
3440
+ if (/^"?(error|failure)s?"?\s*:\s*(null|false|""|\[\]|\{\})\s*,?$/i.test(text)) return true;
3381
3441
  if (String(relativePath || '').startsWith('atris/team/auto-improver/logs/') && /^-\s*summary:\s*/i.test(text)) return true;
3382
3442
  if (/\bauto[- ]improver\b/i.test(text) && /\b(dogfood|receipt|prevented|pain_)\b/i.test(text)) return true;
3383
3443
  if (/\bauto_improver\b/i.test(text)) return true;
@@ -3395,7 +3455,9 @@ function normalizeFailurePattern(line) {
3395
3455
  .trim());
3396
3456
  }
3397
3457
 
3398
- function failureCoveredByPassLesson(line, passLessonText = '') {
3458
+ // Callers scanning many lines pass passLessonLower so the lessons file is
3459
+ // lowercased once per scan, not once per log line.
3460
+ function failureCoveredByPassLesson(line, passLessonText = '', passLessonLower = passLessonText.toLowerCase()) {
3399
3461
  const text = String(line || '');
3400
3462
  if (!text || !passLessonText) return false;
3401
3463
  if (/spawnSync\s+\/bin\/sh\s+ETIMEDOUT/i.test(text)) {
@@ -3411,23 +3473,59 @@ function failureCoveredByPassLesson(line, passLessonText = '') {
3411
3473
  const bare = pattern.replace(/^#+\s*[#:.]*\s*·\s*/, '').trim();
3412
3474
  for (const candidate of [pattern, bare]) {
3413
3475
  if (candidate && candidate.length >= 12
3414
- && passLessonText.toLowerCase().includes(candidate.toLowerCase())) {
3476
+ && passLessonLower.includes(candidate.toLowerCase())) {
3415
3477
  return true;
3416
3478
  }
3417
3479
  }
3418
3480
  return false;
3419
3481
  }
3420
3482
 
3483
+ // Runtime logs the repeat-failure scan reads, listed by where they live
3484
+ // instead of one capped walk of atris/team. That folder holds hundreds of
3485
+ // skill and mission files, and a 220-file walk ran out before it reached
3486
+ // most members' logs, so this week's repeats counted as zero. Listing paths
3487
+ // is cheap; the 7-day filter below decides what actually gets read.
3488
+ function listAutoImproverRuntimeLogs(root) {
3489
+ const listAll = (dir) => (fs.existsSync(dir) ? listFilesBounded(dir, { maxFiles: 5000 }) : []);
3490
+ const childDirs = (dir) => {
3491
+ try {
3492
+ return fs.readdirSync(dir, { withFileTypes: true })
3493
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
3494
+ .map((entry) => entry.name)
3495
+ .sort();
3496
+ } catch {
3497
+ return [];
3498
+ }
3499
+ };
3500
+ const files = [];
3501
+ const logsRoot = path.join(root, 'atris', 'logs');
3502
+ try {
3503
+ for (const entry of fs.readdirSync(logsRoot, { withFileTypes: true })) {
3504
+ if (entry.isFile()) files.push(path.join(logsRoot, entry.name));
3505
+ }
3506
+ } catch {}
3507
+ for (const name of childDirs(logsRoot)) {
3508
+ if (name !== 'archive') files.push(...listAll(path.join(logsRoot, name)));
3509
+ }
3510
+ const teamRoot = path.join(root, 'atris', 'team');
3511
+ for (const member of childDirs(teamRoot)) {
3512
+ for (const name of ['goals.md', 'goals.json']) {
3513
+ const goalsPath = path.join(teamRoot, member, name);
3514
+ if (fs.existsSync(goalsPath)) files.push(goalsPath);
3515
+ }
3516
+ files.push(...listAll(path.join(teamRoot, member, 'logs')));
3517
+ }
3518
+ const wikiLog = path.join(root, 'atris', 'wiki', 'log.md');
3519
+ if (fs.existsSync(wikiLog)) files.push(wikiLog);
3520
+ return files;
3521
+ }
3522
+
3421
3523
  function collectAutoImproverLogSignals(root) {
3422
- const roots = [
3423
- path.join(root, 'atris', 'logs'),
3424
- path.join(root, 'atris', 'team'),
3425
- path.join(root, 'atris', 'wiki'),
3426
- ].filter((candidate) => fs.existsSync(candidate));
3427
3524
  const passLessonText = safeReadText(path.join(root, 'atris', 'lessons.md'), 500000)
3428
3525
  .split(/\r?\n/)
3429
3526
  .filter((line) => /\s+pass\s*[-\u2014:,]/.test(line))
3430
3527
  .join('\n');
3528
+ const passLessonLower = passLessonText.toLowerCase();
3431
3529
  const failureRegex = /\b(error|failed|failure|blocked|timeout|regression|crash|missing proof|naraka|suffering)\b/i;
3432
3530
  const unclearRegex = /\b(tbd|unclear|unknown|needs user|needs owner|needs proof|no next|blocked)\b/i;
3433
3531
  const counts = new Map();
@@ -3435,66 +3533,67 @@ function collectAutoImproverLogSignals(root) {
3435
3533
  let filesScanned = 0;
3436
3534
  let linesScanned = 0;
3437
3535
  let unclearNextActions = 0;
3438
- for (const scanRoot of roots) {
3439
- for (const filePath of listFilesBounded(scanRoot)) {
3440
- const relative = repoRelative(root, filePath);
3441
- const logDate = dateFromLogPath(relative);
3442
- if (relative.startsWith('atris/logs/archive/')) continue;
3443
- const isRuntimeLog = relative.startsWith('atris/logs/')
3444
- || /^atris\/team\/[^/]+\/logs\//.test(relative)
3445
- || /^atris\/team\/[^/]+\/goals\.(md|json)$/.test(relative)
3446
- || relative === 'atris/wiki/log.md';
3447
- if (!isRuntimeLog) continue;
3448
- // Only scan logs from the last 7 days. Historical log lines keep
3449
- // triggering false-positive findings long after their root cause is
3450
- // fixed (e.g. "goal-from-mission blocked" from July when MISSION.md
3451
- // was repaired in August). Old evidence stays on disk for audits;
3452
- // it just stops feeding the pain score, so pain can actually drop.
3453
- if (logDate && isRuntimeLog) {
3454
- const logAgeDays = (Date.now() - new Date(logDate + 'T12:00:00').getTime()) / 864e5;
3455
- if (logAgeDays > 7) continue;
3456
- }
3457
- const text = safeReadText(filePath);
3458
- if (!text) continue;
3459
- filesScanned += 1;
3460
- const allLines = text.split(/\r?\n/);
3461
- const lineOffset = Math.max(0, allLines.length - 500);
3462
- const lines = allLines.slice(-500);
3463
- linesScanned += lines.length;
3464
- for (let index = 0; index < lines.length; index += 1) {
3465
- const sourceLine = lineOffset + index + 1;
3466
- const line = lines[index];
3467
- if (isAutoImproverGeneratedLogLine(line, relative)) continue;
3468
- // Declared verification receipts ("check: <command> ...") describe what
3469
- // was verified, not what broke. Wiki upkeep sweeps write one per page,
3470
- // so counting them as failures spawns bogus recurring-pattern tasks
3471
- // (CLI-199 came from 13 such lines in atris/wiki/log.md).
3472
- if (/^\s*[-*]?\s*check:\s/i.test(line)) continue;
3473
- if (/\b(errors?|fail(?:ed|ures?)|blocked|timeouts?)\s*:\s*0\b/i.test(line)) continue;
3474
- if (/\bblocked\s*(?:->|→|to)\s*ready\b/i.test(line)) continue;
3475
- if (failureCoveredByPassLesson(line, passLessonText)) continue;
3476
- if (unclearRegex.test(line)) {
3477
- unclearNextActions += 1;
3478
- unclearActions.push({
3479
- path: relative,
3480
- date: logDate,
3481
- line: sourceLine,
3482
- text: compactSentence(line, 180),
3483
- });
3484
- }
3485
- if (!failureRegex.test(line)) continue;
3486
- const pattern = normalizeFailurePattern(line);
3487
- if (!pattern) continue;
3488
- const existing = counts.get(pattern) || { pattern, count: 0, evidence: [] };
3489
- existing.count += 1;
3490
- existing.evidence.push({
3536
+ for (const filePath of listAutoImproverRuntimeLogs(root)) {
3537
+ const relative = repoRelative(root, filePath);
3538
+ const logDate = logDateForScan(relative, filePath);
3539
+ if (relative.startsWith('atris/logs/archive/')) continue;
3540
+ const isRuntimeLog = relative.startsWith('atris/logs/')
3541
+ || /^atris\/team\/[^/]+\/logs\//.test(relative)
3542
+ || /^atris\/team\/[^/]+\/goals\.(md|json)$/.test(relative)
3543
+ || relative === 'atris/wiki/log.md';
3544
+ if (!isRuntimeLog) continue;
3545
+ // Only scan logs from the last 7 days. Historical log lines keep
3546
+ // triggering false-positive findings long after their root cause is
3547
+ // fixed (e.g. "goal-from-mission blocked" from July when MISSION.md
3548
+ // was repaired in August). Old evidence stays on disk for audits;
3549
+ // it just stops feeding the pain score, so pain can actually drop.
3550
+ if (logDate && isRuntimeLog) {
3551
+ const logAgeDays = (Date.now() - new Date(logDate + 'T12:00:00').getTime()) / 864e5;
3552
+ if (logAgeDays > 7) continue;
3553
+ }
3554
+ const text = safeReadText(filePath);
3555
+ if (!text) continue;
3556
+ filesScanned += 1;
3557
+ const allLines = text.split(/\r?\n/);
3558
+ const lineOffset = Math.max(0, allLines.length - 500);
3559
+ const lines = allLines.slice(-500);
3560
+ linesScanned += lines.length;
3561
+ for (let index = 0; index < lines.length; index += 1) {
3562
+ const sourceLine = lineOffset + index + 1;
3563
+ const line = lines[index];
3564
+ if (isAutoImproverGeneratedLogLine(line, relative)) continue;
3565
+ // Declared verification receipts ("check: <command> ...") describe what
3566
+ // was verified, not what broke. Wiki upkeep sweeps write one per page,
3567
+ // so counting them as failures spawns bogus recurring-pattern tasks
3568
+ // (CLI-199 came from 13 such lines in atris/wiki/log.md).
3569
+ if (/^\s*[-*]?\s*check:\s/i.test(line)) continue;
3570
+ if (/\b(errors?|fail(?:ed|ures?)|blocked|timeouts?)\s*:\s*0\b/i.test(line)) continue;
3571
+ if (/\bblocked\s*(?:->|→|to)\s*ready\b/i.test(line)) continue;
3572
+ if (failureCoveredByPassLesson(line, passLessonText, passLessonLower)) continue;
3573
+ // A file name is not a next step ("receipt: atris/runs/...-unknown-word-...json"),
3574
+ // and "learned:" / "why:" lines explain the past, so neither counts as unclear.
3575
+ const unclearText = line.replace(/[\w.@~-]*\/[\w./@~-]*\.\w{1,5}\b/g, ' ');
3576
+ if (!/^\s*[-*]?\s*(learned|why)\s*:/i.test(line) && unclearRegex.test(unclearText)) {
3577
+ unclearNextActions += 1;
3578
+ unclearActions.push({
3491
3579
  path: relative,
3492
3580
  date: logDate,
3493
3581
  line: sourceLine,
3494
3582
  text: compactSentence(line, 180),
3495
3583
  });
3496
- counts.set(pattern, existing);
3497
3584
  }
3585
+ if (!failureRegex.test(line)) continue;
3586
+ const pattern = normalizeFailurePattern(line);
3587
+ if (!pattern) continue;
3588
+ const existing = counts.get(pattern) || { pattern, count: 0, evidence: [] };
3589
+ existing.count += 1;
3590
+ existing.evidence.push({
3591
+ path: relative,
3592
+ date: logDate,
3593
+ line: sourceLine,
3594
+ text: compactSentence(line, 180),
3595
+ });
3596
+ counts.set(pattern, existing);
3498
3597
  }
3499
3598
  }
3500
3599
  const repeated = [...counts.values()]
@@ -4256,7 +4355,10 @@ function parseFrontmatter(content) {
4256
4355
  } else if (val.startsWith('[') && val.endsWith(']')) {
4257
4356
  result[currentKey] = val.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, ''));
4258
4357
  } else {
4259
- result[currentKey] = val.replace(/^["']|["']$/g, '');
4358
+ // Unwrap only a matching outer pair: `verify: node -e "process.exit(0)"`
4359
+ // must keep its closing quote or the check is a shell syntax error.
4360
+ const quoted = val.match(/^(["'])([\s\S]*)\1$/);
4361
+ result[currentKey] = quoted ? quoted[2] : val;
4260
4362
  }
4261
4363
  }
4262
4364
  }
@@ -8975,6 +9077,26 @@ async function memberLoopWakeTick(name, run, index, tickStartedAt) {
8975
9077
  const MEMBER_LOOP_IDLE_BREAK_THRESHOLD = 2;
8976
9078
  const MEMBER_LOOP_IDENTICAL_ASK_BREAK_THRESHOLD = 2;
8977
9079
 
9080
+ // A loop kept running for days would keep old code after an atris upgrade. Read the
9081
+ // installed version from disk, not the cached require, so a new install is seen.
9082
+ function memberLoopInstalledVersion() {
9083
+ try {
9084
+ return JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version || null;
9085
+ } catch {
9086
+ return null;
9087
+ }
9088
+ }
9089
+
9090
+ // Start the same loop again on the new code. The lease is already released, so the
9091
+ // fresh process takes it over; this one exits with the fresh run's status.
9092
+ function memberLoopRestartOnNewVersion(upgrade) {
9093
+ const child = spawnSync(process.execPath, process.argv.slice(1), {
9094
+ stdio: 'inherit',
9095
+ env: { ...process.env, ATRIS_MEMBER_LOOP_RESTARTED_FROM: upgrade.from },
9096
+ });
9097
+ process.exit(child.status == null ? 1 : child.status);
9098
+ }
9099
+
8978
9100
  // Loop phase: control. Hold the lease, honor the stop file, pace ticks by interval, and
8979
9101
  // break early when the member is idling instead of faking activity.
8980
9102
  async function memberLoopRunTicks(name, paths, lease, run) {
@@ -8985,6 +9107,13 @@ async function memberLoopRunTicks(name, paths, lease, run) {
8985
9107
  state.stopped = true;
8986
9108
  break;
8987
9109
  }
9110
+ if (index > 0 && plan.forever && state.startVersion) {
9111
+ const installed = memberLoopInstalledVersion();
9112
+ if (installed && installed !== state.startVersion) {
9113
+ state.upgrade = { from: state.startVersion, to: installed };
9114
+ break;
9115
+ }
9116
+ }
8988
9117
  refreshMemberLoopLease(paths, lease.lease, plan.ttlMs);
8989
9118
  const tickStartedAt = stampIso();
8990
9119
  try {
@@ -9038,7 +9167,7 @@ function memberLoopRecordResult(name, paths, lease, run) {
9038
9167
  const { options, plan, state } = run;
9039
9168
  const { execute, aliveMode, asJson } = options;
9040
9169
  const { hourly, forever, runId, ticks, intervalMs, durationMs, startedAt } = plan;
9041
- const { tickResults, decisions, stopped, failed, earlyExit, tickLogPath } = state;
9170
+ const { tickResults, decisions, stopped, failed, earlyExit, tickLogPath, upgrade } = state;
9042
9171
  const finishedAt = stampIso();
9043
9172
  const plannedOnly = aliveMode && tickResults.length > 0 && tickResults.every((tick) => tick.decision === 'planned');
9044
9173
  const summary = {
@@ -9047,7 +9176,7 @@ function memberLoopRecordResult(name, paths, lease, run) {
9047
9176
  schema: aliveMode ? 'atris.member_alive.v1' : 'atris.member_loop.v1',
9048
9177
  member: name,
9049
9178
  alive: aliveMode,
9050
- status: failed ? 'failed' : stopped ? 'stopped' : plannedOnly ? 'planned' : earlyExit ? (earlyExit.blocked_on_human ? 'blocked_on_human' : 'idle') : 'completed',
9179
+ status: failed ? 'failed' : stopped ? 'stopped' : upgrade ? 'restarting_on_new_version' : plannedOnly ? 'planned' : earlyExit ? (earlyExit.blocked_on_human ? 'blocked_on_human' : 'idle') : 'completed',
9051
9180
  mode: execute ? 'execute' : 'dry_run',
9052
9181
  cadence: hourly ? 'hourly' : 'custom',
9053
9182
  forever,
@@ -9063,6 +9192,8 @@ function memberLoopRecordResult(name, paths, lease, run) {
9063
9192
  duration_ms_actual: Date.parse(finishedAt) - Date.parse(startedAt),
9064
9193
  decisions,
9065
9194
  early_exit: earlyExit,
9195
+ upgrade: upgrade || null,
9196
+ restarted_from_version: process.env.ATRIS_MEMBER_LOOP_RESTARTED_FROM || null,
9066
9197
  stop: earlyExit?.stop || null,
9067
9198
  blocked_on_human: earlyExit?.blocked_on_human === true,
9068
9199
  needs_user: earlyExit?.needs_user === true,
@@ -9087,6 +9218,7 @@ function memberLoopRecordResult(name, paths, lease, run) {
9087
9218
  `Status: ${payload.status}`,
9088
9219
  `Cadence: ${payload.cadence}${payload.forever ? ' forever' : ''}`,
9089
9220
  `Ticks: ${payload.ticks}/${payload.ticks_requested}`,
9221
+ ...(upgrade ? [`Upgrade: atris ${upgrade.from} -> ${upgrade.to}, restarting on the new version`] : []),
9090
9222
  `Decisions: ${Object.entries(decisions).map(([key, count]) => `${key} x${count}`).join(', ') || 'none'}`,
9091
9223
  `Stop: ${payload.stop_command}`,
9092
9224
  `Receipt: ${path.relative(process.cwd(), receiptPath)}`,
@@ -9152,11 +9284,14 @@ async function memberLoop(name, ...args) {
9152
9284
  lastAskKey: null,
9153
9285
  consecutiveIdenticalAsk: 0,
9154
9286
  tickLogPath,
9287
+ startVersion: memberLoopInstalledVersion(),
9288
+ upgrade: null,
9155
9289
  };
9156
9290
 
9157
9291
  const run = { options, plan, state };
9158
9292
  await memberLoopRunTicks(name, paths, lease, run);
9159
9293
  memberLoopRecordResult(name, paths, lease, run);
9294
+ if (state.upgrade) memberLoopRestartOnNewVersion(state.upgrade);
9160
9295
  }
9161
9296
 
9162
9297
  async function memberTick(name, ...args) {
@@ -9823,10 +9958,26 @@ const EXPERIMENT_STATUSES = new Set(['experimental', 'one-off']);
9823
9958
 
9824
9959
  function ideaRuleFor(slug, record, text) {
9825
9960
  const own = IDEA_ARCHETYPE_RULES.find((rule) => rule.slugs.includes(slug));
9826
- if (own && own.pattern.test(text)) return own;
9961
+ if (own && own.pattern.test(text)) return { rule: own, own: true };
9827
9962
  const { autoJobForMember } = require('../lib/member-engine');
9828
9963
  const archetype = ARCHETYPE_FOR_JOB[autoJobForMember(slug, record.role)];
9829
- return IDEA_ARCHETYPE_RULES.find((rule) => rule.slugs.includes(archetype)) || null;
9964
+ const rule = IDEA_ARCHETYPE_RULES.find((r) => r.slugs.includes(archetype)) || null;
9965
+ return { rule, own: Boolean(own && rule === own) };
9966
+ }
9967
+
9968
+ // The request's job is the first rule it matches; the list runs from
9969
+ // specific jobs to the general build job, so "where is the code" is a
9970
+ // navigator job even though "code" is also a build word.
9971
+ function ideaJobRule(text) {
9972
+ return IDEA_ARCHETYPE_RULES.find((rule) => rule.pattern.test(text)) || null;
9973
+ }
9974
+
9975
+ // 3 = named in the idea, 2 = the job is the member's own, 1 = the member
9976
+ // falls back to the job, 0 = not its job. Fit outranks shared words.
9977
+ function ideaJobFit(named, memberRule, jobRule) {
9978
+ if (named) return 3;
9979
+ if (!jobRule || !memberRule.rule || memberRule.rule !== jobRule) return 0;
9980
+ return memberRule.own ? 2 : 1;
9830
9981
  }
9831
9982
 
9832
9983
  // 0 = normal, 1 = experiment or one-off, 2 = parked, human, or odd-typed.
@@ -9838,10 +9989,12 @@ function dispatchTier(record) {
9838
9989
  return 0;
9839
9990
  }
9840
9991
 
9841
- // Lower tier wins first, then higher score, then name order for a stable pick.
9992
+ // Lower tier wins first, then job fit, then higher score, then name order
9993
+ // for a stable pick.
9842
9994
  function outranksDispatch(candidate, best) {
9843
9995
  if (!best) return true;
9844
9996
  if (candidate.tier !== best.tier) return candidate.tier < best.tier;
9997
+ if (candidate.fit !== best.fit) return candidate.fit > best.fit;
9845
9998
  if (candidate.score !== best.score) return candidate.score > best.score;
9846
9999
  return candidate.member.localeCompare(best.member) < 0;
9847
10000
  }
@@ -9875,6 +10028,7 @@ function pickMemberForIdea(ideaText, root = process.cwd(), preferredMember = '')
9875
10028
  .toLowerCase()
9876
10029
  .split(/[^a-z0-9]+/)
9877
10030
  .filter((w) => w.length >= 4);
10031
+ const jobRule = ideaJobRule(text);
9878
10032
 
9879
10033
  let best = null;
9880
10034
  for (const record of members) {
@@ -9886,10 +10040,11 @@ function pickMemberForIdea(ideaText, root = process.cwd(), preferredMember = '')
9886
10040
  let score = 0;
9887
10041
  let matchedReason = '';
9888
10042
 
9889
- const rule = ideaRuleFor(slug, record, text);
9890
- if (rule && rule.pattern.test(text)) {
10043
+ const memberRule = ideaRuleFor(slug, record, text);
10044
+ const fit = ideaJobFit(named, memberRule, jobRule);
10045
+ if (fit > 0 && memberRule.rule) {
9891
10046
  score += 30;
9892
- matchedReason = rule.reason;
10047
+ matchedReason = memberRule.rule.reason;
9893
10048
  }
9894
10049
 
9895
10050
  const missionFile = path.join(teamDir, slug, 'MISSION.md');
@@ -9920,6 +10075,7 @@ function pickMemberForIdea(ideaText, root = process.cwd(), preferredMember = '')
9920
10075
  reason: matchedReason,
9921
10076
  score,
9922
10077
  tier,
10078
+ fit,
9923
10079
  };
9924
10080
  if (outranksDispatch(candidate, best)) best = candidate;
9925
10081
  }
@@ -10412,6 +10568,29 @@ function isKnownMemberSlug(token, root = process.cwd()) {
10412
10568
  || fs.existsSync(path.join(root, 'atris', 'team', `${clean}.md`));
10413
10569
  }
10414
10570
 
10571
+ const MEMBER_SUBCOMMANDS = [
10572
+ 'list', 'roster', 'create', 'install', 'activate', 'upgrade', 'push', 'pull', 'goal',
10573
+ 'chat', 'tick', 'wake', 'run', 'result', 'go', 'work', 'up', 'steer', 'sleep', 'rest',
10574
+ 'room', 'day', 'standup', 'handoff', 'dispatch', 'feed', 'board', 'stop', 'card', 'ping',
10575
+ 'loop', 'alive', 'review', 'block', 'status', 'live', 'heartbeat', 'improvements',
10576
+ 'report', 'history', 'supervisor', 'export', 'archive', 'tidy', 'help',
10577
+ ];
10578
+
10579
+ // `atris member <word>` where the word is neither a member nor a subcommand.
10580
+ // Points at the nearest name and the team, and exits 1 without running anyone.
10581
+ function memberUnknownWord(word, root = process.cwd()) {
10582
+ const { suggestCommand } = require('../lib/known-commands');
10583
+ let names = [];
10584
+ try { names = findAllMembers(path.join(root, 'atris', 'team')).map((m) => m.name); } catch {}
10585
+ const near = suggestCommand(word, [...MEMBER_SUBCOMMANDS, ...names]);
10586
+ console.log(`No member or command named "${word}", so nothing started.`);
10587
+ if (near) console.log(`Did you mean: atris member ${near}`);
10588
+ if (names.length) console.log(`Your team: ${names.join(', ')}`);
10589
+ console.log('To give a member a job: atris member run "<job in a few words>"');
10590
+ process.exitCode = 1;
10591
+ return { ok: false, reason: 'unknown_word', word, suggestion: near || null };
10592
+ }
10593
+
10415
10594
  // The job a member does (build, review or search), read from the same team
10416
10595
  // roster that picks its engine.
10417
10596
  function memberGoJob(owner, root) {
@@ -10435,10 +10614,10 @@ function memberGoTask(owner, root, { claim, requireCheck = false }) {
10435
10614
  const job = memberGoJob(owner, root);
10436
10615
  const workspaceRoot = taskDb.workspaceRoot(root);
10437
10616
  if (claim) {
10438
- const got = picker.claimTaskForMember(taskDb, db, { workspaceRoot, member: owner, job, requireCheck });
10617
+ const got = picker.claimTaskForMember(taskDb, db, { workspaceRoot, member: owner, job, requireCheck, recoverClaimed: true });
10439
10618
  return got ? { ...got, job } : null;
10440
10619
  }
10441
- const task = picker.pickTaskForMember(taskDb, db, { workspaceRoot, job, requireCheck });
10620
+ const task = picker.pickTaskForMember(taskDb, db, { workspaceRoot, member: owner, job, requireCheck, recoverClaimed: true });
10442
10621
  return task ? { task, objective: picker.taskObjective(task), job } : null;
10443
10622
  } catch (error) {
10444
10623
  console.error(`could not take a task from the list: ${String(error && error.message || error).split('\n')[0]}`);
@@ -10460,15 +10639,59 @@ function memberGoFinishTask(owner, task, result, thrown = null) {
10460
10639
  if (!thrown && result && result.started && ['complete', 'ready'].includes(mission.status) && checked) {
10461
10640
  const proof = [`[verified] ${owner} ran mission ${result.mission_id} and its check passed.`,
10462
10641
  receipt ? `Receipt: ${receipt}` : ''].filter(Boolean).join('\n');
10463
- const ready = taskDb.readyTask(db, { id: task.id, actor: owner, proof });
10642
+ // The commit comes from the member's own work copy, not the main folder.
10643
+ const workRoot = (result.worktree && result.worktree.path) || (mission.worktree && mission.worktree.path) || undefined;
10644
+ const ready = taskDb.readyTask(db, { id: task.id, actor: owner, proof, workRoot });
10464
10645
  if (ready.ready !== false) return { id: task.id, title: task.title, outcome: 'review', receipt: receipt || null };
10646
+ // Its change already merged and the merge line closed the job.
10647
+ if (taskDb.getTask(db, task.id)?.status === 'done') return { id: task.id, title: task.title, outcome: 'done', receipt: receipt || null };
10465
10648
  }
10649
+ const ticks = Array.isArray(result && result.ticks) ? result.ticks : [];
10650
+ const lastReason = String((ticks[ticks.length - 1] || {}).reason || (ticks[ticks.length - 1] || {}).error || '');
10651
+ const outOfTime = /timeout|wall-exceeded/.test(lastReason);
10466
10652
  const why = thrown ? thrown.message
10653
+ : outOfTime ? 'ran out of time before the work finished'
10467
10654
  : (result && result.error) || (result && result.started ? 'the check did not pass' : 'the run did not start');
10468
10655
  const reason = String(why || 'the run did not finish').split(/\r?\n/)[0].trim().slice(0, 200);
10469
10656
  try { taskDb.noteTask(db, { id: task.id, actor: owner, content: `Released by ${owner}: ${reason}` }); } catch {}
10657
+ // Work staged on a protected surface waits for a person to review it; a
10658
+ // member picking the task again would only redo it and stop again.
10659
+ let held = false;
10660
+ const forReview = Boolean(!thrown && result && result.needs_review === true);
10661
+ if (forReview) {
10662
+ try {
10663
+ held = taskDb.tagTask(db, { id: task.id, actor: owner, add: ['needs-human'] }).tagged === true;
10664
+ if (held) taskDb.noteTask(db, { id: task.id, actor: owner, content: `Held for a person to review: ${reason}` });
10665
+ } catch {}
10666
+ }
10667
+ // Third failure in a row: hold the task for a person instead of letting
10668
+ // members pick it up again forever.
10669
+ if (!held && memberGoFailStreak(taskDb, db, task.id) >= MEMBER_GO_STRIKES) {
10670
+ try {
10671
+ held = taskDb.tagTask(db, { id: task.id, actor: owner, add: ['needs-human'] }).tagged === true;
10672
+ if (held) taskDb.noteTask(db, { id: task.id, actor: owner,
10673
+ content: `Held for a person: failed ${MEMBER_GO_STRIKES} times in a row. Last reason: ${reason}` });
10674
+ } catch {}
10675
+ }
10470
10676
  const released = taskDb.releaseTask(db, { id: task.id, actor: owner });
10471
- return { id: task.id, title: task.title, outcome: released.released ? 'released' : 'left_claimed', reason };
10677
+ const outcome = released.released ? (held ? 'needs_human' : 'released') : 'left_claimed';
10678
+ return { id: task.id, title: task.title, outcome, reason, ...(held && forReview ? { held_for: 'review' } : {}) };
10679
+ }
10680
+
10681
+ const MEMBER_GO_STRIKES = 3;
10682
+
10683
+ // Failed runs on a task since its last pass, review, reopen, or a person
10684
+ // clearing its hold.
10685
+ function memberGoFailStreak(taskDb, db, taskId) {
10686
+ let streak = 0;
10687
+ for (const event of taskDb.listTaskEvents(db, { taskId })) {
10688
+ const type = event.event_type;
10689
+ const payload = event.payload || {};
10690
+ if (type === 'message' && /^Released by \S+: /.test(String(payload.content || ''))) streak += 1;
10691
+ else if (['proof_ready', 'reviewed', 'reopened'].includes(type)) streak = 0;
10692
+ else if (type === 'task_tags_updated' && (payload.removed || []).includes('needs-human')) streak = 0;
10693
+ }
10694
+ return streak;
10472
10695
  }
10473
10696
 
10474
10697
  async function memberSteerOrDispatch(...rawArgs) {
@@ -10531,7 +10754,7 @@ async function memberSteerOrDispatchWith({ pullTask = false } = {}, rawArgs = []
10531
10754
  const missionSummary = memberMissionSummary(owner, missionMap, root);
10532
10755
  // A human or odd-typed member never takes a task: it would be claimed and
10533
10756
  // then stranded when the run refuses the member.
10534
- const pulled = pullTask && !prompt && !missionSummary.live && !memberRefusalFor(targetMember, paths.memberFile)
10757
+ const pulled = pullTask && !prompt && !missionSummary.live_mission && !memberRefusalFor(targetMember, paths.memberFile)
10535
10758
  ? memberGoTask(owner, root, { claim: !dryRun, requireCheck: needsCheck })
10536
10759
  : null;
10537
10760
  if (needsCheck && !pulled) {
@@ -10546,6 +10769,11 @@ async function memberSteerOrDispatchWith({ pullTask = false } = {}, rawArgs = []
10546
10769
  || goalPlane.goal?.title
10547
10770
  || extractMemberNorthStar(paths.memberDir, '');
10548
10771
  const pulledTask = pulled ? { id: pulled.task.id, title: pulled.task.title, job: pulled.job } : null;
10772
+ // The supervisor's preview can lose a race. Only the child that claimed
10773
+ // the task may announce which job it actually got.
10774
+ if (needsCheck && pulledTask && !dryRun && process.connected && process.send) {
10775
+ process.send({ type: 'member_task_claimed', member: owner, task: pulledTask }, () => {});
10776
+ }
10549
10777
  if (pulled) whyPicked = `${whyPicked} Took the oldest open ${pulled.job} task from the list.`.trim();
10550
10778
  const engineSeat = resolveMemberEngineSeat(owner, root);
10551
10779
  const { autoQueueMemberNextPush, closeMemberHandoffsForReceiver } = require('./mission');
@@ -10640,7 +10868,8 @@ async function memberSteerOrDispatchWith({ pullTask = false } = {}, rawArgs = []
10640
10868
  const runFlags = required ? [...stripKnownFlags(nextFlags, ['--verify'], []), '--verify', required] : nextFlags;
10641
10869
  let result = null;
10642
10870
  try {
10643
- result = await memberRunWith(owner, [effectivePrompt, '--ignite', ...runFlags], { quiet: asJson });
10871
+ // The job rides to the merge line so it closes itself when its change merges.
10872
+ result = await memberRunWith(owner, [effectivePrompt, '--ignite', ...runFlags], { quiet: asJson, closeTaskIds: [pulled.task.id] });
10644
10873
  } catch (error) {
10645
10874
  memberGoFinishTask(owner, pulled.task, null, error);
10646
10875
  throw error;
@@ -10651,6 +10880,9 @@ async function memberSteerOrDispatchWith({ pullTask = false } = {}, rawArgs = []
10651
10880
  ? { task, work_step: { summary: result?.work_step?.summary || null }, error: result?.error || null }
10652
10881
  : merged));
10653
10882
  else if (task.outcome === 'review') console.log(`Task "${task.title}" is ready for review.`);
10883
+ else if (task.outcome === 'done') console.log(`Task "${task.title}" merged and closed itself.`);
10884
+ else if (task.outcome === 'needs_human' && task.held_for === 'review') console.log(`Task "${task.title}" waits for a person to review it: ${task.reason}`);
10885
+ else if (task.outcome === 'needs_human') console.log(`Task "${task.title}" failed three times in a row and now waits for a person: ${task.reason}`);
10654
10886
  else console.log(`Task "${task.title}" went back on the list: ${task.reason}`);
10655
10887
  return merged;
10656
10888
  }
@@ -10723,14 +10955,42 @@ function memberUpPick(root, name, taken) {
10723
10955
  const { fittingTasks } = require('../lib/member-task-pick');
10724
10956
  const db = taskDb.open();
10725
10957
  const job = memberGoJob(name, root);
10726
- return fittingTasks(taskDb, db, { workspaceRoot: taskDb.workspaceRoot(root), job, requireCheck: true })
10958
+ return fittingTasks(taskDb, db, { workspaceRoot: taskDb.workspaceRoot(root), member: name, job, requireCheck: true, recoverClaimed: true })
10727
10959
  .find((task) => !taken.has(task.id)) || null;
10728
10960
  } catch {
10729
10961
  return null;
10730
10962
  }
10731
10963
  }
10732
10964
 
10733
- function memberUpSpawn(root, name, onExit) {
10965
+ // An empty job list: write the member's next job from its own mission.
10966
+ function memberUpWriteNextJob(root, name, { dryRun = false } = {}) {
10967
+ const taskDb = require('../lib/task-db');
10968
+ const { writeSelfNextJob } = require('../lib/member-self-next-job');
10969
+ const db = taskDb.open();
10970
+ return writeSelfNextJob(taskDb, db, {
10971
+ root,
10972
+ workspaceRoot: taskDb.workspaceRoot(root),
10973
+ member: name,
10974
+ job: memberGoJob(name, root),
10975
+ dryRun,
10976
+ });
10977
+ }
10978
+
10979
+ // Every open task a member could start, with the job it needs.
10980
+ function memberUpWaiting(root) {
10981
+ try {
10982
+ const taskDb = require('../lib/task-db');
10983
+ const { fittingTasks } = require('../lib/member-task-pick');
10984
+ const db = taskDb.open();
10985
+ const workspaceRoot = taskDb.workspaceRoot(root);
10986
+ return ['build', 'review', 'search'].flatMap((job) => fittingTasks(taskDb, db, { workspaceRoot, job, requireCheck: true })
10987
+ .map((task) => ({ id: task.id, title: task.title, job })));
10988
+ } catch {
10989
+ return [];
10990
+ }
10991
+ }
10992
+
10993
+ function memberUpSpawn(root, name, onExit, onClaim) {
10734
10994
  const { spawn } = require('child_process');
10735
10995
  const logDir = path.join(root, '.atris', 'state', 'member-up');
10736
10996
  fs.mkdirSync(logDir, { recursive: true });
@@ -10743,12 +11003,15 @@ function memberUpSpawn(root, name, onExit) {
10743
11003
  cwd: root,
10744
11004
  detached: true,
10745
11005
  // Children keep their log handle when the foreground supervisor stops.
10746
- stdio: ['ignore', out, out],
11006
+ stdio: ['ignore', out, out, 'ipc'],
10747
11007
  env: process.env,
10748
11008
  });
10749
11009
  } finally {
10750
11010
  fs.closeSync(out);
10751
11011
  }
11012
+ child.on('message', (message) => {
11013
+ if (message?.type === 'member_task_claimed' && message.member === name) onClaim?.(message.task);
11014
+ });
10752
11015
  child.on('error', (error) => onExit(null, { ok: false, reason: error.message }));
10753
11016
  child.on('close', (code, signal) => {
10754
11017
  let output = '';
@@ -10773,7 +11036,11 @@ async function memberUp(...args) {
10773
11036
  if (hasFlag(args, '--help') || hasFlag(args, '-h')) {
10774
11037
  console.log('Usage: atris member up [--max N] [--only a,b] [--dry-run]');
10775
11038
  console.log('Keeps the team working: every minute, starts each free member that has a fitting task with a runnable check.');
10776
- console.log('A member with no such task stays idle; it never runs its own goal instead.');
11039
+ console.log('A member with no such task writes its next job from its mission when the mission has a ## Check, then starts it.');
11040
+ console.log('Without a check it stays idle. At most one self-written job is open per member, and 3 a day.');
11041
+ console.log('The longer a member stays idle, the less often it is checked: 5, 10, 20, then every 30 minutes.');
11042
+ console.log('A working member with no new step for 20 minutes is called out as stuck, once until it moves again.');
11043
+ console.log('Every 5 minutes it also puts member changes github dropped from its merge line back in, when self-landing is on.');
10777
11044
  console.log('--only limits it to those members. Same as atris member wake 5 or atris member wake a b.');
10778
11045
  console.log('Ctrl-C or atris member sleep --all ends it. Runs it started keep going.');
10779
11046
  return;
@@ -10790,8 +11057,17 @@ async function memberUp(...args) {
10790
11057
  const deps = {
10791
11058
  listMembers: () => memberUpTeam(root),
10792
11059
  pickTask: (name, taken) => memberUpPick(root, name, taken),
10793
- spawn: (name, onExit) => memberUpSpawn(root, name, onExit),
11060
+ writeNextJob: (name, opts) => memberUpWriteNextJob(root, name, opts),
11061
+ spawn: (name, onExit, onClaim) => memberUpSpawn(root, name, onExit, onClaim),
10794
11062
  now: () => Date.now(),
11063
+ jobOf: (name) => memberGoJob(name, root),
11064
+ waitingTasks: () => memberUpWaiting(root),
11065
+ requeueDropped: () => require('../lib/member-up').memberMaintenance({
11066
+ cwd: root,
11067
+ codeRoot: path.resolve(__dirname, '..'),
11068
+ requeueDropped: () => require('../lib/member-autoland').requeueDroppedAutolands({ cwd: root }),
11069
+ waitingTasks: () => memberUpWaiting(root),
11070
+ }),
10795
11071
  };
10796
11072
  if (parsed.dryRun) {
10797
11073
  const up = createMemberUp(deps, { max, only, dryRun: true });
@@ -10892,16 +11168,56 @@ function memberSleep(target, ...args) {
10892
11168
  return payload;
10893
11169
  }
10894
11170
 
11171
+ // The member's active goal plus how many times it was set, so a re-agreed
11172
+ // goal with the same words still counts as the talk's next step.
11173
+ function oneOnOneGoalMark(name, paths) {
11174
+ const goal = (loadMemberGoals(name, paths).goals || []).filter((g) => g && g.status === 'active').pop();
11175
+ return goal ? { title: goal.title, mark: `${goal.id}:${(goal.history || []).length}` } : { title: '', mark: '' };
11176
+ }
11177
+
11178
+ function askOneOnOneNextStep(name) {
11179
+ const rl = require('readline').createInterface({ input: process.stdin, output: process.stdout });
11180
+ return new Promise((resolve) => rl.question(`What is ${name}'s next step? `, (answer) => {
11181
+ rl.close();
11182
+ resolve(String(answer || '').trim());
11183
+ }));
11184
+ }
11185
+
11186
+ // A 1-on-1 is a talk with the member in its own voice, and it ends with the
11187
+ // member's next step saved as its active goal. The member records the step
11188
+ // itself during the talk; --next or a closing question fills it in otherwise.
10895
11189
  async function memberOneOnOne(name, ...args) {
10896
11190
  if (!name || name === '--help' || name === '-h' || hasFlag(args, '--help') || hasFlag(args, '-h')) {
10897
- console.log('Usage: atris member 1on1 <name> [--note "..."]');
10898
- console.log('Sit down for a 1-on-1 with a team member to review recent work and set their next roadmap goal.');
11191
+ console.log('Usage: atris member 1on1 <name> [--next "..."]');
11192
+ console.log('Talk with a team member in its own voice. The talk ends with its next step saved as its goal.');
10899
11193
  return;
10900
11194
  }
11195
+ const paths = requireMemberDir(name);
11196
+ const next = readFlag(args, '--next', '');
11197
+ const rest = stripKnownFlags(args, ['--next'], []);
11198
+ const before = oneOnOneGoalMark(name, paths);
10901
11199
  const { interviewCommand } = require('./interview');
10902
- const code = await interviewCommand([name, ...args]);
10903
- if (code) process.exitCode = code;
10904
- return code;
11200
+ const code = await interviewCommand([name, ...rest], process.cwd(), { mode: 'one_on_one' });
11201
+ if (code) {
11202
+ process.exitCode = code;
11203
+ return code;
11204
+ }
11205
+ let after = oneOnOneGoalMark(name, paths);
11206
+ if (after.mark === before.mark) {
11207
+ const step = next || (process.stdin.isTTY && process.stdout.isTTY ? await askOneOnOneNextStep(name) : '');
11208
+ if (step) {
11209
+ memberGoal(name, step);
11210
+ after = oneOnOneGoalMark(name, paths);
11211
+ }
11212
+ }
11213
+ if (after.title && after.mark !== before.mark) {
11214
+ console.log(`${name}'s next step: ${after.title}`);
11215
+ return 0;
11216
+ }
11217
+ console.log(`The 1-on-1 ended without a next step for ${name}.`);
11218
+ console.log(`Set one: atris member goal ${name} "..."`);
11219
+ process.exitCode = 1;
11220
+ return 1;
10905
11221
  }
10906
11222
 
10907
11223
  function collectMemberHandoffs(root = process.cwd(), cards = []) {
@@ -11239,7 +11555,7 @@ async function memberCommand(subcommand, ...args) {
11239
11555
  const HELP_AWARE_SUBCOMMANDS = new Set([
11240
11556
  'create', 'new', 'run', 'install', 'result', 'dispatch', 'route', 'feed', 'stream',
11241
11557
  'card', 'cards', 'go', 'work', 'up', 'steer', 'sleep', 'rest', '1on1', 'interview',
11242
- 'room', 'ui', 'day', 'standup', 'handoff', 'handoffs',
11558
+ 'room', 'ui', 'day', 'standup', 'handoff', 'handoffs', 'report',
11243
11559
  ]);
11244
11560
  const explicitHelp = subcommand === 'help' || subcommand === '--help' || subcommand === '-h'
11245
11561
  || (!HELP_AWARE_SUBCOMMANDS.has(subcommand) && (args[0] === '-h' || args[0] === '--help'));
@@ -11250,6 +11566,9 @@ async function memberCommand(subcommand, ...args) {
11250
11566
  case 'list':
11251
11567
  case 'ls':
11252
11568
  return memberList(...args);
11569
+ case 'roster':
11570
+ // Who is on the team, working or idle, and on which engine.
11571
+ return require('./team').memberRosterCommand(args);
11253
11572
  case 'create':
11254
11573
  case 'new':
11255
11574
  return memberCreate(args[0], ...args.slice(1));
@@ -11345,6 +11664,8 @@ async function memberCommand(subcommand, ...args) {
11345
11664
  return memberHeartbeat(args[0], ...args.slice(1));
11346
11665
  case 'improvements':
11347
11666
  return memberImprovements(args[0], ...args.slice(1));
11667
+ case 'report':
11668
+ return require('../lib/member-report').memberReportCommand(...args);
11348
11669
  case 'history':
11349
11670
  return memberHistory(args[0], ...args.slice(1));
11350
11671
  case 'supervisor':
@@ -11373,6 +11694,14 @@ async function memberCommand(subcommand, ...args) {
11373
11694
  if (isKnownMemberSlug(subcommand, process.cwd()) && args.filter((a) => !String(a).startsWith('-')).length === 0) {
11374
11695
  return memberCard(subcommand, ...args);
11375
11696
  }
11697
+ // One word that is neither a member nor a subcommand is a typo far
11698
+ // more often than a job. Starting a paid run on it costs money and
11699
+ // does nothing useful, so name the miss and stop. Jobs keep working
11700
+ // as more than one word, or through `atris member run "<job>"`.
11701
+ const extraWords = args.filter((a) => !String(a).startsWith('-')).length;
11702
+ if (!/\s/.test(subcommand.trim()) && extraWords === 0) {
11703
+ return memberUnknownWord(subcommand);
11704
+ }
11376
11705
  return memberSteerOrDispatch(subcommand, ...args);
11377
11706
  }
11378
11707
  console.log('run "<job>" pick the right member and run it: atris member run "fix the flaky login test"');
@@ -11386,14 +11715,15 @@ async function memberCommand(subcommand, ...args) {
11386
11715
  console.log(' "<prompt>" Steer a running member mid-flight or start the best specialist');
11387
11716
  console.log(' go [name|"..."] Wake the highest-leverage member on its next_push or run a prompt');
11388
11717
  console.log(' steer [name] "..." Steer a member mid-run or update its immediate direction');
11389
- console.log(' 1on1 <name> Sit down for a 1-on-1 to review recent wins and set the roadmap');
11718
+ console.log(' 1on1 <name> Talk with a member in its own voice; ends with its next step saved');
11390
11719
  console.log(' sleep [name|--all] Park active member loops so your team rests until woken');
11391
11720
  console.log(' room [--json] Render the visual Team Room and desktop UI payload');
11392
11721
  console.log(' board [--watch] One screen of every member: job, last step, working/quiet/done');
11393
11722
  console.log(' create <name> Scaffold a new team member (MEMBER.md + dirs) [--push]');
11394
11723
  console.log(' install <slug> [--update] Install missing files, or refresh template skills');
11395
11724
  console.log(' chat <name> "..." Send a message to this member\'s desk in Atris Desktop');
11396
- console.log(' list [--json] Show all team members');
11725
+ console.log(' list [--json] Show all team members with skills and version');
11726
+ console.log(' roster [--all] Who is on the team: what each does, working or idle, and engine');
11397
11727
  console.log(' tidy [--dry-run] Put away member copies of the repo whose work already landed');
11398
11728
  console.log(' export [--dry-run] Write each member as a subagent for Claude Code, Cursor, and Codex');
11399
11729
  console.log(' activate <name> Symlink member skills, show context and permissions');
@@ -11406,6 +11736,7 @@ async function memberCommand(subcommand, ...args) {
11406
11736
  console.log(' wake <name> Decide tick/wait/ask/stop, then run one bounded work step on the roster engine (--plan to only decide; --keep keeps it working)');
11407
11737
  console.log(' run "<job>" Pick the best member for the job and run it (same as dispatch "<job>" --ignite)');
11408
11738
  console.log(' run <name> ["..."] Start a budgeted member mission, or run its active Mission Runtime');
11739
+ console.log(' report [--since 2h] Short summary per member: merged, finished, turned down, done but not merged yet [--json]');
11409
11740
  console.log(' result <name> Newest run result: run record, receipt, artifacts, number [--since <iso>] [--json]');
11410
11741
  console.log(' dispatch "<idea>" Route an idea to the best specialist member and start work');
11411
11742
  console.log(' up [--max N] [--only a,b] Keep the team working: start free members on fitting tasks every minute (wake 5 / wake a b)');
@@ -11469,9 +11800,12 @@ async function memberCommand(subcommand, ...args) {
11469
11800
  }
11470
11801
 
11471
11802
  module.exports = {
11803
+ memberRunDefaultWallSeconds,
11472
11804
  memberRunWantsReferee,
11473
11805
  proposalPromptForGoal,
11474
11806
  memberGoFinishTask,
11807
+ memberRunReviewAsk,
11808
+ memberUpSpawn,
11475
11809
  appendMemberGoalLog,
11476
11810
  appendProjectLog,
11477
11811
  memberCommand,