atris 3.73.1 → 3.74.1

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 (51) hide show
  1. package/bin/atris.js +85 -82
  2. package/commands/aeo.js +2 -2
  3. package/commands/align.js +10 -19
  4. package/commands/auth.js +1 -1
  5. package/commands/autopilot.js +4 -8
  6. package/commands/avail.js +36 -7
  7. package/commands/computer.js +3 -1
  8. package/commands/console.js +1 -1
  9. package/commands/context-sync.js +1 -60
  10. package/commands/fleet.js +0 -1
  11. package/commands/lifecycle.js +17 -6
  12. package/commands/log-sync.js +1 -1
  13. package/commands/member.js +158 -18
  14. package/commands/mission.js +157 -69
  15. package/commands/now.js +0 -7
  16. package/commands/plugin.js +1 -1
  17. package/commands/pull.js +1 -1
  18. package/commands/push.js +0 -1
  19. package/commands/radar.js +0 -4
  20. package/commands/recap.js +25 -1
  21. package/commands/report.js +0 -4
  22. package/commands/review.js +1 -1
  23. package/commands/setup.js +6 -2
  24. package/commands/slop.js +8 -2
  25. package/commands/task.js +0 -522
  26. package/commands/terminal.js +1 -1
  27. package/commands/youtube.js +4 -6
  28. package/commands/zen.js +4 -4
  29. package/lib/agent-today.js +412 -0
  30. package/lib/autoland.js +0 -8
  31. package/lib/bench/runner.js +0 -4
  32. package/lib/chat-log-scan.js +0 -28
  33. package/lib/cli-scope.js +4 -2
  34. package/lib/engine-registry.js +0 -4
  35. package/lib/falsifier-probe.js +2 -1
  36. package/lib/file-ops.js +6 -1
  37. package/lib/first-minute.js +2 -0
  38. package/lib/gm-bet.js +125 -30
  39. package/lib/gm-phone-page.js +398 -99
  40. package/lib/gm-phone.js +30 -3
  41. package/lib/handoff-wake.js +180 -0
  42. package/lib/manifest.js +0 -10
  43. package/lib/member-switches.js +0 -11
  44. package/lib/member-up.js +18 -3
  45. package/lib/mission-protected-lane.js +38 -6
  46. package/lib/review-integrity.js +0 -4
  47. package/lib/spawn-env.js +92 -3
  48. package/lib/task-proof.js +2 -1
  49. package/lib/task-receipt.js +3 -2
  50. package/lib/yes-lock.js +633 -81
  51. package/package.json +1 -1
package/commands/align.js CHANGED
@@ -42,15 +42,6 @@ function sleep(ms) {
42
42
  return new Promise((r) => setTimeout(r, ms));
43
43
  }
44
44
 
