atris 3.73.0 → 3.74.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.
@@ -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;
@@ -11134,6 +11156,8 @@ async function memberUp(...args) {
11134
11156
  pickTask: (name, taken) => memberUpPick(root, name, taken),
11135
11157
  writeNextJob: (name, opts) => memberUpWriteNextJob(root, name, opts),
11136
11158
  spawn: (name, onExit, onClaim) => memberUpSpawn(root, name, onExit, onClaim),
11159
+ // the start lock handoffs share, so both count what the other started
11160
+ reserve: (fn) => require('../lib/handoff-wake').withWakeReservation(path.join(root, '.atris', 'state'), fn),
11137
11161
  now: () => Date.now(),
11138
11162
  jobOf: (name) => memberGoJob(name, root),
11139
11163
  waitingTasks: () => memberUpWaiting(root),
@@ -11352,10 +11376,108 @@ function collectMemberOneOnOnes(root = process.cwd(), slugs = []) {
11352
11376
  return queue;
11353
11377
  }
11354
11378
 
11379
+ // The env a woken member starts with: its own plain-terminal run, not part of
11380
+ // the session or mission driver that handed off (those markers would turn the
11381
+ // run into proof-only mode, a native-goal wait, or a child of the old driver).
11382
+ const HANDOFF_WAKE_SCRUB_ENV = [
11383
+ 'CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT', 'CODEX_SANDBOX', 'CURSOR_AGENT', 'DEVIN_SESSION_ID',
11384
+ 'CODEX_THREAD_ID', 'CODEX_SESSION_ID', 'ATRIS_AGENT_ID',
11385
+ 'ATRIS_MISSION_DRIVER_DETACHED', 'ATRIS_MISSION_DRIVER_PARENT_PID',
11386
+ ];
11387
+
11388
+ // Start the receiver of a handoff on the handed-off step: the same bounded
11389
+ // work step `atris member wake <name>` runs (member run, 15 minutes), but
11390
+ // detached, so the member handing off is not held while the next one works.
11391
+ function startHandoffReceiver(root, to, step, parentMissionId = null) {
11392
+ const env = { ...process.env, ATRIS_CALLER_RUNTIME: 'terminal' };
11393
+ for (const name of HANDOFF_WAKE_SCRUB_ENV) delete env[name];
11394
+ // The handing-off mission's git guard (a temp folder of wrappers first on
11395
+ // PATH) belongs to that mission; the woken run sets up its own.
11396
+ env.PATH = String(env.PATH || '').split(path.delimiter)
11397
+ .filter((dir) => !path.basename(dir).startsWith(require('../lib/mission-protected-lane').GUARD_DIR_PREFIX))
11398
+ .join(path.delimiter);
11399
+ // Every woken run is under the fixed handoff seal, whatever handed off:
11400
+ // outward commands and merging to master wait for the owner's yes. The
11401
+ // parent only says where its yes cards show.
11402
+ // The seal goes by environment only, never on the command line the step
11403
+ // text shares, and every job the woken run starts inherits it. A step that
11404
+ // starts with "-" is quoted so it can never read as an option.
11405
+ env.ATRIS_HANDOFF_YES_LOCK = JSON.stringify(require('../lib/yes-lock').handoffSeal());
11406
+ if (parentMissionId) env.ATRIS_HANDOFF_YES_LOCK_PARENT = parentMissionId;
11407
+ else delete env.ATRIS_HANDOFF_YES_LOCK_PARENT;
11408
+ const stepText = /^\s*-/.test(String(step)) ? `step: ${String(step).trim()}` : String(step);
11409
+ const res = require('child_process').spawnSync(process.execPath, [
11410
+ path.join(__dirname, '..', 'bin', 'atris.js'),
11411
+ 'member', 'run', to, stepText, '--minutes', '15', '--no-verify', '--detach', '--json',
11412
+ ], { cwd: root, env, encoding: 'utf8', timeout: 120000 });
11413
+ let payload = null;
11414
+ const lines = String(res.stdout || '').trim().split('\n');
11415
+ for (let i = lines.length - 1; i >= 0 && !payload; i -= 1) {
11416
+ try { payload = JSON.parse(lines.slice(i).join('\n')); } catch {}
11417
+ }
11418
+ const ok = res.status === 0 && Boolean(payload && payload.ok !== false && payload.mission_id);
11419
+ return {
11420
+ ok,
11421
+ mission_id: payload ? payload.mission_id || null : null,
11422
+ pid: payload ? payload.pid || null : null,
11423
+ worktree: payload && payload.worktree ? payload.worktree.path || null : null,
11424
+ error: ok ? null : String((payload && payload.error) || res.stderr || (res.error && res.error.message) || `member run exited ${res.status}`),
11425
+ };
11426
+ }
11427
+
11428
+ // Whether to wake the receiver, and the outcome. Under the test runner a
11429
+ // handoff only records unless ATRIS_HANDOFF_WAKE=1 asks, so suites that hand
11430
+ // off never start a real engine by accident.
11431
+ function wakeOnHandoff(handoff, root, args) {
11432
+ const testRun = Boolean(process.env.NODE_TEST_CONTEXT) && process.env.ATRIS_HANDOFF_WAKE !== '1';
11433
+ if (hasFlag(args, '--no-wake') || testRun) {
11434
+ return {
11435
+ status: 'not_started',
11436
+ reason: 'not_asked',
11437
+ text: `${handoff.to} was not started; the handoff is only written down. to start it: atris member wake ${handoff.to}`,
11438
+ };
11439
+ }
11440
+ const { wakeHandoffReceiver, withWakeReservation } = require('../lib/handoff-wake');
11441
+ const parent = handoffParentMission(handoff, root);
11442
+ const outcome = wakeHandoffReceiver(handoff, {
11443
+ listMembers: () => memberUpTeam(root),
11444
+ awake: (name) => {
11445
+ try { return require('../lib/member-switches').isMemberAwake(name, root); } catch { return true; }
11446
+ },
11447
+ engine: (name) => require('../lib/member-engine').memberRosterEngine(name, root),
11448
+ start: (name, step) => startHandoffReceiver(root, name, step, parent),
11449
+ reserve: (fn) => withWakeReservation(path.join(root, '.atris', 'state'), fn),
11450
+ });
11451
+ return outcome.status === 'started' ? { ...outcome, yes_lock: 'handoff', yes_lock_parent: parent } : outcome;
11452
+ }
11453
+
11454
+ // The job a handoff came from, only to show the woken run's yes cards on it:
11455
+ // the mission whose guard this command runs under (a handoff from inside a
11456
+ // mission tick), else the job a [bet-...] tag in the step names. null when
11457
+ // neither is found; what the woken run holds never depends on it.
11458
+ function handoffParentMission(handoff, root) {
11459
+ const fromGuard = require('../lib/mission-protected-lane').guardMissionOnPath(process.env.PATH);
11460
+ if (fromGuard) return fromGuard;
11461
+ const bets = Array.from(String(handoff.summary || '').matchAll(/\[(bet-[A-Za-z0-9._-]+)\]/g)).map((m) => m[1]);
11462
+ if (!bets.length) return null;
11463
+ const dir = path.join(require('../lib/state-home').atrisHome(), 'state', 'yes-locks');
11464
+ let files = [];
11465
+ try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.json')); } catch {}
11466
+ for (const name of files) {
11467
+ try {
11468
+ const data = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'));
11469
+ if (data && data.mission_id && bets.includes(data.bet_id)) return String(data.mission_id);
11470
+ } catch {}
11471
+ }
11472
+ return null;
11473
+ }
11474
+
11355
11475
  function memberHandoff(from, to, ...args) {
11356
11476
  if (from === '--help' || from === '-h' || hasFlag(args, '--help') || hasFlag(args, '-h')) {
11357
- console.log('Usage: atris member handoff [<from> <to> "<slice>"] [--json]');
11477
+ console.log('Usage: atris member handoff [<from> <to> "<slice>"] [--no-wake] [--json]');
11358
11478
  console.log('Passes a completed slice from one member to the next specialist and queues their next_push.');
11479
+ console.log('The next member is started on the slice when it is not already working, unless the team is at its limit.');
11480
+ console.log('--no-wake only writes the handoff down.');
11359
11481
  return;
11360
11482
  }
11361
11483
  const root = process.cwd();
@@ -11374,17 +11496,21 @@ function memberHandoff(from, to, ...args) {
11374
11496
  }
11375
11497
  const targetTo = sliceWords[0];
11376
11498
  const summary = sliceWords.slice(1).join(' ').trim();
11377
- const { recordMemberHandoff } = require('./mission');
11378
- const handoff = recordMemberHandoff(from, targetTo, summary, root);
11379
- if (!handoff) {
11499
+ const { recordMemberHandoff, updateMemberHandoff, resolveMemberWorkspaceRoot } = require('./mission');
11500
+ const recorded = recordMemberHandoff(from, targetTo, summary, root);
11501
+ if (!recorded) {
11380
11502
  console.error(`Could not record handoff from ${from} to ${targetTo}. Check member names.`);
11381
11503
  process.exitCode = 1;
11382
11504
  return null;
11383
11505
  }
11506
+ const parentRoot = resolveMemberWorkspaceRoot(root);
11507
+ // the stored summary is cut at 180 characters; the wake gets the whole step
11508
+ const wake = { ...wakeOnHandoff({ ...recorded, summary }, parentRoot, args), at: new Date().toISOString() };
11509
+ const handoff = updateMemberHandoff(recorded.id, { wake }, parentRoot) || { ...recorded, wake };
11384
11510
  const lines = [
11385
11511
  `Handoff recorded: ${handoff.from} -> ${handoff.to}`,
11386
11512
  `Slice: ${handoff.summary}`,
11387
- `Next: ${handoff.next_command}`,
11513
+ wake.text,
11388
11514
  ];
11389
11515
  printJsonOrText({ ok: true, action: 'member_handoff', handoff }, lines, asJson);
11390
11516
  return handoff;
@@ -11773,10 +11899,18 @@ async function memberCommand(subcommand, ...args) {
11773
11899
  // more often than a job. Starting a paid run on it costs money and
11774
11900
  // does nothing useful, so name the miss and stop. Jobs keep working
11775
11901
  // as more than one word, or through `atris member run "<job>"`.
11776
- const extraWords = args.filter((a) => !String(a).startsWith('-')).length;
11902
+ const extraWordList = args.filter((a) => !String(a).startsWith('-'));
11903
+ const extraWords = extraWordList.length;
11777
11904
  if (!/\s/.test(subcommand.trim()) && extraWords === 0) {
11778
11905
  return memberUnknownWord(subcommand);
11779
11906
  }
11907
+ // `atris member inbox validator`: an unknown word followed by a member
11908
+ // name is a command shape (verb, then who), not a job. It once started
11909
+ // a mission called "inbox validator"; name the miss instead.
11910
+ if (!/\s/.test(subcommand.trim()) && extraWords === 1
11911
+ && isKnownMemberSlug(String(extraWordList[0]).trim(), process.cwd())) {
11912
+ return memberUnknownWord(subcommand);
11913
+ }
11780
11914
  return memberSteerOrDispatch(subcommand, ...args);
11781
11915
  }
11782
11916
  console.log('run "<job>" pick the right member and run it: atris member run "fix the flaky login test"');
@@ -1905,15 +1905,58 @@ function recordMemberHandoff(fromMember, toMember, sliceSummary, root = process.
1905
1905
  next_command: `atris member go ${to}`,
1906
1906
  created_at: now,
1907
1907
  };
1908
+ const handoffsFile = path.join(parentRoot, '.atris', 'state', 'member-handoffs.jsonl');
1909
+ try {
1910
+ withHandoffsLock(handoffsFile, () => fs.appendFileSync(handoffsFile, `${JSON.stringify(handoff)}\n`, 'utf8'));
1911
+ } catch {
1912
+ return null;
1913
+ }
1908
1914
  try {
1909
- const handoffsFile = path.join(parentRoot, '.atris', 'state', 'member-handoffs.jsonl');
1910
- fs.mkdirSync(path.dirname(handoffsFile), { recursive: true });
1911
- fs.appendFileSync(handoffsFile, `${JSON.stringify(handoff)}\n`, 'utf8');
1912
1915
  autoQueueMemberNextPush(to, `${from} handoff: ${cleanSummary}`, `Baton pass from ${from}`, parentRoot);
1913
1916
  } catch {}
1914
1917
  return handoff;
1915
1918
  }
1916
1919
 
1920
+ // Every write to member-handoffs.jsonl (append or whole-file rewrite) holds
1921
+ // one lock file beside it, so concurrent handoffs never lose a row.
1922
+ // (Codex review round 3 on #1483.)
1923
+ function withHandoffsLock(handoffsFile, fn) {
1924
+ fs.mkdirSync(path.dirname(handoffsFile), { recursive: true });
1925
+ const lockFile = path.join(path.dirname(handoffsFile), 'member-handoffs.lock');
1926
+ return require('../lib/handoff-wake').withFileLock(lockFile, fn, { staleMs: 60 * 1000, waitMs: 30 * 1000 });
1927
+ }
1928
+
1929
+ function rewriteHandoffs(handoffsFile, lines) {
1930
+ const tmp = `${handoffsFile}.${process.pid}.tmp`;
1931
+ fs.writeFileSync(tmp, `${lines.join('\n')}\n`, 'utf8');
1932
+ fs.renameSync(tmp, handoffsFile);
1933
+ }
1934
+
1935
+ // Merge `patch` into the newest handoff row with this id. Returns the
1936
+ // updated row, or null when it is not on file.
1937
+ function updateMemberHandoff(handoffId, patch, root = process.cwd()) {
1938
+ const id = String(handoffId || '');
1939
+ if (!id || !patch) return null;
1940
+ const handoffsFile = path.join(resolveMemberWorkspaceRoot(root), '.atris', 'state', 'member-handoffs.jsonl');
1941
+ try {
1942
+ return withHandoffsLock(handoffsFile, () => {
1943
+ const lines = fs.readFileSync(handoffsFile, 'utf8').split(/\r?\n/).filter((l) => l.trim());
1944
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
1945
+ let row = null;
1946
+ try { row = JSON.parse(lines[i]); } catch {}
1947
+ if (!row || row.id !== id) continue;
1948
+ const updated = { ...row, ...patch };
1949
+ lines[i] = JSON.stringify(updated);
1950
+ rewriteHandoffs(handoffsFile, lines);
1951
+ return updated;
1952
+ }
1953
+ return null;
1954
+ });
1955
+ } catch {
1956
+ return null;
1957
+ }
1958
+ }
1959
+
1917
1960
  function closeMemberHandoffsForReceiver(receiverMember, root = process.cwd()) {
1918
1961
  const receiver = String(receiverMember || '').trim().toLowerCase();
1919
1962
  if (!receiver) return 0;
@@ -1921,28 +1964,30 @@ function closeMemberHandoffsForReceiver(receiverMember, root = process.cwd()) {
1921
1964
  const handoffsFile = path.join(parentRoot, '.atris', 'state', 'member-handoffs.jsonl');
1922
1965
  if (!fs.existsSync(handoffsFile)) return 0;
1923
1966
  try {
1924
- const rawLines = fs.readFileSync(handoffsFile, 'utf8').split(/\r?\n/).filter((l) => l.trim());
1925
- let closedCount = 0;
1926
- const now = stampIso();
1927
- const updatedLines = rawLines.map((line) => {
1928
- try {
1929
- const row = JSON.parse(line);
1930
- if (row && String(row.to || '').toLowerCase() === receiver && (row.status || 'open') === 'open') {
1931
- closedCount++;
1932
- return JSON.stringify({ ...row, status: 'closed', closed_at: now });
1933
- }
1934
- } catch {}
1935
- return line;
1936
- });
1937
- if (closedCount > 0) {
1938
- fs.writeFileSync(handoffsFile, `${updatedLines.join('\n')}\n`, 'utf8');
1939
- }
1940
- return closedCount;
1967
+ return withHandoffsLock(handoffsFile, () => closeHandoffRows(handoffsFile, receiver));
1941
1968
  } catch {
1942
1969
  return 0;
1943
1970
  }
1944
1971
  }
1945
1972
 
1973
+ function closeHandoffRows(handoffsFile, receiver) {
1974
+ const rawLines = fs.readFileSync(handoffsFile, 'utf8').split(/\r?\n/).filter((l) => l.trim());
1975
+ let closedCount = 0;
1976
+ const now = stampIso();
1977
+ const updatedLines = rawLines.map((line) => {
1978
+ try {
1979
+ const row = JSON.parse(line);
1980
+ if (row && String(row.to || '').toLowerCase() === receiver && (row.status || 'open') === 'open') {
1981
+ closedCount++;
1982
+ return JSON.stringify({ ...row, status: 'closed', closed_at: now });
1983
+ }
1984
+ } catch {}
1985
+ return line;
1986
+ });
1987
+ if (closedCount > 0) rewriteHandoffs(handoffsFile, updatedLines);
1988
+ return closedCount;
1989
+ }
1990
+
1946
1991
  function memberMissionFile(owner, root = process.cwd()) {
1947
1992
  const dir = memberDir(owner, root);
1948
1993
  if (!dir) return null;
@@ -3217,6 +3262,7 @@ function missionFromArgs(args) {
3217
3262
  '--task',
3218
3263
  '--ask',
3219
3264
  '--yes-lock',
3265
+ '--yes-lock-parent',
3220
3266
  '--campaign',
3221
3267
  '--reserve',
3222
3268
  '--mission-id',
@@ -3260,7 +3306,18 @@ function missionFromArgs(args) {
3260
3306
  const taskIds = readRepeatedFlag(args, '--task');
3261
3307
  // MEMBER.md approval-required becomes an open human ask the owner must clear.
3262
3308
  const humanAsks = [...readRepeatedFlag(args, '--ask'), ...memberApprovalAsks(owner, process.cwd(), objective)];
3263
- const yesLocks = readYesLockSeal(readFlag(args, '--yes-lock', ''), wantsJson(args));
3309
+ // The seal is never read from text a caller passes through: two
3310
+ // --yes-lock words (one could be the objective) refuse the start, and a
3311
+ // run woken by a handoff carries its seal in the environment, which every
3312
+ // job it starts inherits. (Codex review round 6 on #1483.)
3313
+ if (args.filter(isYesLockWord).length > 1) {
3314
+ exitMissionError('mission start got more than one --yes-lock, so it is not clear which seal applies; nothing started', 2, wantsJson(args));
3315
+ }
3316
+ const handoffSeal = String(process.env.ATRIS_HANDOFF_YES_LOCK || '').trim();
3317
+ const yesLocks = readYesLockSeal(handoffSeal || readFlag(args, '--yes-lock', ''), wantsJson(args));
3318
+ const yesLockParent = yesLocks.length
3319
+ ? String((handoffSeal ? process.env.ATRIS_HANDOFF_YES_LOCK_PARENT : readFlag(args, '--yes-lock-parent', '')) || '').trim()
3320
+ : '';
3264
3321
  const drive = missionDrive(readFlag(args, '--destination', ''));
3265
3322
  const alwaysOn = hasFlag(args, '--always-on');
3266
3323
  const xpTaskEnabled = hasFlag(args, '--xp-task') || hasFlag(args, '--agent-xp');
@@ -3299,6 +3356,9 @@ function missionFromArgs(args) {
3299
3356
  human_asks: humanAsks,
3300
3357
  // the sealed owner steps: the guard holds what each locks until the owner says yes
3301
3358
  ...(yesLocks.length ? { yes_locks: { steps: yesLocks, sealed_at: stampIso() } } : {}),
3359
+ // a run woken by a handoff: the job it came from, only to show its yes
3360
+ // cards there (lib/yes-lock.js handoffYesCards)
3361
+ ...(yesLockParent ? { yes_lock_parent: yesLockParent } : {}),
3302
3362
  ...(campaignId ? { campaign_id: campaignId } : {}),
3303
3363
  ...(drive ? { drive } : {}),
3304
3364
  next_action: verifier ? 'run verifier with `atris mission tick <id> --verify`' : 'define verifier or run next task',
@@ -3319,7 +3379,12 @@ function missionFromArgs(args) {
3319
3379
 
3320
3380
  // --yes-lock '[{"step":3,"text":"...","categories":["publish"]}]': the owner
3321
3381
  // steps a job may not do before the owner says yes. Bad input stops the start.
3322
- const YES_LOCK_CATEGORIES = new Set(['publish', 'tag', 'post', 'send', 'merge', 'deploy']);
3382
+ const YES_LOCK_CATEGORIES = new Set(['publish', 'tag', 'post', 'send', 'merge', 'deploy', 'spend']);
3383
+ function isYesLockWord(arg) {
3384
+ const text = String(arg || '').trim();
3385
+ return text === '--yes-lock' || text.startsWith('--yes-lock=');
3386
+ }
3387
+
3323
3388
  function readYesLockSeal(raw, asJson) {
3324
3389
  if (!raw) return [];
3325
3390
  let list;
@@ -3329,7 +3394,7 @@ function readYesLockSeal(raw, asJson) {
3329
3394
  exitMissionError('--yes-lock takes a JSON list of {step, text, categories}', 2, asJson);
3330
3395
  }
3331
3396
  const ok = Array.isArray(list) && list.every(l => l && Number.isInteger(l.step) && l.step > 0 && Array.isArray(l.categories) && l.categories.length && l.categories.every(c => YES_LOCK_CATEGORIES.has(c)));
3332
- if (!ok) exitMissionError('--yes-lock takes a JSON list of {step, text, categories}, with categories from publish, tag, post, send, merge, deploy', 2, asJson);
3397
+ if (!ok) exitMissionError('--yes-lock takes a JSON list of {step, text, categories}, with categories from publish, tag, post, send, merge, deploy, spend', 2, asJson);
3333
3398
  return list.map(l => ({ step: l.step, text: String(l.text || '').slice(0, 200), categories: [...new Set(l.categories)] }));
3334
3399
  }
3335
3400
 
@@ -4512,6 +4577,15 @@ function normalizedObjective(text) {
4512
4577
  return String(text || '').toLowerCase().replace(/\s+/g, ' ').trim();
4513
4578
  }
4514
4579
 
4580
+ // True when `existing` runs under the lock `wanted` carries: the start
4581
+ // carries none, or the same seal.
4582
+ function lockCovered(existing, wanted) {
4583
+ const want = (wanted && wanted.yes_locks && wanted.yes_locks.steps) || [];
4584
+ if (!want.length) return true;
4585
+ const have = (existing && existing.yes_locks && existing.yes_locks.steps) || [];
4586
+ return JSON.stringify(have) === JSON.stringify(want);
4587
+ }
4588
+
4515
4589
  function findActiveTwinMission(objective, owner, root = process.cwd()) {
4516
4590
  const wantObjective = normalizedObjective(objective);
4517
4591
  const wantOwner = String(owner || '').trim().toLowerCase();
@@ -4547,6 +4621,11 @@ function startMission(args, options = {}) {
4547
4621
  const idLikeObjective = String(mission.objective || '').trim();
4548
4622
  if (/^mission-\d{4}-\d{2}-\d{2}-/.test(idLikeObjective)) {
4549
4623
  const existing = resolveMission(idLikeObjective);
4624
+ if (existing && !lockCovered(existing, mission)) {
4625
+ // A start carrying a yes-lock never resumes a job under a weaker lock:
4626
+ // it would run without it. (Codex review round 4 on #1483.)
4627
+ exitMissionError(`${existing.id} is not under the yes-lock this start carries, so resuming it would run without the yes-lock; start a new job instead`, 2, asJson);
4628
+ }
4550
4629
  if (existing) {
4551
4630
  printJsonOrText(
4552
4631
  { ok: true, action: 'mission_recovered', recovered: true, mission: existing, note: 'objective looked like a mission id; recovered the existing mission instead of creating a new one' },
@@ -4560,55 +4639,57 @@ function startMission(args, options = {}) {
4560
4639
  }
4561
4640
  exitMissingMission(idLikeObjective, 1, asJson);
4562
4641
  }
4563
- if (!hasFlag(args, '--duplicate')) {
4564
- const twin = findActiveTwinMission(mission.objective, mission.owner);
4565
- if (twin) {
4566
- const requestedDestination = mission.drive?.destination || '';
4567
- if (requestedDestination && twin.drive?.destination && requestedDestination !== twin.drive.destination) {
4568
- const proposed = proposeMissionDestinationChange(twin, requestedDestination);
4569
- const proposal = proposed.drive.pending_destination_proposal;
4570
- const missionRoot = twin.worktree_root || process.cwd();
4571
- const { mission: saved } = saveMission(
4572
- proposed,
4573
- missionRoot,
4574
- 'mission_destination_change_proposed',
4575
- {
4576
- current_destination: twin.drive.destination,
4577
- proposed_destination: proposal.destination,
4578
- reason: proposal.reason,
4579
- },
4580
- );
4581
- printJsonOrText(
4582
- {
4583
- ok: true,
4584
- action: 'mission_destination_change_proposed',
4585
- reused: true,
4586
- operator_gate_required: true,
4587
- mission: saved,
4588
- proposal,
4589
- },
4590
- [
4591
- `Destination change proposed for ${saved.id}.`,
4592
- `Current destination: ${saved.drive.destination}`,
4593
- `Proposed destination: ${proposal.destination}`,
4594
- `Operator gate: ${proposal.reason}.`,
4595
- ],
4596
- asJson,
4597
- );
4598
- return;
4599
- }
4642
+ // A start that carries a yes-lock never reuses a twin under a different
4643
+ // lock (or none): the reused job would run without it. It gets a fresh
4644
+ // job under the lock instead. (Codex review rounds 3 and 4 on #1483.)
4645
+ const twinCandidate = hasFlag(args, '--duplicate') ? null : findActiveTwinMission(mission.objective, mission.owner);
4646
+ if (twinCandidate && lockCovered(twinCandidate, mission)) {
4647
+ const twin = twinCandidate;
4648
+ const requestedDestination = mission.drive?.destination || '';
4649
+ if (requestedDestination && twin.drive?.destination && requestedDestination !== twin.drive.destination) {
4650
+ const proposed = proposeMissionDestinationChange(twin, requestedDestination);
4651
+ const proposal = proposed.drive.pending_destination_proposal;
4652
+ const missionRoot = twin.worktree_root || process.cwd();
4653
+ const { mission: saved } = saveMission(
4654
+ proposed,
4655
+ missionRoot,
4656
+ 'mission_destination_change_proposed',
4657
+ {
4658
+ current_destination: twin.drive.destination,
4659
+ proposed_destination: proposal.destination,
4660
+ reason: proposal.reason,
4661
+ },
4662
+ );
4600
4663
  printJsonOrText(
4601
- { ok: true, action: 'mission_reused', reused: true, mission: twin, note: 'an active mission with this objective and owner already exists; resumed instead of cloning (pass --duplicate to force a second one)' },
4664
+ {
4665
+ ok: true,
4666
+ action: 'mission_destination_change_proposed',
4667
+ reused: true,
4668
+ operator_gate_required: true,
4669
+ mission: saved,
4670
+ proposal,
4671
+ },
4602
4672
  [
4603
- `Already active: ${twin.id} (${twin.status})`,
4604
- `Same objective, same owner, reusing it instead of starting a clone.`,
4605
- `Resume: atris mission run ${twin.id}`,
4606
- `Really want a second one: re-run with --duplicate`,
4673
+ `Destination change proposed for ${saved.id}.`,
4674
+ `Current destination: ${saved.drive.destination}`,
4675
+ `Proposed destination: ${proposal.destination}`,
4676
+ `Operator gate: ${proposal.reason}.`,
4607
4677
  ],
4608
4678
  asJson,
4609
4679
  );
4610
4680
  return;
4611
4681
  }
4682
+ printJsonOrText(
4683
+ { ok: true, action: 'mission_reused', reused: true, mission: twin, note: 'an active mission with this objective and owner already exists; resumed instead of cloning (pass --duplicate to force a second one)' },
4684
+ [
4685
+ `Already active: ${twin.id} (${twin.status})`,
4686
+ `Same objective, same owner, reusing it instead of starting a clone.`,
4687
+ `Resume: atris mission run ${twin.id}`,
4688
+ `Really want a second one: re-run with --duplicate`,
4689
+ ],
4690
+ asJson,
4691
+ );
4692
+ return;
4612
4693
  }
4613
4694
  // Intake gate: a mission without a verifier is a planning wish, and planning
4614
4695
  // wishes silt up the queue (11 swept on 2026-07-06 with zero ticks). Refuse
@@ -4736,6 +4817,13 @@ function startMission(args, options = {}) {
4736
4817
  lane: saved.lane,
4737
4818
  verifier: saved.verifier,
4738
4819
  });
4820
+ if (saved.yes_lock_parent && saved.yes_locks && saved.yes_locks.steps) {
4821
+ // the lock record names the job it came from, before any command is held
4822
+ try {
4823
+ require('../lib/yes-lock').writeYesLocks(path.join(require('../lib/state-home').atrisHome(), 'state'), saved.id,
4824
+ { seal: saved.yes_locks.steps, parentMissionId: saved.yes_lock_parent });
4825
+ } catch {}
4826
+ }
4739
4827
  const worktreeBaseline = captureMissionWorktreeBaseline(saved, process.cwd(), codeBaseline);
4740
4828
  const nextTickCommand = `atris mission tick ${saved.id}${saved.verifier ? ' --verify' : ''}`;
4741
4829
  const payload = {
@@ -12807,6 +12895,9 @@ module.exports = {
12807
12895
  usefulClaudeReceiptSummary,
12808
12896
  autoQueueMemberNextPush,
12809
12897
  recordMemberHandoff,
12898
+ updateMemberHandoff,
12899
+ resolveMemberWorkspaceRoot,
12900
+ isYesLockWord,
12810
12901
  closeMemberHandoffsForReceiver,
12811
12902
  cappedClaudeReceiptText,
12812
12903
  extractLayerFromReceiptText,
package/commands/slop.js CHANGED
@@ -418,9 +418,15 @@ function findDeadCode(root = process.cwd(), opts = {}) {
418
418
  const candidates = scanDirs.flatMap((d) => listJsFiles(d));
419
419
  if (!candidates.length) return { root, candidates: 0, dead: [], testOnly: [] };
420
420
 
421
- // 1) reachability BFS from entrypoints over static edges
421
+ // 1) reachability BFS from entrypoints over static edges. A file that runs
422
+ // itself when started directly (`node lib/x.js`, guarded by
423
+ // `require.main === module`) is an entrypoint too, even if only a test
424
+ // imports it.
422
425
  const reached = new Set();
423
- const queue = [...deadEntrypoints(root)];
426
+ const runsItself = (f) => {
427
+ try { return /\brequire\.main\s*===\s*module\b/.test(fs.readFileSync(f, 'utf8')); } catch { return false; }
428
+ };
429
+ const queue = [...deadEntrypoints(root), ...candidates.filter(runsItself)];
424
430
  while (queue.length) {
425
431
  const f = queue.pop();
426
432
  if (reached.has(f)) continue;
@@ -52,7 +52,8 @@ function probeVerifierCanFail({ command, runner, tmpRoot } = {}) {
52
52
  // like it cannot fail. Same strip as verifyCommandEnv.
53
53
  const env = { ...process.env };
54
54
  delete env.NODE_TEST_CONTEXT;
55
- const result = run('bash', ['-lc', cmd], {
55
+ // a login shell rebuilds PATH; a mission guard's wrappers go back first
56
+ const result = run('bash', ['-lc', `${require('./spawn-env').shellGuardLine(env)}${cmd}`], {
56
57
  cwd: dir,
57
58
  encoding: 'utf8',
58
59
  timeout: PROBE_TIMEOUT_MS,