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/lib/gm-phone.js CHANGED
@@ -16,6 +16,10 @@ const crypto = require('crypto');
16
16
 
17
17
  const CARD_LIMIT = 5;
18
18
  const CONFIRM_MS = 5000;
19
+ // a typed send's id, and how many and how long sends are remembered
20
+ const SEND_ID = /^[a-z0-9]{8,32}$/;
21
+ const SENDS_KEPT = 50;
22
+ const SENDS_MS = 10 * 60 * 1000;
19
23
  const UNDO_MS = 5000;
20
24
  // the server keeps an undo a little past the toast so a slow tap still counts
21
25
  const UNDO_GRACE_MS = 3000;
@@ -505,11 +509,14 @@ function createPhoneHandler(deps) {
505
509
  async function betAction(p, body) {
506
510
  let out;
507
511
  if (p === '/bet/think') out = bet.next({ sharper: body.sharper === true });
508
- else if (p === '/bet/answer') out = bet.answer({ choice: String(body.choice || ''), text: String(body.text || '') });
512
+ // typed words in something else are read first, like say something: a
513
+ // question back or confusion is explained and nothing is saved
514
+ else if (p === '/bet/answer' && body.choice === 'other') out = await bet.say({ text: String(body.text || ''), card: String(body.card || '') });
515
+ else if (p === '/bet/answer') out = bet.answer({ choice: String(body.choice || ''), text: String(body.text || ''), card: String(body.card || '') });
509
516
  else if (p === '/bet/report') out = bet.report({ value: body.value });
510
517
  else if (p === '/bet/pick') out = bet.pick({ id: String(body.id || '') });
511
518
  else if (p === '/bet/steer') out = await bet.talk({ member: String(body.member || ''), text: String(body.text || ''), confirm: body.confirm === true, note: body.note === true });
512
- else if (p === '/bet/say') out = await bet.say({ text: String(body.text || ''), confirm: body.confirm === true, note: body.note === true });
519
+ else if (p === '/bet/say') out = await bet.say({ text: String(body.text || ''), confirm: body.confirm === true, note: body.note === true, card: String(body.card || '') });
513
520
  else if (p === '/bet/skip') out = bet.skip();
514
521
  else if (p === '/bet/do') {
515
522
  if (body.confirm !== true) return { status: 409, body: { ok: false, reason: 'needs_confirm', confirm_ms: CONFIRM_MS } };
@@ -524,6 +531,26 @@ function createPhoneHandler(deps) {
524
531
  return { status: out.ok ? 200 : 409, body: out };
525
532
  }
526
533
 
534
+ // A typed send carries its own id, made on the phone when the words are
535
+ // first sent and the same on every retry of those words. A repeat of a
536
+ // send that went through, or is still going, gets the first answer and
537
+ // nothing is sent again; one that did nothing is tried again. The newest
538
+ // SENDS_KEPT are remembered for SENDS_MS.
539
+ const sends = new Map();
540
+ function sendOnce(p, body) {
541
+ const id = typeof body.send === 'string' && SEND_ID.test(body.send) ? `${p} ${body.send}` : null;
542
+ if (!id) return betAction(p, body);
543
+ const seen = sends.get(id);
544
+ if (seen && now() - seen.at < SENDS_MS) return seen.out;
545
+ sends.delete(id);
546
+ const out = betAction(p, body);
547
+ sends.set(id, { at: now(), out });
548
+ const forget = () => { if (sends.get(id) && sends.get(id).out === out) sends.delete(id); };
549
+ out.then(r => { if (!r.body || r.body.ok !== true) forget(); }, forget);
550
+ while (sends.size > SENDS_KEPT) sends.delete(sends.keys().next().value);
551
+ return out;
552
+ }
553
+
527
554
  const send = (res, status, body, type = 'application/json; charset=utf-8', extra = {}) => {
528
555
  res.writeHead(status, {
529
556
  'content-type': type,
@@ -561,7 +588,7 @@ function createPhoneHandler(deps) {
561
588
  return send(res, err.message === 'too_large' ? 413 : 400, { ok: false, reason: 'bad_body' });
562
589
  }
563
590
  if (check.path.startsWith('/bet/')) {
564
- const out = await betAction(check.path, body || {});
591
+ const out = await sendOnce(check.path, body || {});
565
592
  return send(res, out.status, out.body);
566
593
  }
567
594
  const out = check.path === '/action' ? act(body || {}) : doUndo(body || {});
@@ -0,0 +1,180 @@
1
+ 'use strict';
2
+
3
+ // A handoff wakes the member it hands to.
4
+ //
5
+ // `atris member handoff <from> <to> "<step>"` used to only write the baton
6
+ // down, so the phone said "validator has it" while no validator run existed
7
+ // (live 2026-10-22, mission #266). Now the receiver is started on the
8
+ // handed-off step through the member runtime (member run, detached), the same
9
+ // bounded work step `atris member wake <name>` runs, and only when the team
10
+ // rules allow it: not asleep, not a person, under the limit of members
11
+ // working at once (the `atris member up` limit), with an engine named in the
12
+ // roster. When it cannot start, the outcome says why in plain words and is
13
+ // written onto the handoff, so nothing claims the member has the work.
14
+ //
15
+ // The team, the engine lookup and the start are passed in, so tests drive the
16
+ // decision without starting anything.
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const { DEFAULT_MAX } = require('./member-up');
21
+
22
+ // Two handoffs at the same moment once both counted 5 working and both
23
+ // started one, past the limit of 6. Count-and-start now holds one lock file
24
+ // in the state folder, and each start is written to a short list of recent
25
+ // wakes, so the next handoff counts it even before its mission says running.
26
+ const LOCK_FILE = 'handoff-wake.lock';
27
+ const RECENT_FILE = 'handoff-wakes.json';
28
+ // A holder that died, or one stuck past this long, no longer holds.
29
+ const STALE_LOCK_MS = 5 * 60 * 1000;
30
+ const WAIT_LOCK_MS = 3 * 60 * 1000;
31
+ const RECENT_MS = 30 * 60 * 1000;
32
+
33
+ function sleepSync(ms) {
34
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
35
+ }
36
+
37
+ function pidAlive(pid) {
38
+ if (!Number.isInteger(pid) || pid <= 0) return false;
39
+ try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; }
40
+ }
41
+
42
+ function takeLock(file, { staleMs, waitMs, now }) {
43
+ const until = now() + waitMs;
44
+ for (;;) {
45
+ try {
46
+ const fd = fs.openSync(file, 'wx');
47
+ fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, at: now() }));
48
+ return fd;
49
+ } catch (error) {
50
+ if (error.code !== 'EEXIST') throw error;
51
+ }
52
+ try {
53
+ const stat = fs.statSync(file);
54
+ let owner = null;
55
+ try { owner = JSON.parse(fs.readFileSync(file, 'utf8')); } catch {}
56
+ const dead = owner && Number.isInteger(owner.pid) && !pidAlive(owner.pid);
57
+ const old = now() - stat.mtimeMs > staleMs;
58
+ if ((dead || old) && fs.statSync(file).ino === stat.ino) {
59
+ fs.unlinkSync(file);
60
+ continue;
61
+ }
62
+ } catch {
63
+ continue;
64
+ }
65
+ if (now() >= until) return null;
66
+ sleepSync(100);
67
+ }
68
+ }
69
+
70
+ // Runs fn() while holding an exclusive lock file. A holder that died, or
71
+ // one older than staleMs, is cleared. Throws when the lock stays held past
72
+ // waitMs, so a caller never writes without it.
73
+ function withFileLock(lockFile, fn, { staleMs = STALE_LOCK_MS, waitMs = WAIT_LOCK_MS, now = Date.now } = {}) {
74
+ fs.mkdirSync(path.dirname(lockFile), { recursive: true });
75
+ const fd = takeLock(lockFile, { staleMs, waitMs, now });
76
+ if (fd === null) throw new Error(`${path.basename(lockFile)} stayed locked by another writer`);
77
+ try {
78
+ return fn();
79
+ } finally {
80
+ try { fs.closeSync(fd); } catch {}
81
+ try { fs.unlinkSync(lockFile); } catch {}
82
+ }
83
+ }
84
+
85
+ // Runs fn({ recent, record }) while holding the wake lock. recent: wakes from
86
+ // the last half hour whose run is still alive; record(entry) adds one.
87
+ // Returns { busy: true } when the lock stayed held past waitMs.
88
+ function withWakeReservation(stateDir, fn, { staleMs = STALE_LOCK_MS, waitMs = WAIT_LOCK_MS, now = Date.now } = {}) {
89
+ fs.mkdirSync(stateDir, { recursive: true });
90
+ const lockFile = path.join(stateDir, LOCK_FILE);
91
+ const recentFile = path.join(stateDir, RECENT_FILE);
92
+ const fd = takeLock(lockFile, { staleMs, waitMs, now });
93
+ if (fd === null) return { busy: true };
94
+ try {
95
+ let recent = [];
96
+ try { recent = JSON.parse(fs.readFileSync(recentFile, 'utf8')).wakes || []; } catch {}
97
+ recent = recent.filter((w) => w && w.member && now() - Number(w.at || 0) < RECENT_MS && pidAlive(w.pid));
98
+ const out = fn({ recent, record: (entry) => recent.push({ ...entry, at: now() }) });
99
+ const tmp = `${recentFile}.${process.pid}.tmp`;
100
+ fs.writeFileSync(tmp, JSON.stringify({ wakes: recent }));
101
+ fs.renameSync(tmp, recentFile);
102
+ return out;
103
+ } finally {
104
+ try { fs.closeSync(fd); } catch {}
105
+ try { fs.unlinkSync(lockFile); } catch {}
106
+ }
107
+ }
108
+
109
+ function notStarted(to, reason, why) {
110
+ return {
111
+ status: 'not_started',
112
+ reason,
113
+ text: `${to} did not start: ${why}. nobody is working on this step yet. to start it: atris member wake ${to}`,
114
+ };
115
+ }
116
+
117
+ // deps: { listMembers() -> [{ name, live, sleeping, parked, human }],
118
+ // awake(name), engine(name), start(name, step) -> { ok, mission_id, pid, worktree, error },
119
+ // max }
120
+ // deps.reserve(fn), when given, holds the shared wake lock around the
121
+ // count-and-start (withWakeReservation); without it the decision runs bare.
122
+ function wakeHandoffReceiver(handoff, deps) {
123
+ const to = String(handoff && handoff.to || '').trim();
124
+ if (typeof deps.reserve !== 'function') return decideAndStart(handoff, deps, { recent: [], record: () => {} });
125
+ const out = deps.reserve((reservation) => decideAndStart(handoff, deps, reservation));
126
+ if (out && out.busy) return notStarted(to, 'busy', 'another handoff held the start lock for minutes, so this one gave up');
127
+ return out;
128
+ }
129
+
130
+ function decideAndStart(handoff, deps, { recent, record }) {
131
+ const to = String(handoff && handoff.to || '').trim();
132
+ const step = String(handoff && handoff.summary || '').trim();
133
+ const recentNames = new Set(recent.map((w) => w.member));
134
+ const team = (deps.listMembers() || []).map((member) => (member && recentNames.has(member.name) ? { ...member, live: true } : member));
135
+ const seat = team.find((member) => member && member.name === to);
136
+ if (!seat) return notStarted(to, 'not_on_team', `${to} is not on the team`);
137
+ if (seat.human) return notStarted(to, 'person', `${to} is a person, so no engine starts for them`);
138
+ if (seat.live) {
139
+ return {
140
+ status: 'already_working',
141
+ reason: 'already_working',
142
+ text: `${to} is already working; the step is queued as its next push`,
143
+ };
144
+ }
145
+ if (seat.sleeping || deps.awake(to) === false) return notStarted(to, 'asleep', `${to} is asleep`);
146
+ if (seat.parked) return notStarted(to, 'parked', `${to} is parked`);
147
+ const max = Math.max(1, Number(deps.max) || DEFAULT_MAX);
148
+ const working = team.filter((member) => member && member.live).length;
149
+ if (working >= max) {
150
+ return notStarted(to, 'at_limit', `${working} members are already working and the limit is ${max}`);
151
+ }
152
+ const engine = deps.engine(to);
153
+ if (!engine) return notStarted(to, 'no_engine', `the roster names no engine for ${to}`);
154
+ let run;
155
+ try {
156
+ run = deps.start(to, step) || {};
157
+ } catch (error) {
158
+ run = { ok: false, error: error && error.message };
159
+ }
160
+ if (!run.ok || !run.mission_id) {
161
+ const why = String(run.error || 'the run did not start').split('\n')[0].trim().slice(0, 200);
162
+ return notStarted(to, 'start_failed', why);
163
+ }
164
+ record({ member: to, mission_id: run.mission_id, pid: run.pid || null });
165
+ return {
166
+ status: 'started',
167
+ reason: 'started',
168
+ mission_id: run.mission_id,
169
+ pid: run.pid || null,
170
+ worktree: run.worktree || null,
171
+ engine: (engine.engine && engine.engine.id) || engine.id || null,
172
+ text: `${to} is working on it now (mission ${run.mission_id})`,
173
+ };
174
+ }
175
+
176
+ module.exports = {
177
+ withFileLock,
178
+ wakeHandoffReceiver,
179
+ withWakeReservation,
180
+ };
package/lib/manifest.js CHANGED
@@ -2,7 +2,6 @@ const crypto = require('crypto');
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
4
  const { stateHome } = require('./state-home');
5
- const { computeContentHash } = require('./journal');
6
5
 
7
6
  /**
8
7
  * Get the manifest file path for a business slug.
@@ -35,14 +34,6 @@ function saveManifest(slug, manifest) {
35
34
  fs.writeFileSync(p, JSON.stringify(manifest, null, 2));
36
35
  }
37
36
 
38
- /**
39
- * Compute SHA-256 hash of normalized content.
40
- * Delegates to journal.js computeContentHash.
41
- */
42
- function computeFileHash(content) {
43
- return computeContentHash(content);
44
- }
45
-
46
37
  /**
47
38
  * Build a manifest object from a set of files.
48
39
  * files: { [path]: { hash, size } }
@@ -247,7 +238,6 @@ function threeWayCompare(localFiles, remoteFiles, manifest) {
247
238
  module.exports = {
248
239
  loadManifest,
249
240
  saveManifest,
250
- computeFileHash,
251
241
  buildManifest,
252
242
  computeLocalHashes,
253
243
  threeWayCompare,
@@ -70,17 +70,6 @@ function asleepMembers(root = process.cwd()) {
70
70
  return Object.keys(members).filter((name) => normalizeMemberEntry(members[name]).awake === false).sort();
71
71
  }
72
72
 
73
- function isMemberLoopAwake(name, loopId, root = process.cwd()) {
74
- const entry = getMemberSwitch(name, root);
75
- if (entry.awake === false) return false;
76
- const id = String(loopId || '').trim();
77
- if (!id) return entry.awake !== false;
78
- if (Object.prototype.hasOwnProperty.call(entry.loops, id)) {
79
- return entry.loops[id] !== false;
80
- }
81
- return true;
82
- }
83
-
84
73
  function ensureMemberEntry(state, name) {
85
74
  if (!state.members[name] || typeof state.members[name] !== 'object') {
86
75
  state.members[name] = { awake: true, loops: {} };
package/lib/member-up.js CHANGED
@@ -346,11 +346,25 @@ function createMemberUp(deps, options = {}) {
346
346
  }
347
347
 
348
348
  // One pass over the team. Returns the members it started (or would start).
349
+ // Count-and-start runs under the wake lock that
350
+ // handoffs share (deps.reserve), and counts the members a handoff or
351
+ // another start just woke, so neither path goes past the limit.
349
352
  function check() {
353
+ if (typeof deps.reserve !== 'function' || dryRun) return checkUnder({ recent: [], record: () => {} });
354
+ const out = deps.reserve((reservation) => checkUnder(reservation));
355
+ if (out && out.busy) {
356
+ print('another start held the start lock, so nobody was started this pass');
357
+ return [];
358
+ }
359
+ return out;
360
+ }
361
+
362
+ function checkUnder({ recent, record }) {
350
363
  const now = deps.now();
351
364
  callOutStalls(now);
352
365
  putBackDropped(now);
353
366
  const started = [];
367
+ const recentNames = new Set((recent || []).map((w) => w && w.member).filter(Boolean));
354
368
  const taken = new Set(Array.from(running.values()).map((run) => run.taskId).filter(Boolean));
355
369
  const team = deps.listMembers();
356
370
  const members = only ? team.filter((m) => m && only.has(m.name)) : team;
@@ -362,11 +376,11 @@ function createMemberUp(deps, options = {}) {
362
376
  print(`${name} is not on the team, skipping`);
363
377
  }
364
378
  }
365
- liveElsewhere = new Set(members.filter((m) => m && m.name && m.live).map((m) => m.name));
379
+ liveElsewhere = new Set(members.filter((m) => m && m.name && (m.live || recentNames.has(m.name))).map((m) => m.name));
366
380
  for (const member of members) {
367
381
  const name = member && member.name;
368
382
  if (!name || member.parked || member.human || member.sleeping) continue;
369
- if (running.has(name) || member.live) continue;
383
+ if (running.has(name) || liveElsewhere.has(name)) continue;
370
384
  if (working() + (dryRun ? started.length : 0) >= max) break;
371
385
  const s = seat(name);
372
386
  if (now < s.nextCheckAt) continue;
@@ -391,10 +405,11 @@ function createMemberUp(deps, options = {}) {
391
405
  const run = { taskId: task.id || null, title, startedAt: now, lastStepAt: now, announced: false };
392
406
  running.set(name, run);
393
407
  try {
394
- deps.spawn(name, (code, result) => {
408
+ const child = deps.spawn(name, (code, result) => {
395
409
  deps.flushProgress?.();
396
410
  if (running.get(name) === run) finish(name, code, result);
397
411
  }, (claimedTask) => claimed(name, run, claimedTask));
412
+ record({ member: name, pid: child && child.pid ? child.pid : null });
398
413
  } catch (error) {
399
414
  running.delete(name);
400
415
  print(`${name} failed to start: ${oneLine(error && error.message || error)}`);
@@ -238,16 +238,24 @@ function gitSubcommand(args) {
238
238
  // Tools that can publish, post, send, or merge. With a mission id, each one
239
239
  // found on PATH gets a wrapper that checks the mission's your-yes lock first
240
240
  // (lib/yes-lock.js); with no lock file the wrapper just runs the tool.
241
+ const { GUARD_DIR_PREFIX } = require('./spawn-env');
242
+ const GUARD_MISSION_FILE = 'mission.json';
241
243
  const YES_LOCK_TOOLS = ['npm', 'yarn', 'pnpm', 'npx', 'pnpx', 'bunx', 'gh', 'atris'];
242
244
 
243
245
  // The lines every wrapper shares: ask the lock, refuse with its message, run
244
- // the real tool, and close the steps a yes allowed once that run succeeds.
246
+ // the real tool (with the words the lock gave back, when it gave any: a
247
+ // branch push in a woken run runs with tags and mirroring forced off), and
248
+ // close the steps a yes allowed once that run succeeds.
245
249
  function yesLockLines(toolExpr, binExpr) {
246
250
  return [
247
251
  `const yesLock = require(${JSON.stringify(path.join(__dirname, 'yes-lock.js'))});`,
248
252
  `const verdict = config.missionId ? yesLock.check({ stateDir: config.stateDir, missionId: config.missionId, seal: config.seal, tool: ${toolExpr}, args, gitBin: config.gitBin || null, cwd: process.cwd(), env: process.env }) : null;`,
249
253
  `if (verdict && verdict.hold) { process.stderr.write(verdict.message + '\\n'); process.exit(yesLock.HELD_EXIT); }`,
250
- `const runReal = () => { const child = spawnSync(${binExpr}, args, { cwd: process.cwd(), stdio: 'inherit' }); const ok = !child.error && child.status === 0; if (verdict && verdict.consume) yesLock.consume(config.stateDir, config.missionId, config.seal, verdict.consume, { ok, claims: verdict.claims }); if (child.error) { process.stderr.write(child.error.message + '\\n'); process.exit(1); } process.exit(child.status == null ? 1 : child.status); };`,
254
+ `const runArgs = verdict && Array.isArray(verdict.args) ? verdict.args : args;`,
255
+ // package scripts run through the guard's own script shell, which puts
256
+ // the wrappers back first after npm puts node_modules/.bin ahead of them
257
+ `const runEnv = config.scriptShell ? require(${JSON.stringify(path.join(__dirname, 'spawn-env.js'))}).packageScriptEnv({ tool: config.tool, realBin: config.realBin, scriptShell: config.scriptShell }, process.env, process.cwd(), args) : process.env;`,
258
+ `const runReal = () => { const child = spawnSync(${binExpr}, runArgs, { cwd: process.cwd(), stdio: 'inherit', env: runEnv }); const ok = !child.error && child.status === 0; if (verdict && verdict.consume) yesLock.consume(config.stateDir, config.missionId, config.seal, verdict.consume, { ok, claims: verdict.claims }); if (child.error) { process.stderr.write(child.error.message + '\\n'); process.exit(1); } process.exit(child.status == null ? 1 : child.status); };`,
251
259
  ];
252
260
  }
253
261
 
@@ -257,8 +265,8 @@ function resolveTool(name, root, pathValue) {
257
265
  return found.status === 0 && bin && path.isAbsolute(bin) ? bin : null;
258
266
  }
259
267
 
260
- function yesLockWrapper({ tool, realBin, missionId, seal, stateDir }) {
261
- const config = { tool, realBin, missionId, seal, stateDir };
268
+ function yesLockWrapper({ tool, realBin, missionId, seal, stateDir, scriptShell = null }) {
269
+ const config = { tool, realBin, missionId, seal, stateDir, scriptShell };
262
270
  return [
263
271
  '#!/usr/bin/env node',
264
272
  "'use strict';",
@@ -292,7 +300,7 @@ function prepareMissionGitGuard({
292
300
  throw new Error('mission git guard could not resolve git');
293
301
  }
294
302
 
295
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'atris-mission-git-'));
303
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), GUARD_DIR_PREFIX));
296
304
  const wrapper = path.join(dir, 'git');
297
305
  const modulePath = __filename;
298
306
  const config = { root: path.resolve(root), tags: normalizedTags(tags), gitBin, missionId: locked ? missionId : null, seal, stateDir };
@@ -317,11 +325,20 @@ function prepareMissionGitGuard({
317
325
  '',
318
326
  ].join('\n');
319
327
  fs.writeFileSync(wrapper, source, { encoding: 'utf8', mode: 0o755 });
328
+ // Which mission this guard belongs to, so a member woken by a handoff from
329
+ // inside this run shows its yes cards on this job.
330
+ fs.writeFileSync(path.join(dir, GUARD_MISSION_FILE), JSON.stringify({ mission_id: missionId || null }), 'utf8');
320
331
  if (locked) {
332
+ // npm, yarn, and pnpm run package scripts with this shell, so a script's
333
+ // own npm or git is the wrapper even when the project ships one in
334
+ // node_modules/.bin; it then runs the shell the project configured
335
+ const scriptShell = path.join(dir, 'script-shell');
336
+ fs.writeFileSync(scriptShell, require('./spawn-env').scriptShellSource(dir), { encoding: 'utf8', mode: 0o755 });
321
337
  for (const tool of YES_LOCK_TOOLS) {
322
338
  const realBin = resolveTool(tool, root, pathValue);
323
339
  if (!realBin) continue;
324
- fs.writeFileSync(path.join(dir, tool), yesLockWrapper({ tool, realBin, missionId, seal, stateDir }), { encoding: 'utf8', mode: 0o755 });
340
+ const pkg = tool === 'npm' || tool === 'yarn' || tool === 'pnpm';
341
+ fs.writeFileSync(path.join(dir, tool), yesLockWrapper({ tool, realBin, missionId, seal, stateDir, scriptShell: pkg ? scriptShell : null }), { encoding: 'utf8', mode: 0o755 });
325
342
  }
326
343
  }
327
344
 
@@ -334,7 +351,22 @@ function prepareMissionGitGuard({
334
351
  };
335
352
  }
336
353
 
354
+ // The mission whose guard is on this PATH (the run that is calling), or
355
+ // null. Used only to show a handed-off run's yes cards on that job.
356
+ function guardMissionOnPath(pathValue = process.env.PATH || '') {
357
+ for (const dir of String(pathValue).split(path.delimiter)) {
358
+ if (!path.basename(dir).startsWith(GUARD_DIR_PREFIX)) continue;
359
+ try {
360
+ const data = JSON.parse(fs.readFileSync(path.join(dir, GUARD_MISSION_FILE), 'utf8'));
361
+ if (data && data.mission_id) return String(data.mission_id);
362
+ } catch {}
363
+ }
364
+ return null;
365
+ }
366
+
337
367
  module.exports = {
368
+ GUARD_DIR_PREFIX,
369
+ guardMissionOnPath,
338
370
  defaultGit,
339
371
  matchProtectedMissionDiff,
340
372
  unreadableMissionGuard,
@@ -25,10 +25,6 @@ function normalizeActor(value) {
25
25
  .replace(/^-+|-+$/g, '');
26
26
  }
27
27
 
28
- function envActor() {
29
- return normalizeActor(process.env.ATRIS_AGENT_ID || process.env.USER || '');
30
- }
31
-
32
28
  // Builder identity, oldest signal wins: explicit stamp, then who claimed it,
33
29
  // then whoever sent the first proof.
34
30
  function taskBuilder(task) {
package/lib/spawn-env.js CHANGED
@@ -2,17 +2,106 @@
2
2
 
3
3
  const path = require('path');
4
4
 
5
+ // The mission guard (lib/mission-protected-lane.js) puts a folder of
6
+ // wrappers for git, npm, gh, and atris first on a job's PATH. Anything that
7
+ // adds a folder ahead of it, or starts a login shell that rebuilds PATH from
8
+ // the profile, must put that folder back first, or a git or npm inside it
9
+ // skips the wrappers and the job's yes-lock.
10
+ const GUARD_DIR_PREFIX = 'atris-mission-git-';
11
+
12
+ function guardDirs(pathValue) {
13
+ return String(pathValue || '').split(path.delimiter).filter(dir => dir && path.basename(dir).startsWith(GUARD_DIR_PREFIX));
14
+ }
15
+
16
+ // The PATH with any guard folder moved back to the front.
17
+ function guardFirst(pathValue) {
18
+ const guards = guardDirs(pathValue);
19
+ if (!guards.length) return String(pathValue || '');
20
+ const rest = String(pathValue || '').split(path.delimiter).filter(dir => !guards.includes(dir));
21
+ return [...guards, ...rest].join(path.delimiter);
22
+ }
23
+
24
+ // A first line for a shell script that puts the guard folder back first
25
+ // after the profile has run. Empty outside a guarded job.
26
+ function shellGuardLine(env = process.env) {
27
+ const guards = guardDirs(env.PATH);
28
+ if (!guards.length) return '';
29
+ return `export PATH=${guards.map(d => `'${d.replace(/'/g, `'\\''`)}'`).join(':')}:"$PATH"\n`;
30
+ }
31
+
5
32
  // Autonomously launched runtimes (cron, launchd, pulse ticks) carry a minimal
6
33
  // PATH without node's directory, so any shell command that may invoke
7
34
  // `node ...` fails only in the one environment nobody is watching. Three
8
35
  // subsystems hit this on 2026-07-07 alone (experiment applies, atris2 relayed
9
36
  // ops, always-on mission verifiers). Prepend the directory of the node that
10
- // runs this CLI before spawning shell commands from autonomous paths.
37
+ // runs this CLI before spawning shell commands from autonomous paths, behind
38
+ // a guard folder if there is one.
11
39
  function envWithNodeDir(base = process.env) {
12
40
  const nodeDir = path.dirname(process.execPath);
13
41
  const basePath = String(base.PATH || '');
14
42
  if (basePath.split(path.delimiter).includes(nodeDir)) return base;
15
- return { ...base, PATH: `${nodeDir}${path.delimiter}${basePath}` };
43
+ return { ...base, PATH: guardFirst(`${nodeDir}${path.delimiter}${basePath}`) };
44
+ }
45
+
46
+ // The guard's script shell for npm, yarn, and pnpm. It puts the wrappers
47
+ // first, then runs the shell the project or user configured
48
+ // (ATRIS_SCRIPT_SHELL), or /bin/sh when none is. For a POSIX shell it also
49
+ // puts them first inside the command, since a shell like zsh can rebuild
50
+ // PATH as it starts.
51
+ function scriptShellSource(dir) {
52
+ const q = `'${String(dir).replace(/'/g, `'\\''`)}'`;
53
+ return [
54
+ '#!/bin/sh',
55
+ `G=${q}`,
56
+ 'PATH="$G:$PATH"; export PATH',
57
+ 'shell="${ATRIS_SCRIPT_SHELL:-/bin/sh}"',
58
+ 'if [ "$1" = "-c" ] && [ $# -ge 2 ]; then',
59
+ ' case "${shell##*/}" in',
60
+ ' sh|bash|zsh|dash|ksh|mksh|ash)',
61
+ ' cmd=$2; shift 2',
62
+ ` exec "$shell" -c "PATH='$G':\\"\\$PATH\\"; export PATH; $cmd" "$@" ;;`,
63
+ ' esac',
64
+ 'fi',
65
+ 'exec "$shell" "$@"',
66
+ '',
67
+ ].join('\n');
68
+ }
69
+
70
+ // The environment npm, yarn, or pnpm runs with under a guard: scripts go
71
+ // through the guard's shell, which then runs the configured one. The tool
72
+ // says which shell is configured (project, user, or global config, or the
73
+ // environment), asked with the same config-file and folder options the
74
+ // command was given; a nested call keeps what the outer one found.
75
+ const CONFIG_FLAGS = {
76
+ npm: ['--userconfig', '--globalconfig', '--prefix', '-C'],
77
+ pnpm: ['--userconfig', '--globalconfig', '--dir', '-C'],
78
+ yarn: ['--use-yarnrc', '--cwd'],
79
+ };
80
+
81
+ function configFlags(tool, args = []) {
82
+ const names = CONFIG_FLAGS[tool] || [];
83
+ const out = [];
84
+ for (let i = 0; i < args.length; i += 1) {
85
+ const a = String(args[i]);
86
+ if (a === '--') break;
87
+ if (names.includes(a.split('=')[0]) && a.includes('=')) out.push(a);
88
+ else if (names.includes(a) && args[i + 1] !== undefined) { out.push(a, String(args[i + 1])); i += 1; }
89
+ }
90
+ return out;
91
+ }
92
+
93
+ function packageScriptEnv({ tool, realBin, scriptShell }, env = process.env, cwd = process.cwd(), args = []) {
94
+ const out = { ...env, npm_config_script_shell: scriptShell };
95
+ // .yarnrc beats npm_config_*, and YARN_SCRIPT_SHELL beats .yarnrc
96
+ if (tool === 'yarn') out.YARN_SCRIPT_SHELL = scriptShell;
97
+ if (tool === 'pnpm') out.npm_config_shell_emulator = 'false';
98
+ if (env.npm_config_script_shell === scriptShell) return out;
99
+ const { spawnSync } = require('child_process');
100
+ const res = spawnSync(realBin, [...configFlags(tool, args), 'config', 'get', 'script-shell'], { cwd, env, encoding: 'utf8', timeout: 10000 });
101
+ const value = res.status === 0 ? String(res.stdout || '').trim().split('\n').pop().trim() : '';
102
+ if (value && !/^(null|undefined)$/.test(value) && value !== scriptShell) out.ATRIS_SCRIPT_SHELL = value;
103
+ else delete out.ATRIS_SCRIPT_SHELL;
104
+ return out;
16
105
  }