45
- /**
46
- * Compute SHA-256 hash of content string.
47
- * @param {string} content - Content to hash
48
- * @returns {string} Hex-encoded hash
49
- */
50
- function hashContent(content) {
51
- return crypto.createHash('sha256').update(content).digest('hex');
52
- }
53
-
54
45
  /**
55
46
  * Compute SHA-256 hash of a file.
56
47
  * @param {string} absPath - Absolute path to file
@@ -472,19 +463,19 @@ async function alignAtris() {
472
463
  const printUsage = () => {
473
464
  console.log('Usage: atris align [business] [--fix] [--hard] [--from cloud|local] [--dry-run]');
474
465
  console.log('');
475
- console.log(' atris align Diff current workspace against cloud (auto-detect)');
476
- console.log(' atris align example-co Diff example-co workspace');
477
- console.log(' atris align example-co --fix Fix drift (local is canonical by default)');
478
- console.log(' atris align example-co --fix --hard Force-push: nuke cloud cruft, upload local. Skips diff. Fast.');
479
- console.log(' atris align example-co --fix --from cloud Cloud is canonical: pull EC2-only, delete local extras');
480
- console.log(' atris align example-co --dry-run Show what would change, do nothing');
466
+ console.log(' atris align Show what differs between this folder and its cloud copy');
467
+ console.log(' atris align example-co Show what differs for the example-co workspace');
468
+ console.log(' atris align example-co --fix Make them match, keeping this computer\'s version');
469
+ console.log(' atris align example-co --fix --hard Make the cloud an exact copy of this computer: delete cloud-only files, upload the rest. Fast.');
470
+ console.log(' atris align example-co --fix --from cloud Keep the cloud version: copy cloud-only files down, delete extra local files');
471
+ console.log(' atris align example-co --dry-run Show what would change, change nothing');
481
472
  };
482
473
 
483
474
  // Explicit help must win before we try to auto-detect a business slug,
484
475
  // otherwise `--help` gets overwritten by the .atris/business.json fallback
485
476
  // and the command attempts a real align instead of printing usage.
486
- const firstArg = process.argv[3];
487
- if (firstArg === '--help' || firstArg === '-h' || firstArg === 'help') {
477
+ const alignArgs = process.argv.slice(3);
478
+ if (alignArgs[0] === 'help' || alignArgs.includes('--help') || alignArgs.includes('-h')) {
488
479
  printUsage();
489
480
  process.exit(0);
490
481
  }
@@ -515,7 +506,7 @@ async function alignAtris() {
515
506
  }
516
507
 
517
508
  if (hard && fromSide !== 'local') {
518
- console.error('--hard only supported with --from local (the canonical force-push direction).');
509
+ console.error('--hard only works with --from local: it overwrites the cloud with this computer\'s files, never the other way.');
519
510
  process.exit(1);
520
511
  }
521
512
 
@@ -546,7 +537,7 @@ async function alignAtris() {
546
537
  // Wake EC2 (the rule)
547
538
  const endpoint = await ensureAwake(creds.token, biz.businessId);
548
539
  if (!endpoint) {
549
- console.error(' EC2 computer did not become ready in time. Aborting.');
540
+ console.error(` The cloud computer for "${slug}" did not wake up in time, so nothing ran. Wait a minute, then run the same command again.`);
550
541
  process.exit(1);
551
542
  }
552
543
 
package/commands/auth.js CHANGED
@@ -1,4 +1,4 @@
1
- const { AGENT_TOKEN_EXPIRED_DETAIL, decodeJwtClaims, loadCredentials, saveCredentials, saveAgentClientCredentials, preserveAgentClientLogin, deleteCredentials, getCredentialsPath, openBrowser, promptUser, displayAccountSummary, ensureValidCredentials, loadProfile, listProfiles, profileNameFromEmail, deleteProfile, saveProfile, getTokenExpiryEpochSeconds, getTerminalSessionId, setSessionProfile, getSessionProfile, clearSessionProfile, cleanStaleSessions, getSessionsDir } = require('../utils/auth');
1
+ const { AGENT_TOKEN_EXPIRED_DETAIL, decodeJwtClaims, loadCredentials, saveCredentials, saveAgentClientCredentials, preserveAgentClientLogin, deleteCredentials, getCredentialsPath, openBrowser, promptUser, displayAccountSummary, ensureValidCredentials, loadProfile, listProfiles, profileNameFromEmail, deleteProfile, saveProfile, getTokenExpiryEpochSeconds, getTerminalSessionId, setSessionProfile, getSessionProfile, cleanStaleSessions, getSessionsDir } = require('../utils/auth');
2
2
  const { getAppBaseUrl, getApiBaseUrl, apiRequestJson } = require('../utils/api');
3
3
  const { isNonInteractive, wantsJson } = require('../lib/noninteractive');
4
4
  const { hasFlag, readFlag } = require('../lib/arg-parser');
@@ -1163,7 +1163,10 @@ function lessonSlug(text, maxLen = 40) {
1163
1163
  */
