atris 3.68.9 → 3.70.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.
@@ -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
  }
@@ -915,6 +914,17 @@ function memberRunBudgetSeconds(args = []) {
915
914
  return null;
916
915
  }
917
916
 
917
+ // A job with no time given gets 15 minutes, or the roster's limit for this
918
+ // member's engine plus ten minutes for the review, whichever is longer.
919
+ function memberRunDefaultWallSeconds(name, cwd = process.cwd()) {
920
+ let cap = 0;
921
+ try {
922
+ const picked = require('../lib/member-engine').memberRosterEngine(name, cwd);
923
+ cap = Number(picked && picked.engine && picked.engine.roster_max_seconds) || 0;
924
+ } catch {}
925
+ return Math.max(900, cap ? cap + 600 : 0);
926
+ }
927
+
918
928
  function pushFlagValue(out, args, name) {
919
929
  const value = readFlag(args, name, '');
920
930
  if (value) out.push(name, String(value));
@@ -1017,6 +1027,12 @@ function startMemberRunMission(name, missionText, args = []) {
1017
1027
  return memberRunCli(startArgs, process.cwd());
1018
1028
  }
1019
1029
 
1030
+ // An ignited run asks a second engine to review the work by default, so every
1031
+ // run ends with a verdict. --no-referee skips it; an explicit --referee is kept.
1032
+ function memberRunWantsReferee(runArgs, ignite) {
1033
+ return Boolean(ignite) && !hasFlag(runArgs, '--referee') && !hasFlag(runArgs, '--no-referee');
1034
+ }
1035
+
1020
1036
  function memberRunCli(args, cwd, { live = false, missionId = '' } = {}) {
1021
1037
  const cliPath = path.join(__dirname, '..', 'bin', 'atris.js');
1022
1038
  const runsDir = path.join(cwd, 'atris', 'runs');
@@ -1034,7 +1050,7 @@ function memberRunCli(args, cwd, { live = false, missionId = '' } = {}) {
1034
1050
  : null;
1035
1051
  const receipt = receiptName ? readJson(path.join(runsDir, receiptName), null) : null;
1036
1052
  return { status: result.status, payload: receipt?.result?.kind === 'mission_run_summary'
1037
- ? { ran_ticks: receipt.result.ran_ticks } : null, stderr: '', errorReported: result.status !== 0 };
1053
+ ? { ran_ticks: receipt.result.ran_ticks, ticks: receipt.result.ticks } : null, stderr: '', errorReported: result.status !== 0 };
1038
1054
  }
1039
1055
  let payload = null;
1040
1056
  try {
@@ -1229,9 +1245,19 @@ function memberRun(name, ...args) {
1229
1245
  return memberRunWith(name, args);
1230
1246
  }
1231
1247
 
1248
+ // One plain line for why the reviewing engine refused the work.
1249
+ function reviewRefusalLine(refusal) {
1250
+ if (refusal.timed_out) return 'the reviewer ran out of time';
1251
+ const text = String(refusal.output || refusal.reason || '');
1252
+ const verdict = text.match(/VERDICT:\s*FAIL\s*[;:]\s*(.+)/i);
1253
+ const line = verdict ? verdict[1] : (text.split(/\r?\n/).find((l) => l.trim()) || refusal.reason || 'no reason given');
1254
+ return line.trim().slice(0, 200);
1255
+ }
1256
+
1232
1257
  function memberRunWith(name, args, { quiet = false } = {}) {
1233
1258
  if (!name || name === '--help' || name === '-h' || hasFlag(args, '--help') || hasFlag(args, '-h')) {
1234
1259
  console.log('usage: atris member run <name> ["mission text"] [--verify "<cmd>"] [--minutes N|--hours N] [--json]');
1260
+ console.log('pick for me: atris member run "fix the flaky login test" (the best member takes it and runs it)');
1235
1261
  console.log('new mission: atris member run growth "improve onboarding proof" --verify "npm test" --minutes 30 --json');
1236
1262
  console.log('choose work: atris member run growth --verify "npm test" --industry logistics --minutes 30 --json');
1237
1263
  console.log('subagent shell: atris member run growth "improve onboarding proof" --engine claude --ignite --json');
@@ -1240,6 +1266,7 @@ function memberRunWith(name, args, { quiet = false } = {}) {
1240
1266
  console.log('truth: useful work must improve revenue, reliability, speed, security, clarity, or trust, then show plain proof.');
1241
1267
  console.log('isolation: new missions use --worktree by default; add --shared-checkout to stay here.');
1242
1268
  console.log('check: --verify or a verify field in the member file adds a mission check.');
1269
+ console.log('review: an --ignite run has a different engine review the work; add --no-referee to skip it.');
1243
1270
  return;
1244
1271
  }
1245
1272
 
@@ -1305,6 +1332,7 @@ function memberRunWith(name, args, { quiet = false } = {}) {
1305
1332
  const progressDir = process.env.ATRIS_PROGRESS_DIR || '~/.atris/progress';
1306
1333
  console.log(`watch: tail -f ${progressDir}/${String(name).replace(/[^A-Za-z0-9._-]/g, '_')}.log`);
1307
1334
  console.log('board: atris member board --watch');
1335
+ console.log('stop: atris member stop <name>');
1308
1336
  }
1309
1337
 
1310
1338
  const cwd = mission.worktree?.path || process.cwd();
@@ -1324,11 +1352,12 @@ function memberRunWith(name, args, { quiet = false } = {}) {
1324
1352
  ['--mission', '--mission-id', '--minutes', '--hours', '--industry', '--domain', '--value', '--outcome', '--truth', '--proof', '--base'],
1325
1353
  ['--worktree', '--shared-checkout', '--no-worktree', '--choose-work', '--auto', '--ignite', '--wait', '--sync']);
1326
1354
  if (ignite && !hasFlag(runArgs, '--complete-on-pass')) runArgs.push('--complete-on-pass');
1355
+ if (memberRunWantsReferee(runArgs, ignite)) runArgs.push('--referee');
1327
1356
  const budgetSeconds = memberRunBudgetSeconds(args);
1328
1357
  if (!readFlag(runArgs, '--max-ticks', '')) {
1329
1358
  runArgs.push('--max-ticks', budgetSeconds ? String(Math.max(4, Math.ceil(budgetSeconds / 300))) : '1');
1330
1359
  }
1331
- if (!readFlag(runArgs, '--max-wall', '')) runArgs.push('--max-wall', String(budgetSeconds || 900));
1360
+ if (!readFlag(runArgs, '--max-wall', '')) runArgs.push('--max-wall', String(budgetSeconds || memberRunDefaultWallSeconds(name, cwd)));
1332
1361
  const caller = callerRuntime(args);
1333
1362
  const runnerSwap = memberRunRunnerSwap(mission, args, caller);
1334
1363
  if (runnerSwap) runArgs.push('--engine', runnerSwap.to);
@@ -1341,7 +1370,12 @@ function memberRunWith(name, args, { quiet = false } = {}) {
1341
1370
  const waitingOnNativeGoal = caller === 'codex' && payload.requires_native_goal_start === true
1342
1371
  && memberRunUsesCallerSession(finalMission.runner);
1343
1372
  const noWorkStepExpected = hasFlag(runArgs, '--detach') || hasFlag(runArgs, '--dry-run');
1344
- const ok = (run.status === 0 && payload.ok !== false && (started || noWorkStepExpected)) || waitingOnNativeGoal;
1373
+ // The work ran but its check failed: the run failed, even though a work
1374
+ // step happened. Scripts and the caller read this from the exit code.
1375
+ const lastRanTick = [...(Array.isArray(payload.ticks) ? payload.ticks : [])].reverse().find((tick) => tick && tick.ran === true);
1376
+ const checkFailed = started && Boolean(String(finalMission.verifier || '').trim())
1377
+ && lastRanTick?.verifier_passed === false && finalMission.status !== 'complete';
1378
+ const ok = ((run.status === 0 && payload.ok !== false && (started || noWorkStepExpected)) || waitingOnNativeGoal) && !checkFailed;
1345
1379
  const reviewAsk = memberRunReviewAsk(name, payload, finalMission, cwd);
1346
1380
  const result = { ...payload, ok, started, state: finalMission.status, mission_id: mission.id,
1347
1381
  mission: finalMission, node_modules_linked: nodeModulesLinked, node_modules_source: nodeModulesSource,
@@ -1365,7 +1399,23 @@ function memberRunWith(name, args, { quiet = false } = {}) {
1365
1399
  result.ask = `${name} is waiting on you: ${payload.human_asks[0]}`;
1366
1400
  result.next_command = payload.next_command || 'atris decide';
1367
1401
  }
1368
- if (!ok) {
1402
+ // A finished ignited run whose check passed hands its branch to GitHub
1403
+ // auto-merge, only past the local gates; any held gate leaves the branch.
1404
+ if (ignite && ok && started && !reviewAsk && finalMission.verifier_result?.passed === true) {
1405
+ const ticks = Array.isArray(payload.ticks) ? payload.ticks : [];
1406
+ const summary = ticks.map((tick) => tick?.summary || tick?.claude?.summary).filter(Boolean).pop() || '';
1407
+ const workerEngine = ticks.map((tick) => tick?.engine_id).filter(Boolean).pop()
1408
+ || runnerSwap?.to || readFlag(args, '--runner', '') || '';
1409
+ result.autoland = require('../lib/member-autoland').autolandMemberRun({ mission: finalMission, cwd, summary, workerEngine });
1410
+ if (!asJson && !quiet) console.log(result.autoland.line);
1411
+ }
1412
+ if (checkFailed) {
1413
+ result.check_failed = true;
1414
+ const refusal = lastRanTick?.referee_result?.passed === false ? lastRanTick.referee_result : null;
1415
+ result.error = refusal
1416
+ ? `turned down in review: ${reviewRefusalLine(refusal)}`
1417
+ : `the work ran but its check failed: ${finalMission.verifier || 'completion check'}`;
1418
+ } else if (!ok) {
1369
1419
  result.error = result.ask || payload.detail || payload.error || payload.reason || payload.next_action
1370
1420
  || run.stderr || 'mission did not run a work step';
1371
1421
  }
@@ -4236,7 +4286,10 @@ function parseFrontmatter(content) {
4236
4286
  } else if (val.startsWith('[') && val.endsWith(']')) {
4237
4287
  result[currentKey] = val.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, ''));
4238
4288
  } else {
4239
- result[currentKey] = val.replace(/^["']|["']$/g, '');
4289
+ // Unwrap only a matching outer pair: `verify: node -e "process.exit(0)"`
4290
+ // must keep its closing quote or the check is a shell syntax error.
4291
+ const quoted = val.match(/^(["'])([\s\S]*)\1$/);
4292
+ result[currentKey] = quoted ? quoted[2] : val;
4240
4293
  }
4241
4294
  }
4242
4295
  }
@@ -9796,11 +9849,35 @@ const IDEA_ARCHETYPE_RULES = [
9796
9849
  },
9797
9850
  ];
9798
9851
 
9799
- // Dispatch defaults to the core team. Other members (experiments, one-off
9800
- // workers) win only when the idea names them or out-scores the core by 2x.
9801
- const CORE_DISPATCH_MEMBERS = new Set([
9802
- 'executor', 'architect', 'auto-improver', 'navigator', 'validator', 'customer-lead', 'researcher',
9803
- ]);
9852
+ // Keep specialized idea rules, then fall back to the same job inference used
9853
+ // by the engine roster. Renamed members get the same job score as built-ins.
9854
+ const ARCHETYPE_FOR_JOB = { build: 'executor', review: 'validator', search: 'researcher' };
9855
+ const EXPERIMENT_STATUSES = new Set(['experimental', 'one-off']);
9856
+
9857
+ function ideaRuleFor(slug, record, text) {
9858
+ const own = IDEA_ARCHETYPE_RULES.find((rule) => rule.slugs.includes(slug));
9859
+ if (own && own.pattern.test(text)) return own;
9860
+ const { autoJobForMember } = require('../lib/member-engine');
9861
+ const archetype = ARCHETYPE_FOR_JOB[autoJobForMember(slug, record.role)];
9862
+ return IDEA_ARCHETYPE_RULES.find((rule) => rule.slugs.includes(archetype)) || null;
9863
+ }
9864
+
9865
+ // 0 = normal, 1 = experiment or one-off, 2 = parked, human, or odd-typed.
9866
+ function dispatchTier(record) {
9867
+ const frontmatter = record.frontmatter || {};
9868
+ const status = String(frontmatter.status || '').trim().toLowerCase();
9869
+ if (status === 'parked' || memberRefusalReason(memberSlugFromRecord(record), frontmatter)) return 2;
9870
+ if (EXPERIMENT_STATUSES.has(status)) return 1;
9871
+ return 0;
9872
+ }
9873
+
9874
+ // Lower tier wins first, then higher score, then name order for a stable pick.
9875
+ function outranksDispatch(candidate, best) {
9876
+ if (!best) return true;
9877
+ if (candidate.tier !== best.tier) return candidate.tier < best.tier;
9878
+ if (candidate.score !== best.score) return candidate.score > best.score;
9879
+ return candidate.member.localeCompare(best.member) < 0;
9880
+ }
9804
9881
 
9805
9882
  function ideaNamesMember(text, slug) {
9806
9883
  const escaped = slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/-/g, '[-\\s]+');
@@ -9835,15 +9912,17 @@ function pickMemberForIdea(ideaText, root = process.cwd(), preferredMember = '')
9835
9912
  let best = null;
9836
9913
  for (const record of members) {
9837
9914
  const slug = memberSlugFromRecord(record);
9915
+ const named = ideaNamesMember(text, slug);
9916
+ // Naming an experiment in the idea is the owner's pick, so it ranks normally.
9917
+ const baseTier = dispatchTier(record);
9918
+ const tier = named && baseTier === 1 ? 0 : baseTier;
9838
9919
  let score = 0;
9839
9920
  let matchedReason = '';
9840
9921
 
9841
- for (const rule of IDEA_ARCHETYPE_RULES) {
9842
- if (rule.slugs.includes(slug) && rule.pattern.test(text)) {
9843
- score += 25 + (rule.slugs[0] === slug ? 5 : 0);
9844
- matchedReason = rule.reason;
9845
- break;
9846
- }
9922
+ const rule = ideaRuleFor(slug, record, text);
9923
+ if (rule && rule.pattern.test(text)) {
9924
+ score += 30;
9925
+ matchedReason = rule.reason;
9847
9926
  }
9848
9927
 
9849
9928
  const missionFile = path.join(teamDir, slug, 'MISSION.md');
@@ -9856,14 +9935,10 @@ function pickMemberForIdea(ideaText, root = process.cwd(), preferredMember = '')
9856
9935
  }
9857
9936
  score += overlapCount * 4;
9858
9937
  if (Number(record.skillCount || 0) > 0) score += 2;
9859
- if (slug === 'executor') score += 3;
9860
- if (slug === 'auto-improver') score += 2;
9861
9938
 
9862
- if (ideaNamesMember(text, slug)) {
9939
+ if (named) {
9863
9940
  score += 40;
9864
9941
  matchedReason = 'named in the idea';
9865
- } else if (!CORE_DISPATCH_MEMBERS.has(slug)) {
9866
- score = Math.floor(score / 2);
9867
9942
  }
9868
9943
 
9869
9944
  if (!matchedReason) {
@@ -9877,10 +9952,9 @@ function pickMemberForIdea(ideaText, root = process.cwd(), preferredMember = '')
9877
9952
  role: record.role || 'Team Member',
9878
9953
  reason: matchedReason,
9879
9954
  score,
9955
+ tier,
9880
9956
  };
9881
- if (!best || candidate.score > best.score || (candidate.score === best.score && candidate.member.localeCompare(best.member) < 0)) {
9882
- best = candidate;
9883
- }
9957
+ if (outranksDispatch(candidate, best)) best = candidate;
9884
9958
  }
9885
9959
 
9886
9960
  return best;
@@ -9938,7 +10012,7 @@ function memberDispatch(...args) {
9938
10012
  printJsonOrText(
9939
10013
  payload,
9940
10014
  [
9941
- `${picked.member} (${picked.role}) will take this because it ${picked.reason.replace(/^[A-Z]/, (c) => c.toLowerCase())}.`,
10015
+ `${picked.member} (${picked.role}) will take this: ${picked.reason.replace(/\.$/, '')}.`,
9942
10016
  '',
9943
10017
  commandPreview,
9944
10018
  ],
@@ -9948,7 +10022,7 @@ function memberDispatch(...args) {
9948
10022
  }
9949
10023
 
9950
10024
  if (!asJson) {
9951
- console.log(`${picked.member} (${picked.role}) is taking this because it ${picked.reason.replace(/^[A-Z]/, (c) => c.toLowerCase())}.`);
10025
+ console.log(`${picked.member} (${picked.role}) is taking this: ${picked.reason.replace(/\.$/, '')}.`);
9952
10026
  }
9953
10027
  // --ignite means do the work now, the same as the previewed member run;
9954
10028
  // without it dispatch only opens the mission.
@@ -10371,7 +10445,101 @@ function isKnownMemberSlug(token, root = process.cwd()) {
10371
10445
  || fs.existsSync(path.join(root, 'atris', 'team', `${clean}.md`));
10372
10446
  }
10373
10447
 
10448
+ // The job a member does (build, review or search), read from the same team
10449
+ // roster that picks its engine.
10450
+ function memberGoJob(owner, root) {
10451
+ const byRole = { navigator: 'search', executor: 'build', validator: 'review' };
10452
+ try {
10453
+ const seat = require('../lib/member-engine').resolveEngineForMember(owner, root);
10454
+ return byRole[seat.role] || byRole[seat.job_key] || 'build';
10455
+ } catch {
10456
+ return 'build';
10457
+ }
10458
+ }
10459
+
10460
+ // With no idea given, member go takes the next real job from the task list:
10461
+ // the oldest open task that fits the member's job. A dry run only looks.
10462
+ // requireCheck: only tasks with a runnable check count.
10463
+ function memberGoTask(owner, root, { claim, requireCheck = false }) {
10464
+ try {
10465
+ const taskDb = require('../lib/task-db');
10466
+ const picker = require('../lib/member-task-pick');
10467
+ const db = taskDb.open();
10468
+ const job = memberGoJob(owner, root);
10469
+ const workspaceRoot = taskDb.workspaceRoot(root);
10470
+ if (claim) {
10471
+ const got = picker.claimTaskForMember(taskDb, db, { workspaceRoot, member: owner, job, requireCheck });
10472
+ return got ? { ...got, job } : null;
10473
+ }
10474
+ const task = picker.pickTaskForMember(taskDb, db, { workspaceRoot, job, requireCheck });
10475
+ return task ? { task, objective: picker.taskObjective(task), job } : null;
10476
+ } catch (error) {
10477
+ console.error(`could not take a task from the list: ${String(error && error.message || error).split('\n')[0]}`);
10478
+ return null;
10479
+ }
10480
+ }
10481
+
10482
+ // A pulled task leaves the run in review when the run's check passed (the
10483
+ // mission is complete, or ready: checked and waiting on a person), else back
10484
+ // on the list with a one-line reason.
10485
+ function memberGoFinishTask(owner, task, result, thrown = null) {
10486
+ const taskDb = require('../lib/task-db');
10487
+ const db = taskDb.open();
10488
+ const mission = (result && result.mission) || {};
10489
+ const receipt = (result && result.work_step && result.work_step.receipt_path) || mission.receipt_path || '';
10490
+ const required = require('../lib/member-task-pick').taskCheck(task).command;
10491
+ const checked = mission.verifier_result && mission.verifier_result.passed === true
10492
+ && (!required || String(mission.verifier_result.command || '').trim() === required);
10493
+ if (!thrown && result && result.started && ['complete', 'ready'].includes(mission.status) && checked) {
10494
+ const proof = [`[verified] ${owner} ran mission ${result.mission_id} and its check passed.`,
10495
+ receipt ? `Receipt: ${receipt}` : ''].filter(Boolean).join('\n');
10496
+ const ready = taskDb.readyTask(db, { id: task.id, actor: owner, proof });
10497
+ if (ready.ready !== false) return { id: task.id, title: task.title, outcome: 'review', receipt: receipt || null };
10498
+ }
10499
+ const ticks = Array.isArray(result && result.ticks) ? result.ticks : [];
10500
+ const lastReason = String((ticks[ticks.length - 1] || {}).reason || (ticks[ticks.length - 1] || {}).error || '');
10501
+ const outOfTime = /timeout|wall-exceeded/.test(lastReason);
10502
+ const why = thrown ? thrown.message
10503
+ : outOfTime ? 'ran out of time before the work finished'
10504
+ : (result && result.error) || (result && result.started ? 'the check did not pass' : 'the run did not start');
10505
+ const reason = String(why || 'the run did not finish').split(/\r?\n/)[0].trim().slice(0, 200);
10506
+ try { taskDb.noteTask(db, { id: task.id, actor: owner, content: `Released by ${owner}: ${reason}` }); } catch {}
10507
+ // Third failure in a row: hold the task for a person instead of letting
10508
+ // members pick it up again forever.
10509
+ let held = false;
10510
+ if (memberGoFailStreak(taskDb, db, task.id) >= MEMBER_GO_STRIKES) {
10511
+ try {
10512
+ held = taskDb.tagTask(db, { id: task.id, actor: owner, add: ['needs-human'] }).tagged === true;
10513
+ if (held) taskDb.noteTask(db, { id: task.id, actor: owner,
10514
+ content: `Held for a person: failed ${MEMBER_GO_STRIKES} times in a row. Last reason: ${reason}` });
10515
+ } catch {}
10516
+ }
10517
+ const released = taskDb.releaseTask(db, { id: task.id, actor: owner });
10518
+ const outcome = released.released ? (held ? 'needs_human' : 'released') : 'left_claimed';
10519
+ return { id: task.id, title: task.title, outcome, reason };
10520
+ }
10521
+
10522
+ const MEMBER_GO_STRIKES = 3;
10523
+
10524
+ // Failed runs on a task since its last pass, review, reopen, or a person
10525
+ // clearing its hold.
10526
+ function memberGoFailStreak(taskDb, db, taskId) {
10527
+ let streak = 0;
10528
+ for (const event of taskDb.listTaskEvents(db, { taskId })) {
10529
+ const type = event.event_type;
10530
+ const payload = event.payload || {};
10531
+ if (type === 'message' && /^Released by \S+: /.test(String(payload.content || ''))) streak += 1;
10532
+ else if (['proof_ready', 'reviewed', 'reopened'].includes(type)) streak = 0;
10533
+ else if (type === 'task_tags_updated' && (payload.removed || []).includes('needs-human')) streak = 0;
10534
+ }
10535
+ return streak;
10536
+ }
10537
+
10374
10538
  async function memberSteerOrDispatch(...rawArgs) {
10539
+ return memberSteerOrDispatchWith({}, rawArgs);
10540
+ }
10541
+
10542
+ async function memberSteerOrDispatchWith({ pullTask = false } = {}, rawArgs = []) {
10375
10543
  if (hasFlag(rawArgs, '--help') || hasFlag(rawArgs, '-h')) {
10376
10544
  console.log('Usage: atris member steer [member] "<direction>" [--dry-run] [--json]');
10377
10545
  console.log(' atris member "<direction>" [--dry-run] [--json]');
@@ -10381,6 +10549,9 @@ async function memberSteerOrDispatch(...rawArgs) {
10381
10549
  const root = process.cwd();
10382
10550
  const asJson = hasFlag(rawArgs, '--json');
10383
10551
  const dryRun = hasFlag(rawArgs, '--dry-run');
10552
+ // Set by `member up`: run only a claimed task with a runnable check, never
10553
+ // the member's own goal text.
10554
+ const needsCheck = pullTask && hasFlag(rawArgs, '--needs-check');
10384
10555
  const valueFlags = new Set(['--engine', '--model', '--verify', '--referee-engine', '--minutes', '--hours', '--max-ticks', '--from']);
10385
10556
  const positionals = [];
10386
10557
  const flagArgs = [];
@@ -10388,6 +10559,7 @@ async function memberSteerOrDispatch(...rawArgs) {
10388
10559
  const token = String(rawArgs[i] || '');
10389
10560
  if (!token) continue;
10390
10561
  if (token.startsWith('-')) {
10562
+ if (token === '--needs-check') continue;
10391
10563
  flagArgs.push(token);
10392
10564
  if (valueFlags.has(token) && i + 1 < rawArgs.length) {
10393
10565
  flagArgs.push(rawArgs[i + 1]);
@@ -10419,18 +10591,31 @@ async function memberSteerOrDispatch(...rawArgs) {
10419
10591
  fm = parseFrontmatter(fs.readFileSync(paths.memberFile, 'utf8')) || {};
10420
10592
  } catch {}
10421
10593
  const goalPlane = memberGoalPlaneStatus(owner, paths);
10594
+ const missionMap = memberRunMissionMap(root);
10595
+ const missionSummary = memberMissionSummary(owner, missionMap, root);
10596
+ // A human or odd-typed member never takes a task: it would be claimed and
10597
+ // then stranded when the run refuses the member.
10598
+ const pulled = pullTask && !prompt && !missionSummary.live && !memberRefusalFor(targetMember, paths.memberFile)
10599
+ ? memberGoTask(owner, root, { claim: !dryRun, requireCheck: needsCheck })
10600
+ : null;
10601
+ if (needsCheck && !pulled) {
10602
+ const payload = { ok: true, action: 'member_go', mode: 'idle', member: owner, reason: 'no task with a check' };
10603
+ printJsonOrText(payload, [`${owner} is idle, no task with a check`], asJson);
10604
+ process.exitCode = require('../lib/member-up').IDLE_EXIT;
10605
+ return payload;
10606
+ }
10422
10607
  const effectivePrompt = prompt
10608
+ || pulled?.objective
10423
10609
  || goalPlane.goal?.next_push
10424
10610
  || goalPlane.goal?.title
10425
10611
  || extractMemberNorthStar(paths.memberDir, '');
10426
-
10427
- const missionMap = memberRunMissionMap(root);
10428
- const missionSummary = memberMissionSummary(owner, missionMap, root);
10612
+ const pulledTask = pulled ? { id: pulled.task.id, title: pulled.task.title, job: pulled.job } : null;
10613
+ if (pulled) whyPicked = `${whyPicked} Took the oldest open ${pulled.job} task from the list.`.trim();
10429
10614
  const engineSeat = resolveMemberEngineSeat(owner, root);
10430
10615
  const { autoQueueMemberNextPush, closeMemberHandoffsForReceiver } = require('./mission');
10431
10616
  const subagentShell = buildMemberSubagentShell(owner, fm.role || 'Team Member', engineSeat.engine_id, effectivePrompt, root);
10432
10617
 
10433
- if (effectivePrompt) {
10618
+ if (effectivePrompt && !pulled) {
10434
10619
  try {
10435
10620
  autoQueueMemberNextPush(owner, effectivePrompt, missionSummary.live?.objective || '', root);
10436
10621
  } catch {}
@@ -10494,11 +10679,13 @@ async function memberSteerOrDispatch(...rawArgs) {
10494
10679
  engine_reason: engineSeat.reason,
10495
10680
  prompt: effectivePrompt,
10496
10681
  why: whyPicked,
10682
+ task: pulledTask,
10497
10683
  command,
10498
10684
  subagent_shell: subagentShell,
10499
10685
  };
10500
10686
  const lines = [
10501
10687
  `Routed to ${owner} on engine ${engineSeat.engine_id}: ${whyPicked}`,
10688
+ ...(pulledTask ? [`Task: ${pulledTask.title}`] : []),
10502
10689
  `Objective: ${effectivePrompt}`,
10503
10690
  `Next: ${command}`,
10504
10691
  ];
@@ -10511,7 +10698,26 @@ async function memberSteerOrDispatch(...rawArgs) {
10511
10698
  }
10512
10699
  const hasExplicitEngine = flagArgs.includes('--engine');
10513
10700
  const nextFlags = hasExplicitEngine ? flagArgs : ['--engine', engineSeat.engine_id, ...flagArgs];
10514
- return memberRun(owner, effectivePrompt, '--ignite', ...nextFlags);
10701
+ if (!pulled) return memberRun(owner, effectivePrompt, '--ignite', ...nextFlags);
10702
+ // A pulled task runs with the check it requires, not the member's own.
10703
+ const required = require('../lib/member-task-pick').taskCheck(pulled.task).command;
10704
+ const runFlags = required ? [...stripKnownFlags(nextFlags, ['--verify'], []), '--verify', required] : nextFlags;
10705
+ let result = null;
10706
+ try {
10707
+ result = await memberRunWith(owner, [effectivePrompt, '--ignite', ...runFlags], { quiet: asJson });
10708
+ } catch (error) {
10709
+ memberGoFinishTask(owner, pulled.task, null, error);
10710
+ throw error;
10711
+ }
10712
+ const task = memberGoFinishTask(owner, pulled.task, result);
10713
+ const merged = { ...(result || {}), task };
10714
+ if (asJson) console.log(JSON.stringify(needsCheck
10715
+ ? { task, work_step: { summary: result?.work_step?.summary || null }, error: result?.error || null }
10716
+ : merged));
10717
+ else if (task.outcome === 'review') console.log(`Task "${task.title}" is ready for review.`);
10718
+ 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}`);
10719
+ else console.log(`Task "${task.title}" went back on the list: ${task.reason}`);
10720
+ return merged;
10515
10721
  }
10516
10722
 
10517
10723
  async function memberGo(...rawArgs) {
@@ -10522,7 +10728,7 @@ async function memberGo(...rawArgs) {
10522
10728
  }
10523
10729
  const nonFlags = rawArgs.filter((a) => a && !String(a).startsWith('-'));
10524
10730
  if (nonFlags.length > 0) {
10525
- return memberSteerOrDispatch(...rawArgs);
10731
+ return memberSteerOrDispatchWith({ pullTask: true }, rawArgs);
10526
10732
  }
10527
10733
  const root = process.cwd();
10528
10734
  const teamDir = path.join(root, 'atris', 'team');
@@ -10542,7 +10748,155 @@ async function memberGo(...rawArgs) {
10542
10748
  const fallback = findAllMembers(teamDir).filter(isFleetMember)[0];
10543
10749
  if (fallback) chosen = memberSlugFromRecord(fallback);
10544
10750
  }
10545
- return memberSteerOrDispatch(chosen, ...rawArgs);
10751
+ return memberSteerOrDispatchWith({ pullTask: true }, [chosen, ...rawArgs]);
10752
+ }
10753
+
10754
+ // Where a running `atris member up` leaves its pid, so a whole-team sleep
10755
+ // can end it.
10756
+ function memberUpPidFile(root) {
10757
+ return path.join(root, '.atris', 'state', 'member-up.json');
10758
+ }
10759
+
10760
+ // A member slept by name stays asleep for member up until someone wakes it;
10761
+ // a whole-team sleep only rests the team for now.
10762
+ function memberSleepingByChoice(latest) {
10763
+ return Boolean(latest && latest.status === 'paused' && latest.stop_reason === 'member-sleep'
10764
+ && latest.sleep_target === 'member');
10765
+ }
10766
+
10767
+ function memberUpTeam(root) {
10768
+ const { isMemberParked } = require('../lib/member-park');
10769
+ const missionMap = memberRunMissionMap(root);
10770
+ return findAllMembers(path.join(root, 'atris', 'team')).filter(isFleetMember).map((record) => {
10771
+ const name = memberSlugFromRecord(record);
10772
+ const summary = memberMissionSummary(name, missionMap, root);
10773
+ return {
10774
+ name,
10775
+ parked: isMemberParked(root, name),
10776
+ human: Boolean(memberRefusalFor(name, path.join(root, 'atris', 'team', name, 'MEMBER.md'))),
10777
+ sleeping: memberSleepingByChoice(summary.latest),
10778
+ live: Boolean(summary.live_mission),
10779
+ };
10780
+ });
10781
+ }
10782
+
10783
+ // The task `member go <name>` would claim, skipping ones already handed out.
10784
+ // Looks only; the child claims it.
10785
+ function memberUpPick(root, name, taken) {
10786
+ try {
10787
+ const taskDb = require('../lib/task-db');
10788
+ const { fittingTasks } = require('../lib/member-task-pick');
10789
+ const db = taskDb.open();
10790
+ const job = memberGoJob(name, root);
10791
+ return fittingTasks(taskDb, db, { workspaceRoot: taskDb.workspaceRoot(root), job, requireCheck: true })
10792
+ .find((task) => !taken.has(task.id)) || null;
10793
+ } catch {
10794
+ return null;
10795
+ }
10796
+ }
10797
+
10798
+ function memberUpSpawn(root, name, onExit) {
10799
+ const { spawn } = require('child_process');
10800
+ const logDir = path.join(root, '.atris', 'state', 'member-up');
10801
+ fs.mkdirSync(logDir, { recursive: true });
10802
+ const logFile = path.join(logDir, `${name}.log`);
10803
+ const out = fs.openSync(logFile, 'a');
10804
+ const start = fs.fstatSync(out).size;
10805
+ let child;
10806
+ try {
10807
+ child = spawn(process.execPath, [path.join(__dirname, '..', 'bin', 'atris.js'), 'member', 'go', name, '--needs-check', '--json'], {
10808
+ cwd: root,
10809
+ detached: true,
10810
+ // Children keep their log handle when the foreground supervisor stops.
10811
+ stdio: ['ignore', out, out],
10812
+ env: process.env,
10813
+ });
10814
+ } finally {
10815
+ fs.closeSync(out);
10816
+ }
10817
+ child.on('error', (error) => onExit(null, { ok: false, reason: error.message }));
10818
+ child.on('close', (code, signal) => {
10819
+ let output = '';
10820
+ let fd;
10821
+ try {
10822
+ fd = fs.openSync(logFile, 'r');
10823
+ const size = fs.fstatSync(fd).size;
10824
+ const from = Math.max(start, size - 64 * 1024);
10825
+ const buf = Buffer.alloc(Math.max(0, size - from));
10826
+ const read = fs.readSync(fd, buf, 0, buf.length, from);
10827
+ output = buf.subarray(0, read).toString('utf8');
10828
+ } catch {} finally {
10829
+ if (fd !== undefined) fs.closeSync(fd);
10830
+ }
10831
+ const { memberUpResult } = require('../lib/member-up');
10832
+ onExit(code, memberUpResult(output, '', signal));
10833
+ });
10834
+ return child;
10835
+ }
10836
+
10837
+ async function memberUp(...args) {
10838
+ if (hasFlag(args, '--help') || hasFlag(args, '-h')) {
10839
+ console.log('Usage: atris member up [--max N] [--only a,b] [--dry-run]');
10840
+ console.log('Keeps the team working: every minute, starts each free member that has a fitting task with a runnable check.');
10841
+ console.log('A member with no such task stays idle; it never runs its own goal instead.');
10842
+ console.log('--only limits it to those members. Same as atris member wake 5 or atris member wake a b.');
10843
+ console.log('Ctrl-C or atris member sleep --all ends it. Runs it started keep going.');
10844
+ return;
10845
+ }
10846
+ const root = process.cwd();
10847
+ const { createMemberUp, runMemberUp, parseMemberUpArgs, DEFAULT_MAX } = require('../lib/member-up');
10848
+ const parsed = parseMemberUpArgs(args);
10849
+ if (parsed.error) {
10850
+ console.error(parsed.error);
10851
+ process.exitCode = 1;
10852
+ return;
10853
+ }
10854
+ const { max, only } = parsed;
10855
+ const deps = {
10856
+ listMembers: () => memberUpTeam(root),
10857
+ pickTask: (name, taken) => memberUpPick(root, name, taken),
10858
+ spawn: (name, onExit) => memberUpSpawn(root, name, onExit),
10859
+ now: () => Date.now(),
10860
+ };
10861
+ if (parsed.dryRun) {
10862
+ const up = createMemberUp(deps, { max, only, dryRun: true });
10863
+ const started = up.check();
10864
+ if (!started.length && up.working() >= up.max) {
10865
+ console.log(`nothing to start: ${up.working()} already working, the limit is ${up.max}`);
10866
+ } else if (!started.length) {
10867
+ console.log('nothing to start: no free member has a task with a runnable check');
10868
+ }
10869
+ return started;
10870
+ }
10871
+ const pidFile = memberUpPidFile(root);
10872
+ fs.mkdirSync(path.dirname(pidFile), { recursive: true });
10873
+ fs.writeFileSync(pidFile, JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() }));
10874
+ const loop = runMemberUp(deps, { max, only });
10875
+ const end = () => loop.stop();
10876
+ process.once('SIGINT', end);
10877
+ process.once('SIGTERM', end);
10878
+ const who = only ? ` for ${only.join(', ')}` : '';
10879
+ console.log(`team is up${who} (at most ${max || DEFAULT_MAX} at once). Ctrl-C to stop.`);
10880
+ await loop.done;
10881
+ process.removeListener('SIGINT', end);
10882
+ process.removeListener('SIGTERM', end);
10883
+ try {
10884
+ if (JSON.parse(fs.readFileSync(pidFile, 'utf8')).pid === process.pid) fs.unlinkSync(pidFile);
10885
+ } catch {}
10886
+ const working = loop.up.running();
10887
+ console.log(working.length ? `team is down; still finishing: ${working.join(', ')}` : 'team is down');
10888
+ }
10889
+
10890
+ // Ends a running `atris member up`. Returns true when one was told to stop.
10891
+ function stopMemberUp(root) {
10892
+ try {
10893
+ const { pid } = JSON.parse(fs.readFileSync(memberUpPidFile(root), 'utf8'));
10894
+ if (!Number.isInteger(pid) || pid === process.pid) return false;
10895
+ process.kill(pid, 'SIGTERM');
10896
+ return true;
10897
+ } catch {
10898
+ return false;
10899
+ }
10546
10900
  }
10547
10901
 
10548
10902
  function memberSleep(target, ...args) {
@@ -10568,6 +10922,7 @@ function memberSleep(target, ...args) {
10568
10922
  status: 'paused',
10569
10923
  paused_at: new Date().toISOString(),
10570
10924
  stop_reason: 'member-sleep',
10925
+ sleep_target: filterMember ? 'member' : 'all',
10571
10926
  next_action: `sleeping; wake with: atris member go ${owner || ''}`.trim(),
10572
10927
  updated_at: new Date().toISOString(),
10573
10928
  };
@@ -10577,9 +10932,11 @@ function memberSleep(target, ...args) {
10577
10932
  slept.push({ mission_id: m.id, member: owner || 'mission-lead', objective: m.objective });
10578
10933
  }
10579
10934
 
10935
+ const supervisorStopped = !filterMember && stopMemberUp(root);
10580
10936
  const payload = {
10581
10937
  ok: true,
10582
10938
  action: 'member_sleep',
10939
+ supervisor_stopped: supervisorStopped,
10583
10940
  target: filterMember || 'all',
10584
10941
  slept_count: slept.length,
10585
10942
  slept,
@@ -10595,6 +10952,7 @@ function memberSleep(target, ...args) {
10595
10952
  `All members are already resting.`,
10596
10953
  `Wake anytime: ${payload.wake_command}`,
10597
10954
  ];
10955
+ if (supervisorStopped) lines.unshift('Stopped member up; it starts nobody new.');
10598
10956
  printJsonOrText(payload, lines, asJson);
10599
10957
  return payload;
10600
10958
  }
@@ -10927,14 +11285,26 @@ function memberExport(...args) {
10927
11285
  return result;
10928
11286
  }
10929
11287
 
11288
+ function memberTidy(...args) {
11289
+ const { tidyMemberCopies, formatTidy } = require('../lib/member-tidy');
11290
+ const dirIndex = args.indexOf('--dir');
11291
+ const result = tidyMemberCopies({
11292
+ dryRun: args.includes('--dry-run'),
11293
+ worktreesDir: dirIndex >= 0 ? args[dirIndex + 1] : undefined,
11294
+ });
11295
+ console.log(formatTidy(result));
11296
+ if (result.aborted) process.exitCode = 1;
11297
+ return result;
11298
+ }
11299
+
10930
11300
  async function memberCommand(subcommand, ...args) {
10931
11301
  // Subcommands that take a member name as args[0] otherwise treat `--help` as
10932
11302
  // a name and error with "Member '--help' not found". `create`/`new` handle
10933
11303
  // help themselves (with subcommand-specific usage), leave those alone.
10934
11304
  const HELP_AWARE_SUBCOMMANDS = new Set([
10935
11305
  'create', 'new', 'run', 'install', 'result', 'dispatch', 'route', 'feed', 'stream',
10936
- 'card', 'cards', 'go', 'work', 'steer', 'sleep', 'rest', '1on1', 'interview',
10937
- 'room', 'ui', 'day', 'standup', 'handoff', 'handoffs',
11306
+ 'card', 'cards', 'go', 'work', 'up', 'steer', 'sleep', 'rest', '1on1', 'interview',
11307
+ 'room', 'ui', 'day', 'standup', 'handoff', 'handoffs', 'report',
10938
11308
  ]);
10939
11309
  const explicitHelp = subcommand === 'help' || subcommand === '--help' || subcommand === '-h'
10940
11310
  || (!HELP_AWARE_SUBCOMMANDS.has(subcommand) && (args[0] === '-h' || args[0] === '--help'));
@@ -10971,9 +11341,17 @@ async function memberCommand(subcommand, ...args) {
10971
11341
  case 'tick':
10972
11342
  return memberTick(args[0], ...args.slice(1));
10973
11343
  case 'wake':
10974
- return memberWake(args[0], ...args.slice(1));
11344
+ return require('../lib/member-verbs').memberWakeVerb(args, {
11345
+ wake: memberWake,
11346
+ up: memberUp,
11347
+ });
10975
11348
  case 'run':
10976
- return memberRun(args[0], ...args.slice(1));
11349
+ return require('../lib/member-verbs').memberRunVerb(args, {
11350
+ root: process.cwd(),
11351
+ aliasOf: memberRuntimeKind,
11352
+ run: memberRun,
11353
+ dispatch: memberDispatch,
11354
+ });
10977
11355
  case 'result': {
10978
11356
  const code = require('../lib/member-result').memberResultCommand(args);
10979
11357
  if (code) process.exitCode = code;
@@ -10982,6 +11360,8 @@ async function memberCommand(subcommand, ...args) {
10982
11360
  case 'go':
10983
11361
  case 'work':
10984
11362
  return memberGo(...args);
11363
+ case 'up':
11364
+ return memberUp(...args);
10985
11365
  case 'steer':
10986
11366
  return memberSteerOrDispatch(...args);
10987
11367
  case 'sleep':
@@ -11006,6 +11386,8 @@ async function memberCommand(subcommand, ...args) {
11006
11386
  return memberFeed(...args);
11007
11387
  case 'board':
11008
11388
  return require('../lib/member-board').memberBoardCommand(...args);
11389
+ case 'stop':
11390
+ return require('../lib/member-board').memberStopCommand(args[0]);
11009
11391
  case 'card':
11010
11392
  case 'cards':
11011
11393
  return memberCard(args[0], ...args.slice(1));
@@ -11028,6 +11410,8 @@ async function memberCommand(subcommand, ...args) {
11028
11410
  return memberHeartbeat(args[0], ...args.slice(1));
11029
11411
  case 'improvements':
11030
11412
  return memberImprovements(args[0], ...args.slice(1));
11413
+ case 'report':
11414
+ return require('../lib/member-report').memberReportCommand(...args);
11031
11415
  case 'history':
11032
11416
  return memberHistory(args[0], ...args.slice(1));
11033
11417
  case 'supervisor':
@@ -11042,6 +11426,8 @@ async function memberCommand(subcommand, ...args) {
11042
11426
  return memberArchive(args[0]);
11043
11427
  case 'purge-archived':
11044
11428
  return memberPurgeArchived(...args);
11429
+ case 'tidy':
11430
+ return memberTidy(...args);
11045
11431
  case undefined:
11046
11432
  case '--json':
11047
11433
  if (fs.existsSync(path.join(process.cwd(), 'atris', 'team'))) {
@@ -11056,6 +11442,9 @@ async function memberCommand(subcommand, ...args) {
11056
11442
  }
11057
11443
  return memberSteerOrDispatch(subcommand, ...args);
11058
11444
  }
11445
+ console.log('run "<job>" pick the right member and run it: atris member run "fix the flaky login test"');
11446
+ console.log('wake <N|names> keep members working: atris member wake 5 or atris member wake navigator executor');
11447
+ console.log('sleep [name|--all] rest the team: atris member sleep --all');
11059
11448
  console.log('');
11060
11449
  console.log('Usage: atris member <subcommand|"<prompt>"> [name]');
11061
11450
  console.log('');
@@ -11072,6 +11461,7 @@ async function memberCommand(subcommand, ...args) {
11072
11461
  console.log(' install <slug> [--update] Install missing files, or refresh template skills');
11073
11462
  console.log(' chat <name> "..." Send a message to this member\'s desk in Atris Desktop');
11074
11463
  console.log(' list [--json] Show all team members');
11464
+ console.log(' tidy [--dry-run] Put away member copies of the repo whose work already landed');
11075
11465
  console.log(' export [--dry-run] Write each member as a subagent for Claude Code, Cursor, and Codex');
11076
11466
  console.log(' activate <name> Symlink member skills, show context and permissions');
11077
11467
  console.log(' upgrade <name> Convert flat file (name.md) to directory format');
@@ -11080,10 +11470,13 @@ async function memberCommand(subcommand, ...args) {
11080
11470
  console.log(' goal <name> "..." Create/update a member long-term goal');
11081
11471
  console.log(' goal-from-mission <name> Create/reuse a goal from MISSION.md and now.md');
11082
11472
  console.log(' goal-from-score <name> Create/reuse an active goal from Team score evidence');
11083
- console.log(' wake <name> Decide tick/wait/ask/stop, then run one bounded work step on the roster engine (--plan to only decide)');
11473
+ 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)');
11474
+ console.log(' run "<job>" Pick the best member for the job and run it (same as dispatch "<job>" --ignite)');
11084
11475
  console.log(' run <name> ["..."] Start a budgeted member mission, or run its active Mission Runtime');
11476
+ console.log(' report [--since 2h] Short summary per member: merged, finished, turned down, done but not merged yet [--json]');
11085
11477
  console.log(' result <name> Newest run result: run record, receipt, artifacts, number [--since <iso>] [--json]');
11086
11478
  console.log(' dispatch "<idea>" Route an idea to the best specialist member and start work');
11479
+ 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)');
11087
11480
  console.log(' feed [--member <n>] Show a plain-language linear feed of real work across the team');
11088
11481
  console.log(' card [name|--all] Show a member\'s roadmap, unlocked skills, live work, and recent wins');
11089
11482
  console.log(' loop <name|--all> Repeat wake on a bounded cadence with a no-overlap lease');
@@ -11144,7 +11537,10 @@ async function memberCommand(subcommand, ...args) {
11144
11537
  }
11145
11538
 
11146
11539
  module.exports = {
11540
+ memberRunDefaultWallSeconds,
11541
+ memberRunWantsReferee,
11147
11542
  proposalPromptForGoal,
11543
+ memberGoFinishTask,
11148
11544
  appendMemberGoalLog,
11149
11545
  appendProjectLog,
11150
11546
  memberCommand,