17
106
 
18
- module.exports = { envWithNodeDir };
107
+ module.exports = { GUARD_DIR_PREFIX, envWithNodeDir, shellGuardLine, scriptShellSource, packageScriptEnv };
package/lib/task-proof.js CHANGED
@@ -154,7 +154,8 @@ function buildVerifiedProof(verifyCmd, baseProof = '', runner, options = {}) {
154
154
  const cmd = String(verifyCmd || '').trim();
155
155
  if (!cmd) return { ok: false, reason: 'verify_command_required' };
156
156
  const run = runner || require('child_process').spawnSync;
157
- const result = run('bash', ['-lc', cmd], {
157
+ // a login shell rebuilds PATH; a mission guard's wrappers go back first
158
+ const result = run('bash', ['-lc', `${require('./spawn-env').shellGuardLine()}${cmd}`], {
158
159
  cwd: options.cwd || process.cwd(),
159
160
  encoding: 'utf8',
160
161
  timeout: options.timeoutMs || 120000,
@@ -61,9 +61,10 @@ function writeTaskReceipt({ taskId, command, root = process.cwd(), runner } = {}
61
61
  const run = runner || spawnSync;
62
62
  // A login shell can put an older node first on PATH (macOS path_helper puts
63
63
  // /usr/local/bin ahead), so the check would run on a node atris does not
64
- // support. Put the node running atris first, after the profile has loaded.
64
+ // support. Put the node running atris first, after the profile has loaded,
65
+ // and a mission guard's wrapper folder ahead of it (lib/spawn-env.js).
65
66
  const nodeDir = path.dirname(process.execPath).replace(/'/g, `'\\''`);
66
- const script = `export PATH='${nodeDir}':"$PATH"\n${cmd}`;
67
+ const script = `export PATH='${nodeDir}':"$PATH"\n${require('./spawn-env').shellGuardLine()}${cmd}`;
67
68
  const timeoutMs = verifyTimeoutMs();
68
69
  const result = run('bash', ['-lc', script], {
69
70
  cwd: root,