1164
1164
  function writeLesson(cwd, slug, status, explanation) {
1165
1165
  const lessonsPath = path.join(cwd, 'atris', 'lessons.md');
1166
- const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
1166
+ // Local calendar day (YYYY-MM-DD); toISOString is UTC and dates an
1167
+ // evening lesson tomorrow anywhere west of Greenwich.
1168
+ const now = new Date();
1169
+ const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
1167
1170
  const lessonLine = `- **[${today}] ${slug}** - ${status} - ${explanation}`;
1168
1171
 
1169
1172
  if (!fs.existsSync(lessonsPath)) {
@@ -4043,13 +4046,6 @@ Search the codebase to verify. Reply: YES <reason> or NO <reason>`;
4043
4046
  }
4044
4047
  }
4045
4048
 
4046
- /**
4047
- * Entry point when called without a description.
4048
- */
4049
- async function autopilotFromTodo(options = {}) {
4050
- return autopilotAtris(null, options);
4051
- }
4052
-
4053
4049
  module.exports = {
4054
4050
  askModel,
4055
4051
  snapshotRepoHeads,
package/commands/avail.js CHANGED
@@ -117,6 +117,21 @@ function resolveTimezone({ tz, where }) {
117
117
  return 'America/Los_Angeles';
118
118
  }
119
119
 
120
+ function parseWholeNumber(raw, min, max) {
121
+ if (raw === undefined || !/^\d+$/.test(String(raw).trim())) return null;
122
+ const n = parseInt(raw, 10);
123
+ if (n < min || n > max) return null;
124
+ return n;
125
+ }
126
+
127
+ function requireWholeNumber(flag, raw, min, max) {
128
+ const n = parseWholeNumber(raw, min, max);
129
+ if (n === null) {
130
+ throw new Error(`${flag} needs a whole number from ${min} to ${max}, got: ${raw === undefined ? 'nothing' : raw}`);
131
+ }
132
+ return n;
133
+ }
134
+
120
135
  function parseSetArgs(argv) {
121
136
  const opts = {
122
137
  wake: null,
@@ -138,10 +153,10 @@ function parseSetArgs(argv) {
138
153
  else if (a === '--tz') opts.tz = argv[++i];
139
154
  else if (a === '--where') opts.where = argv[++i];
140
155
  else if (a === '--days') opts.days = argv[++i];
141
- else if (a === '--slot-minutes') opts.slotMinutes = parseInt(argv[++i], 10);
142
- else if (a === '--window-days') opts.windowDays = parseInt(argv[++i], 10);
156
+ else if (a === '--slot-minutes') opts.slotMinutes = requireWholeNumber(a, argv[++i], 5, 480);
157
+ else if (a === '--window-days') opts.windowDays = requireWholeNumber(a, argv[++i], 1, 365);
143
158
  else if (a === '--help' || a === '-h') return { help: true };
144
- else throw new Error(`unknown arg: ${a}`);
159
+ else throw new Error(`atris avail set does not take "${a}". Run: atris avail --help`);
145
160
  }
146
161
  if (opts.wake == null || opts.sleep == null) {
147
162
  throw new Error('--wake and --sleep are required (e.g. --wake 2:30pm --sleep 9pm)');
@@ -287,18 +302,30 @@ async function setAvail(argv) {
287
302
  return 0;
288
303
  }
289
304
 
305
+ function parseSlotsDays(raw) {
306
+ return parseWholeNumber(raw, 1, 365);
307
+ }
308
+
290
309
  async function slotsAvail(argv) {
291
310
  const opts = { days: 7, username: null, json: false };
292
311
  for (let i = 0; i < argv.length; i += 1) {
293
312
  const a = argv[i];
294
313
  if (a === '--json') opts.json = true;
295
- else if (a === '--days') opts.days = parseInt(argv[++i], 10);
314
+ else if (a === '--days') {
315
+ const raw = argv[++i];
316
+ const days = parseSlotsDays(raw);
317
+ if (days === null) {
318
+ console.error(`--days needs a whole number from 1 to 365, got: ${raw === undefined ? 'nothing' : raw}`);
319
+ return 1;
320
+ }
321
+ opts.days = days;
322
+ }
296
323
  else if (a === '--user' || a === '--username') opts.username = argv[++i];
297
324
  else if (a === '--help' || a === '-h') {
298
325
  printHelp();
299
326
  return 0;
300
327
  } else {
301
- console.error(`unknown arg: ${a}`);
328
+ console.error(`atris avail slots does not take "${a}". Run: atris avail --help`);
302
329
  return 1;
303
330
  }
304
331
  }
@@ -320,7 +347,7 @@ async function slotsAvail(argv) {
320
347
  try {
321
348
  data = JSON.parse(result.body.toString('utf8'));
322
349
  } catch {
323
- console.error('Invalid slots response');
350
+ console.error(`the booking server sent back a reply atris could not read (HTTP ${result.status}). Try again in a minute.`);
324
351
  return 1;
325
352
  }
326
353
  if (result.status < 200 || result.status >= 300) {
@@ -392,7 +419,7 @@ async function availCommand(argv = []) {
392
419
  const sub = argv[0].toLowerCase();
393
420
  if (sub === 'set' || sub === 'update') return setAvail(argv.slice(1));
394
421
  if (sub === 'slots') return slotsAvail(argv.slice(1));
395
- console.error(`Unknown subcommand: ${sub}`);
422
+ console.error(`atris avail does not know "${sub}". Use show, set, or slots.`);
396
423
  printHelp();
397
424
  return 1;
398
425
  }
@@ -402,6 +429,8 @@ module.exports = {
402
429
  formatBookingLink,
403
430
  parseTimeToDecimal,
404
431
  parseDaysArg,
432
+ parseSlotsDays,
433
+ parseSetArgs,
405
434
  buildAvailableHours,
406
435
  resolveTimezone,
407
436
  formatDecimalHour,
@@ -35,7 +35,7 @@ const {
35
35
  runEngineDeviceLoginCommand,
36
36
  validEngineLoginSeat,
37
37
  } = require('./engine');
38
- const { consoleCommand, gatherAtrisContext, buildSystemPrompt } = require('./console');
38
+ const { consoleCommand } = require('./console');
39
39
  const { streamSession } = require('./serve');
40
40
  const { buildRemoteAtrisBootstrapCommand } = require('../lib/runtime-bootstrap');
41
41
  const { envForcesNonInteractive } = require('../lib/noninteractive');
@@ -4380,6 +4380,7 @@ async function runComputer(argv = process.argv.slice(3), deps = {}) {
4380
4380
  if (!codeopsCtx) {
4381
4381
  console.error('Atris CodeOps is not available for this account.');
4382
4382
  console.error('Ask an Atris CodeOps admin to add you to the atris-codeops business.');
4383
+ process.exitCode = 1;
4383
4384
  return;
4384
4385
  }
4385
4386
  const codeopsOptions = codeOpsCloudOptions(cloudOptions);
@@ -4418,6 +4419,7 @@ async function runComputer(argv = process.argv.slice(3), deps = {}) {
4418
4419
  default:
4419
4420
  console.error(`Unknown CodeOps subcommand: ${codeopsSub}`);
4420
4421
  console.log('Run: atris computer codeops help');
4422
+ process.exitCode = 1;
4421
4423
  return;
4422
4424
  }
4423
4425
  return;
@@ -1,7 +1,7 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
  const os = require('os');
4
- const { spawn, spawnSync } = require('child_process');
4
+ const { spawnSync } = require('child_process');
5
5
  const readline = require('readline');
6
6
  const { resolveClaudeRunnerBin } = require('../lib/runner-command');
7
7
 
@@ -222,65 +222,6 @@ async function businessDiff(slug) {
222
222
  }
223
223
 
224
224
 
225
- /**
226
- * atris log <business-slug>
227
- * Shows human-readable commit history.
228
- */
229
- async function businessLog(slug) {
230
- const biz = await resolveBusiness(slug);
231
-
232
- if (!biz.workspaceId) {
233
- console.error(`Business "${slug}" has no workspace.`);
234
- process.exit(1);
235
- }
236
-
237
- const limit = 20;
238
- const pathFilter = process.argv.includes('--path') ? process.argv[process.argv.indexOf('--path') + 1] : null;
239
-
240
- const params = `limit=${limit}` + (pathFilter ? `&path=${encodeURIComponent(pathFilter)}` : '');
241
- const result = await apiRequestJson(
242
- `/business/${biz.businessId}/workspaces/${biz.workspaceId}/git/log?${params}`,
243
- { method: 'GET', token: biz.token }
244
- );
245
-
246
- if (!result.ok) {
247
- if (result.status === 409) {
248
- console.log('\n Computer is sleeping. Wake it first.\n');
249
- } else {
250
- console.error(`\n Failed to get history: ${result.errorMessage || result.status}\n`);
251
- }
252
- process.exit(1);
253
- }
254
-
255
- const commits = result.data.commits || [];
256
- if (commits.length === 0) {
257
- console.log(`\n ${biz.name}: no history yet.\n`);
258
- return;
259
- }
260
-
261
- console.log(`\n ${biz.name}: history\n`);
262
-
263
- for (const commit of commits) {
264
- const date = _timeSince(commit.date) || commit.date;
265
- const msg = commit.message || '';
266
-
267
- // Parse actor from message format "actor/name: message"
268
- const colonIdx = msg.indexOf(': ');
269
- let actor = '';
270
- let description = msg;
271
- if (colonIdx > 0 && colonIdx < 40) {
272
- actor = msg.substring(0, colonIdx);
273
- description = msg.substring(colonIdx + 2);
274
- }
275
-
276
- const actorDisplay = actor ? ` ${actor}` : '';
277
- console.log(` ${date.padEnd(12)} ${actorDisplay}`);
278
- console.log(` ${description}`);
279
- console.log('');
280
- }
281
- }
282
-
283
-
284
225
  /**
285
226
  * Convert ISO timestamp to human-readable relative time (e.g., "5m ago").
286
227
  * @param {string} isoString - ISO 8601 timestamp
@@ -300,4 +241,4 @@ function _timeSince(isoString) {
300
241
  }
301
242
 
302
243
 
303
- module.exports = { businessStatus, businessDiff, businessLog };
244
+ module.exports = { businessStatus, businessDiff };
package/commands/fleet.js CHANGED
@@ -18,7 +18,6 @@ const https = require('https');
18
18
  const fs = require('fs');
19
19
  const path = require('path');
20
20
  const os = require('os');
21
- const crypto = require('crypto');
22
21
  const { parseScopeFlag } = require('../lib/cli-scope');
23
22
 
24
23
  // ── Config ──────────────────────────────────────────────
@@ -11,6 +11,15 @@ const {
11
11
  setMemberLoopAwake,
12
12
  } = require('../lib/member-switches');
13
13
 
14
+ /**
15
+ * True when the user asked for help. Checked before anything else so
16
+ * `atris sleep --help` inside a bound folder never sleeps that business.
17
+ * @returns {boolean}
18
+ */
19
+ function wantsHelp() {
20
+ return process.argv.slice(3).some((a) => a === '--help' || a === '-h' || a === 'help');
21
+ }
22
+
14
23
  /**
15
24
  * Resolve business slug from CLI arg or local .atris/business.json.
16
25
  * @returns {string|null} The resolved slug, or null if not found.
@@ -121,11 +130,12 @@ async function waitForBusinessComputer(token, business, maxWait = 90000) {
121
130
  * @returns {Promise<void>}
122
131
  */
123
132
  async function sleepAtris() {
124
- if (handleMemberSwitch(false)) return;
133
+ const help = wantsHelp();
134
+ if (!help && handleMemberSwitch(false)) return;
125
135
 
126
- const slug = resolveSlug();
136
+ const slug = help ? null : resolveSlug();
127
137
 
128
- if (!slug || slug === '--help' || slug === '-h' || slug === 'help') {
138
+ if (!slug) {
129
139
  console.log('Usage: atris sleep [business|member] [--loop <loop-id>]');
130
140
  console.log('');
131
141
  console.log(' Pause a workspace to save compute. Storage only.');
@@ -172,11 +182,12 @@ async function sleepAtris() {
172
182
  * @returns {Promise<void>}
173
183
  */
174
184
  async function wakeAtris() {
175
- if (handleMemberSwitch(true)) return;
185
+ const help = wantsHelp();
186
+ if (!help && handleMemberSwitch(true)) return;
176
187
 
177
- const slug = resolveSlug();
188
+ const slug = help ? null : resolveSlug();
178
189
 
179
- if (!slug || slug === '--help' || slug === '-h' || slug === 'help') {
190
+ if (!slug) {
180
191
  console.log('Usage: atris wake [business|member] [--loop <loop-id>]');
181
192
  console.log('');
182
193
  console.log(' Wake a sleeping workspace. Wake signal sent; loops pick it up on their next pass.');
@@ -1,6 +1,6 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
- const { getLogPath, ensureLogDirectory, createLogFile } = require('../lib/file-ops');
3
+ const { getLogPath, createLogFile } = require('../lib/file-ops');
4
4
  const { parseJournalSections, mergeSections, reconstructJournal, showLogDiff } = require('../lib/journal');
5
5
  const { computeContentHash, isSameTimestamp } = require('../lib/journal');
6
6
  const { loadConfig, loadLogSyncState, saveLogSyncState } = require('../utils/config');
@@ -777,6 +777,8 @@ const MEMBER_RUN_START_VALUE_FLAGS = [
777
777
  '--truth',
778
778
  '--proof',
779
779
  '--base',
780
+ '--yes-lock',
781
+ '--yes-lock-parent',
780
782
  ];
781
783
 
782
784
  const MEMBER_RUN_START_BOOLEAN_FLAGS = [
@@ -1000,6 +1002,12 @@ function buildMemberRunStartArgs(owner, missionText, args = [], cwd = process.cw
1000
1002
  pushFlagValue(startArgs, args, '--minutes');
1001
1003
  pushFlagValue(startArgs, args, '--hours');
1002
1004
  pushFlagValue(startArgs, args, '--base');
1005
+ // a run inside a woken member's run keeps its seal: mission start reads it
1006
+ // from the environment, never from these words
1007
+ if (!process.env.ATRIS_HANDOFF_YES_LOCK) {
1008
+ pushFlagValue(startArgs, args, '--yes-lock');
1009
+ pushFlagValue(startArgs, args, '--yes-lock-parent');
1010
+ }
1003
1011
  // Member runs choose and drive work immediately, so an absent check must not
1004
1012
  // leave autonomous callers with a mission that never ran. A check inferred
1005
1013
  // from the mission text counts as named.
@@ -1297,6 +1305,10 @@ function memberRunWith(name, args, { quiet = false, closeTaskIds = [] } = {}) {
1297
1305
  console.error(`${name} is parked; running anyway`);
1298
1306
  }
1299
1307
  } catch { /* an unreadable card is requireMemberDir's problem, not this notice's */ }
1308
+ if (args.filter(require('./mission').isYesLockWord).length > 1) {
1309
+ return printMemberRunResult({ started: false, state: null, mission_id: null,
1310
+ error: 'member run got more than one --yes-lock, so it is not clear which seal applies; nothing started' }, asJson, { quiet });
1311
+ }
1300
1312
  const requestedVerifier = readFlag(args, '--verify', '') || memberRunConfiguredVerifier(paths);
1301
1313
  const missionText = memberRunMissionText(args);
1302
1314
  const hasMissionOverride = Boolean(readFlag(args, '--mission', '') || readFlag(args, '--mission-id', ''));
@@ -1362,7 +1374,7 @@ function memberRunWith(name, args, { quiet = false, closeTaskIds = [] } = {}) {
1362
1374
  const missionWordIndices = missionText && !hasMissionOverride ? new Set(memberRunMissionWordIndices(args)) : new Set();
1363
1375
  const executionArgs = args.filter((_, index) => !missionWordIndices.has(index));
1364
1376
  const runArgs = stripKnownFlags(executionArgs,
1365
- ['--mission', '--mission-id', '--minutes', '--hours', '--industry', '--domain', '--value', '--outcome', '--truth', '--proof', '--base'],
1377
+ ['--mission', '--mission-id', '--minutes', '--hours', '--industry', '--domain', '--value', '--outcome', '--truth', '--proof', '--base', '--yes-lock', '--yes-lock-parent'],
1366
1378
  ['--worktree', '--shared-checkout', '--no-worktree', '--choose-work', '--auto', '--ignite', '--wait', '--sync']);
1367
1379
  if (ignite && !hasFlag(runArgs, '--complete-on-pass')) runArgs.push('--complete-on-pass');
1368
1380
  if (memberRunWantsReferee(runArgs, ignite)) runArgs.push('--referee');
@@ -1374,6 +1386,16 @@ function memberRunWith(name, args, { quiet = false, closeTaskIds = [] } = {}) {
1374
1386
  const caller = callerRuntime(args);
1375
1387
  const runnerSwap = memberRunRunnerSwap(mission, args, caller);
1376
1388
  if (runnerSwap) runArgs.push('--engine', runnerSwap.to);
1389
+ // A run in the foreground (wake, go, run by name) is noted on the start
1390
+ // list under the shared lock, so member up and handoffs count it while it
1391
+ // works. A detached run is noted by whoever started it.
1392
+ if (!hasFlag(runArgs, '--detach') && !hasFlag(runArgs, '--dry-run')) {
1393
+ try {
1394
+ const stateDir = path.join(require('./mission').resolveMemberWorkspaceRoot(process.cwd()), '.atris', 'state');
1395
+ require('../lib/handoff-wake').withWakeReservation(stateDir,
1396
+ ({ record }) => record({ member: name, mission_id: mission.id, pid: process.pid }), { waitMs: 30000 });
1397
+ } catch {}
1398
+ }
1377
1399
  const run = memberRunCli(['mission', 'run', mission.id, ...runArgs], cwd, { live: !asJson && !quiet, missionId: mission.id });
1378
1400
  const payload = run.payload || {};
1379
1401
  const finalMission = payload.mission || require('./mission').resolveMission(mission.id, cwd) || mission;
@@ -4491,13 +4513,15 @@ function memberList(...flags) {
4491
4513
 
4492
4514
  console.log('');
4493
4515
  console.log('Team Members');
4494
- console.log('─'.repeat(70));
4516
+ console.log('─'.repeat(72));
4495
4517
 
4496
4518
  const nameW = 16;
4497
- const roleW = 16;
4498
- const fmtW = 6;
4519
+ const roleW = 18;
4520
+ const fmtW = 8;
4499
4521
  const skillW = 8;
4500
- const ctxW = 8;
4522
+ const ctxW = 9;
4523
+ // Keep one space between columns, even when a value fills its column.
4524
+ const cell = (value, width) => `${clipCell(value, width - 1)} `;
4501
4525
 
4502
4526
  console.log(
4503
4527
  ' ' +
@@ -4508,18 +4532,18 @@ function memberList(...flags) {
4508
4532
  'Context'.padEnd(ctxW) +
4509
4533
  'Version'
4510
4534
  );
4511
- console.log(' ' + '─'.repeat(66));
4535
+ console.log(' ' + '─'.repeat(70));
4512
4536
 
4513
4537
  for (const m of members) {
4514
4538
  const skills = m.format === 'directory' ? String(m.skillCount) : '-';
4515
4539
  const context = m.format === 'directory' ? String(m.contextCount) : '-';
4516
4540
  console.log(
4517
4541
  ' ' +
4518
- clipCell(m.name, nameW) +
4519
- clipCell(m.role, roleW) +
4520
- clipCell(m.format === 'directory' ? 'dir' : 'flat', fmtW) +
4521
- clipCell(skills, skillW) +
4522
- clipCell(context, ctxW) +
4542
+ cell(m.name, nameW) +
4543
+ cell(m.role, roleW) +
4544
+ cell(m.format === 'directory' ? 'dir' : 'flat', fmtW) +
4545
+ cell(skills, skillW) +
4546
+ cell(context, ctxW) +
4523
4547
  (m.version || '-') +
4524
4548
  (parked.has(m.name) ? ' parked' : '')
4525
4549
  );
@@ -9865,7 +9889,11 @@ async function memberImprovements(businessId, ...args) {
9865
9889
  console.error('Not logged in. Run: atris login');
9866
9890
  process.exit(1);
9867
9891
  }
9868
- const days = flags.days && Number.isFinite(flags.days) ? flags.days : 30;
9892
+ if (flags.days !== null && !(Number.isInteger(flags.days) && flags.days >= 1)) {
9893
+ console.error('--days needs a whole number of days, 1 or more (example: --days 7).');
9894
+ process.exit(1);
9895
+ }
9896
+ const days = flags.days || 30;
9869
9897
  const result = await apiRequestJson(`/business/${encodeURIComponent(businessId)}/improvements/daily?days=${days}`, { token: creds.token });
9870
9898
  if (!result.ok) {
9871
9899
  console.error(`Failed to fetch improvements: ${result.error || 'Unknown error'}`);
@@ -11134,6 +11162,8 @@ async function memberUp(...args) {
11134
11162
  pickTask: (name, taken) => memberUpPick(root, name, taken),
11135
11163
  writeNextJob: (name, opts) => memberUpWriteNextJob(root, name, opts),
11136
11164
  spawn: (name, onExit, onClaim) => memberUpSpawn(root, name, onExit, onClaim),
11165
+ // the start lock handoffs share, so both count what the other started
11166
+ reserve: (fn) => require('../lib/handoff-wake').withWakeReservation(path.join(root, '.atris', 'state'), fn),
11137
11167
  now: () => Date.now(),
11138
11168
  jobOf: (name) => memberGoJob(name, root),
11139
11169
  waitingTasks: () => memberUpWaiting(root),
@@ -11352,10 +11382,108 @@ function collectMemberOneOnOnes(root = process.cwd(), slugs = []) {
11352
11382
  return queue;
11353
11383
  }
11354
11384
 
11385
+ // The env a woken member starts with: its own plain-terminal run, not part of
11386
+ // the session or mission driver that handed off (those markers would turn the
11387
+ // run into proof-only mode, a native-goal wait, or a child of the old driver).
11388
+ const HANDOFF_WAKE_SCRUB_ENV = [
11389
+ 'CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT', 'CODEX_SANDBOX', 'CURSOR_AGENT', 'DEVIN_SESSION_ID',
11390
+ 'CODEX_THREAD_ID', 'CODEX_SESSION_ID', 'ATRIS_AGENT_ID',
11391
+ 'ATRIS_MISSION_DRIVER_DETACHED', 'ATRIS_MISSION_DRIVER_PARENT_PID',
11392
+ ];
11393
+
11394
+ // Start the receiver of a handoff on the handed-off step: the same bounded
11395
+ // work step `atris member wake <name>` runs (member run, 15 minutes), but
11396
+ // detached, so the member handing off is not held while the next one works.
11397
+ function startHandoffReceiver(root, to, step, parentMissionId = null) {
11398
+ const env = { ...process.env, ATRIS_CALLER_RUNTIME: 'terminal' };
11399
+ for (const name of HANDOFF_WAKE_SCRUB_ENV) delete env[name];
11400
+ // The handing-off mission's git guard (a temp folder of wrappers first on
11401
+ // PATH) belongs to that mission; the woken run sets up its own.
11402
+ env.PATH = String(env.PATH || '').split(path.delimiter)
11403
+ .filter((dir) => !path.basename(dir).startsWith(require('../lib/mission-protected-lane').GUARD_DIR_PREFIX))
11404
+ .join(path.delimiter);
11405
+ // Every woken run is under the fixed handoff seal, whatever handed off:
11406
+ // outward commands and merging to master wait for the owner's yes. The
11407
+ // parent only says where its yes cards show.
11408
+ // The seal goes by environment only, never on the command line the step
11409
+ // text shares, and every job the woken run starts inherits it. A step that
11410
+ // starts with "-" is quoted so it can never read as an option.
11411
+ env.ATRIS_HANDOFF_YES_LOCK = JSON.stringify(require('../lib/yes-lock').handoffSeal());
11412
+ if (parentMissionId) env.ATRIS_HANDOFF_YES_LOCK_PARENT = parentMissionId;
11413
+ else delete env.ATRIS_HANDOFF_YES_LOCK_PARENT;
11414
+ const stepText = /^\s*-/.test(String(step)) ? `step: ${String(step).trim()}` : String(step);
11415
+ const res = require('child_process').spawnSync(process.execPath, [
11416
+ path.join(__dirname, '..', 'bin', 'atris.js'),
11417
+ 'member', 'run', to, stepText, '--minutes', '15', '--no-verify', '--detach', '--json',
11418
+ ], { cwd: root, env, encoding: 'utf8', timeout: 120000 });
11419
+ let payload = null;
11420
+ const lines = String(res.stdout || '').trim().split('\n');
11421
+ for (let i = lines.length - 1; i >= 0 && !payload; i -= 1) {
11422
+ try { payload = JSON.parse(lines.slice(i).join('\n')); } catch {}
11423
+ }
11424
+ const ok = res.status === 0 && Boolean(payload && payload.ok !== false && payload.mission_id);
11425
+ return {
11426
+ ok,
11427
+ mission_id: payload ? payload.mission_id || null : null,
11428
+ pid: payload ? payload.pid || null : null,
11429
+ worktree: payload && payload.worktree ? payload.worktree.path || null : null,
11430
+ error: ok ? null : String((payload && payload.error) || res.stderr || (res.error && res.error.message) || `member run exited ${res.status}`),
11431
+ };
11432
+ }
11433
+
11434
+ // Whether to wake the receiver, and the outcome. Under the test runner a
11435
+ // handoff only records unless ATRIS_HANDOFF_WAKE=1 asks, so suites that hand
11436
+ // off never start a real engine by accident.
11437
+ function wakeOnHandoff(handoff, root, args) {
11438
+ const testRun = Boolean(process.env.NODE_TEST_CONTEXT) && process.env.ATRIS_HANDOFF_WAKE !== '1';
11439
+ if (hasFlag(args, '--no-wake') || testRun) {
11440
+ return {
11441
+ status: 'not_started',
11442
+ reason: 'not_asked',
11443
+ text: `${handoff.to} was not started; the handoff is only written down. to start it: atris member wake ${handoff.to}`,
11444
+ };
11445
+ }
11446
+ const { wakeHandoffReceiver, withWakeReservation } = require('../lib/handoff-wake');
11447
+ const parent = handoffParentMission(handoff, root);
11448
+ const outcome = wakeHandoffReceiver(handoff, {
11449
+ listMembers: () => memberUpTeam(root),
11450
+ awake: (name) => {
11451
+ try { return require('../lib/member-switches').isMemberAwake(name, root); } catch { return true; }
11452
+ },
11453
+ engine: (name) => require('../lib/member-engine').memberRosterEngine(name, root),
11454
+ start: (name, step) => startHandoffReceiver(root, name, step, parent),
11455
+ reserve: (fn) => withWakeReservation(path.join(root, '.atris', 'state'), fn),
11456
+ });
11457
+ return outcome.status === 'started' ? { ...outcome, yes_lock: 'handoff', yes_lock_parent: parent } : outcome;
11458
+ }
11459
+
11460
+ // The job a handoff came from, only to show the woken run's yes cards on it:
11461
+ // the mission whose guard this command runs under (a handoff from inside a
11462
+ // mission tick), else the job a [bet-...] tag in the step names. null when
11463
+ // neither is found; what the woken run holds never depends on it.
11464
+ function handoffParentMission(handoff, root) {
11465
+ const fromGuard = require('../lib/mission-protected-lane').guardMissionOnPath(process.env.PATH);
11466
+ if (fromGuard) return fromGuard;
11467
+ const bets = Array.from(String(handoff.summary || '').matchAll(/\[(bet-[A-Za-z0-9._-]+)\]/g)).map((m) => m[1]);
11468
+ if (!bets.length) return null;
11469
+ const dir = path.join(require('../lib/state-home').atrisHome(), 'state', 'yes-locks');
11470
+ let files = [];
11471
+ try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.json')); } catch {}
11472
+ for (const name of files) {
11473
+ try {
11474
+ const data = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'));
11475
+ if (data && data.mission_id && bets.includes(data.bet_id)) return String(data.mission_id);
11476
+ } catch {}
11477
+ }
11478
+ return null;
11479
+ }
11480
+
11355
11481
  function memberHandoff(from, to, ...args) {
11356
11482
  if (from === '--help' || from === '-h' || hasFlag(args, '--help') || hasFlag(args, '-h')) {
11357
- console.log('Usage: atris member handoff [<from> <to> "<slice>"] [--json]');
11483
+ console.log('Usage: atris member handoff [<from> <to> "<slice>"] [--no-wake] [--json]');
11358
11484
  console.log('Passes a completed slice from one member to the next specialist and queues their next_push.');
11485
+ console.log('The next member is started on the slice when it is not already working, unless the team is at its limit.');
11486
+ console.log('--no-wake only writes the handoff down.');
11359
11487
  return;
11360
11488
  }
11361
11489
  const root = process.cwd();
@@ -11374,17 +11502,21 @@ function memberHandoff(from, to, ...args) {
11374
11502
  }
11375
11503
  const targetTo = sliceWords[0];
11376
11504
  const summary = sliceWords.slice(1).join(' ').trim();
11377
- const { recordMemberHandoff } = require('./mission');
11378
- const handoff = recordMemberHandoff(from, targetTo, summary, root);
11379
- if (!handoff) {
11505
+ const { recordMemberHandoff, updateMemberHandoff, resolveMemberWorkspaceRoot } = require('./mission');
11506
+ const recorded = recordMemberHandoff(from, targetTo, summary, root);
11507
+ if (!recorded) {
11380
11508
  console.error(`Could not record handoff from ${from} to ${targetTo}. Check member names.`);
11381
11509
  process.exitCode = 1;
11382
11510
  return null;
11383
11511
  }
11512
+ const parentRoot = resolveMemberWorkspaceRoot(root);
11513
+ // the stored summary is cut at 180 characters; the wake gets the whole step
11514
+ const wake = { ...wakeOnHandoff({ ...recorded, summary }, parentRoot, args), at: new Date().toISOString() };
11515
+ const handoff = updateMemberHandoff(recorded.id, { wake }, parentRoot) || { ...recorded, wake };
11384
11516
  const lines = [
11385
11517
  `Handoff recorded: ${handoff.from} -> ${handoff.to}`,
11386
11518
  `Slice: ${handoff.summary}`,
11387
- `Next: ${handoff.next_command}`,
11519
+ wake.text,
11388
11520
  ];
11389
11521
  printJsonOrText({ ok: true, action: 'member_handoff', handoff }, lines, asJson);
11390
11522
  return handoff;
@@ -11773,10 +11905,18 @@ async function memberCommand(subcommand, ...args) {
11773
11905
  // more often than a job. Starting a paid run on it costs money and
11774
11906
  // does nothing useful, so name the miss and stop. Jobs keep working
11775
11907
  // as more than one word, or through `atris member run "<job>"`.
11776
- const extraWords = args.filter((a) => !String(a).startsWith('-')).length;
11908
+ const extraWordList = args.filter((a) => !String(a).startsWith('-'));
11909
+ const extraWords = extraWordList.length;
11777
11910
  if (!/\s/.test(subcommand.trim()) && extraWords === 0) {
11778
11911
  return memberUnknownWord(subcommand);
11779
11912
  }
11913
+ // `atris member inbox validator`: an unknown word followed by a member
11914
+ // name is a command shape (verb, then who), not a job. It once started
11915
+ // a mission called "inbox validator"; name the miss instead.
11916
+ if (!/\s/.test(subcommand.trim()) && extraWords === 1
11917
+ && isKnownMemberSlug(String(extraWordList[0]).trim(), process.cwd())) {
11918
+ return memberUnknownWord(subcommand);
11919
+ }
11780
11920
  return memberSteerOrDispatch(subcommand, ...args);
11781
11921
  }
11782
11922
  console.log('run "<job>" pick the right member and run it: atris member run "fix the flaky login test"');