atris 3.42.0 → 3.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/atris/skills/design/SKILL.md +7 -1
  2. package/atris/skills/engines/SKILL.md +44 -13
  3. package/atris/team/customer-lead/MEMBER.md +45 -0
  4. package/atris/team/customer-lead/SOUL.md +33 -0
  5. package/atris/team/customer-lead/START_HERE.md +7 -0
  6. package/atris/team/customer-lead/skills/customer-commitments/SKILL.md +45 -0
  7. package/atris/team/improver/MEMBER.md +33 -0
  8. package/bin/atris.js +37 -4
  9. package/commands/autoland.js +15 -1
  10. package/commands/caretaker.js +303 -0
  11. package/commands/clean.js +76 -0
  12. package/commands/engine-watch.js +212 -0
  13. package/commands/engine.js +99 -11
  14. package/commands/founder.js +304 -0
  15. package/commands/human-missions.js +844 -0
  16. package/commands/init.js +16 -7
  17. package/commands/lesson.js +178 -4
  18. package/commands/mission.js +124 -69
  19. package/commands/slop.js +34 -3
  20. package/commands/task.js +51 -4
  21. package/commands/team.js +329 -13
  22. package/commands/verify.js +99 -6
  23. package/commands/worktree.js +119 -4
  24. package/lib/auto-accept-certified.js +302 -0
  25. package/lib/cloud-mission.js +59 -2
  26. package/lib/conductor-artifacts.js +1 -1
  27. package/lib/dispatch-scout.js +383 -0
  28. package/lib/engine-ask.js +645 -0
  29. package/lib/engine-job-lifecycle.js +65 -0
  30. package/lib/engine-receipt-sweep.js +98 -0
  31. package/lib/engine-registry.js +2 -2
  32. package/lib/engine-validate.js +374 -0
  33. package/lib/fleet.js +459 -106
  34. package/lib/known-commands.js +2 -2
  35. package/lib/lesson-ledger.js +84 -0
  36. package/lib/member-alive.js +2 -2
  37. package/lib/policy-lessons.js +70 -0
  38. package/lib/receipt-evidence.js +56 -1
  39. package/lib/runner-command.js +1 -1
  40. package/lib/secret-gateway.js +588 -0
  41. package/lib/team-presence.js +13 -1
  42. package/lib/voice-gate.js +6 -0
  43. package/lib/wish-audit.js +5 -205
  44. package/lib/wish-delegate.js +5 -2
  45. package/package.json +6 -1
@@ -0,0 +1,588 @@
1
+ 'use strict';
2
+
3
+ // Per-run loopback secret-swap gateway for one-lap. Holds one real credential
4
+ // outside the sandbox, hands the engine a random placeholder plus a loopback
5
+ // base url, and forwards only grant-matched GET/HEAD requests.
6
+
7
+ const fs = require('fs');
8
+ const http = require('http');
9
+ const https = require('https');
10
+ const crypto = require('crypto');
11
+ const net = require('net');
12
+ const { spawn, spawnSync } = require('child_process');
13
+
14
+ const ALLOWED_METHODS = new Set(['GET', 'HEAD']);
15
+ const STRIP_RESPONSE_HEADERS = new Set([
16
+ 'set-cookie',
17
+ 'set-cookie2',
18
+ 'www-authenticate',
19
+ 'authorization',
20
+ 'proxy-authenticate',
21
+ 'proxy-authorization',
22
+ ]);
23
+ const PROXY_ENV_KEYS = [
24
+ 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY',
25
+ 'http_proxy', 'https_proxy', 'all_proxy', 'no_proxy',
26
+ ];
27
+
28
+ function createPlaceholder() {
29
+ return crypto.randomBytes(32).toString('base64url');
30
+ }
31
+
32
+ function normalizeGrant(raw) {
33
+ const src = raw && typeof raw === 'object' ? raw : {};
34
+ const id = String(src.id || '').trim();
35
+ const host = String(src.host || '').trim().toLowerCase();
36
+ const secretEnv = String(src.secretEnv || src.secret_env || '').trim();
37
+ const credentialHeader = String(src.credentialHeader || src.credential_header || '').trim().toLowerCase();
38
+ const placeholderEnv = String(src.placeholderEnv || src.placeholder_env || '').trim();
39
+ const baseUrlEnv = String(src.baseUrlEnv || src.base_url_env || '').trim();
40
+ const pathPrefixes = Array.isArray(src.pathPrefixes)
41
+ ? src.pathPrefixes
42
+ : (Array.isArray(src.path_prefixes) ? src.path_prefixes : []);
43
+ const methods = Array.isArray(src.methods) ? src.methods : ['GET', 'HEAD'];
44
+
45
+ if (!id) throw new Error('secret grant requires id');
46
+ if (!host || host.includes(':') || host.includes('/') || /[A-Z]/.test(String(src.host || '').trim())) {
47
+ throw new Error('secret grant host must be an exact lowercase hostname');
48
+ }
49
+ if (!secretEnv) throw new Error('secret grant requires secretEnv');
50
+ if (!credentialHeader) throw new Error('secret grant requires credentialHeader');
51
+ if (!placeholderEnv) throw new Error('secret grant requires placeholderEnv');
52
+ if (!baseUrlEnv) throw new Error('secret grant requires baseUrlEnv');
53
+ if (!pathPrefixes.length) throw new Error('secret grant requires pathPrefixes');
54
+
55
+ const normalizedPrefixes = pathPrefixes.map((prefix) => {
56
+ const value = String(prefix || '');
57
+ if (!value.startsWith('/') || value.includes('?') || value.includes('#') || value.includes('\\') || value.includes('//')) {
58
+ throw new Error('secret grant path prefix must be a canonical absolute path');
59
+ }
60
+ if (value.includes('%') || value.includes('.') && /(^|\/)\.\.?(\/|$)/.test(value)) {
61
+ throw new Error('secret grant path prefix must be canonical');
62
+ }
63
+ return value;
64
+ });
65
+
66
+ const normalizedMethods = [...new Set(methods.map((method) => String(method || '').trim().toUpperCase()))];
67
+ if (!normalizedMethods.length || normalizedMethods.some((method) => !ALLOWED_METHODS.has(method))) {
68
+ throw new Error('secret grant methods are limited to GET and HEAD');
69
+ }
70
+
71
+ return {
72
+ id,
73
+ host,
74
+ secretEnv,
75
+ credentialHeader,
76
+ placeholderEnv,
77
+ baseUrlEnv,
78
+ pathPrefixes: normalizedPrefixes,
79
+ methods: normalizedMethods,
80
+ };
81
+ }
82
+
83
+ function pathMatchesGrant(pathname, prefixes) {
84
+ for (const prefix of prefixes) {
85
+ if (pathname === prefix) return true;
86
+ const boundary = prefix.endsWith('/') ? prefix : `${prefix}/`;
87
+ if (pathname.startsWith(boundary)) return true;
88
+ }
89
+ return false;
90
+ }
91
+
92
+ function inspectRequestTarget(requestUrl) {
93
+ const raw = String(requestUrl || '');
94
+ if (!raw) return { ok: false, reason: 'empty_target' };
95
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(raw)) return { ok: false, reason: 'absolute_form' };
96
+ if (raw.includes('\\')) return { ok: false, reason: 'backslash' };
97
+ if (raw.includes('#') || /%23/i.test(raw)) return { ok: false, reason: 'fragment' };
98
+ if (raw.includes('@')) return { ok: false, reason: 'userinfo' };
99
+
100
+ const queryIndex = raw.indexOf('?');
101
+ const pathPart = queryIndex === -1 ? raw : raw.slice(0, queryIndex);
102
+ const search = queryIndex === -1 ? '' : raw.slice(queryIndex);
103
+ if (!pathPart.startsWith('/')) return { ok: false, reason: 'relative_target' };
104
+ if (/%2f/i.test(pathPart) || /%5c/i.test(pathPart)) return { ok: false, reason: 'encoded_separator' };
105
+ if (/%2e/i.test(pathPart)) return { ok: false, reason: 'dot_segment' };
106
+
107
+ let decoded;
108
+ try {
109
+ decoded = decodeURIComponent(pathPart);
110
+ } catch {
111
+ return { ok: false, reason: 'bad_encoding' };
112
+ }
113
+ if (decoded.includes('\0') || decoded.includes('\\') || decoded.includes('#') || decoded.includes('?')) {
114
+ return { ok: false, reason: 'ambiguous_path' };
115
+ }
116
+ if (!decoded.startsWith('/')) return { ok: false, reason: 'relative_target' };
117
+ for (const segment of decoded.split('/')) {
118
+ if (segment === '.' || segment === '..') return { ok: false, reason: 'dot_segment' };
119
+ }
120
+ return { ok: true, pathname: decoded, search };
121
+ }
122
+
123
+ function headerValue(headers, name) {
124
+ const wanted = String(name || '').toLowerCase();
125
+ for (const [key, value] of Object.entries(headers || {})) {
126
+ if (String(key).toLowerCase() === wanted) {
127
+ return Array.isArray(value) ? String(value[0] || '') : String(value || '');
128
+ }
129
+ }
130
+ return '';
131
+ }
132
+
133
+ function placeholderMatches(provided, expected) {
134
+ const left = Buffer.from(String(provided || ''), 'utf8');
135
+ const right = Buffer.from(String(expected || ''), 'utf8');
136
+ if (left.length !== right.length) {
137
+ const fill = Buffer.alloc(right.length);
138
+ crypto.timingSafeEqual(fill, right);
139
+ return false;
140
+ }
141
+ return crypto.timingSafeEqual(left, right);
142
+ }
143
+
144
+ function requestHasBody(req) {
145
+ if (req.headers['transfer-encoding']) return true;
146
+ const length = req.headers['content-length'];
147
+ if (length === undefined) return false;
148
+ const n = Number(length);
149
+ return !Number.isFinite(n) || n > 0;
150
+ }
151
+
152
+ function parseGatewaySession(raw) {
153
+ const parsed = typeof raw === 'string' ? JSON.parse(raw) : (raw || {});
154
+ const grant = normalizeGrant(parsed.grant);
155
+ const placeholder = String(parsed.placeholder || '');
156
+ const secret = String(parsed.secret || '');
157
+ if (!placeholder) throw new Error('gateway session requires placeholder');
158
+ if (!secret) throw new Error('gateway session requires secret');
159
+ return {
160
+ grant,
161
+ placeholder,
162
+ secret,
163
+ upstreamPort: Number.isInteger(parsed.upstreamPort) ? parsed.upstreamPort : 443,
164
+ upstreamAddress: parsed.upstreamAddress ? String(parsed.upstreamAddress) : grant.host,
165
+ rejectUnauthorized: parsed.rejectUnauthorized !== false,
166
+ };
167
+ }
168
+
169
+ function applySecretGrantEnvironment(environment, grantInput, options = {}) {
170
+ const grant = normalizeGrant(grantInput);
171
+ const placeholder = options.placeholder || createPlaceholder();
172
+ environment[grant.placeholderEnv] = placeholder;
173
+ for (const key of PROXY_ENV_KEYS) {
174
+ if (key.toUpperCase() === 'NO_PROXY' || key === 'no_proxy') environment[key] = '127.0.0.1,localhost';
175
+ else environment[key] = '';
176
+ }
177
+ const plan = {
178
+ grant,
179
+ placeholder,
180
+ };
181
+ if (options.upstreamPort !== undefined) plan.upstreamPort = options.upstreamPort;
182
+ if (options.upstreamAddress !== undefined) plan.upstreamAddress = options.upstreamAddress;
183
+ if (options.rejectUnauthorized !== undefined) plan.rejectUnauthorized = options.rejectUnauthorized;
184
+ environment.ATRIS_ONE_LAP_SECRET_GATEWAY = JSON.stringify(plan);
185
+ return { grant, placeholder, plan };
186
+ }
187
+
188
+ function childEnvironmentWithGateway(baseEnv, session, gateway) {
189
+ const env = { ...baseEnv };
190
+ delete env.ATRIS_ONE_LAP_SECRET_GATEWAY;
191
+ delete env.ATRIS_ONE_LAP_SECRET_GATEWAY_STDIN;
192
+ if (session.grant.secretEnv !== session.grant.placeholderEnv) {
193
+ delete env[session.grant.secretEnv];
194
+ }
195
+ env[session.grant.placeholderEnv] = session.placeholder;
196
+ env[session.grant.baseUrlEnv] = gateway.baseUrl;
197
+ return env;
198
+ }
199
+
200
+ function recordReceipt(receipts, entry) {
201
+ receipts.push({
202
+ grant_id: entry.grant_id,
203
+ decision: entry.decision,
204
+ method: entry.method,
205
+ host: entry.host,
206
+ path: entry.path,
207
+ upstream_status: entry.upstream_status == null ? null : entry.upstream_status,
208
+ request_bytes: entry.request_bytes || 0,
209
+ response_bytes: entry.response_bytes || 0,
210
+ reason: entry.reason || null,
211
+ });
212
+ }
213
+
214
+ function rejectClient(res, receipts, meta, status, reason) {
215
+ recordReceipt(receipts, {
216
+ grant_id: meta.grant_id,
217
+ decision: 'deny',
218
+ method: meta.method,
219
+ host: meta.host,
220
+ path: meta.path || '',
221
+ reason,
222
+ });
223
+ res.statusCode = status;
224
+ res.setHeader('content-type', 'text/plain; charset=utf-8');
225
+ res.end('gateway denied\n');
226
+ }
227
+
228
+ function hostHeaderAllowed(hostHeader, listenPort) {
229
+ const raw = String(hostHeader || '').trim().toLowerCase();
230
+ if (!raw) return false;
231
+ const allowed = new Set([
232
+ '127.0.0.1',
233
+ `127.0.0.1:${listenPort}`,
234
+ 'localhost',
235
+ `localhost:${listenPort}`,
236
+ ]);
237
+ return allowed.has(raw);
238
+ }
239
+
240
+ function startSecretGateway(sessionInput) {
241
+ const session = parseGatewaySession(sessionInput);
242
+ const receipts = [];
243
+ let listening = false;
244
+ let listenPort = 0;
245
+
246
+ const server = http.createServer((req, res) => {
247
+ const method = String(req.method || '').toUpperCase();
248
+ const meta = {
249
+ grant_id: session.grant.id,
250
+ method,
251
+ host: session.grant.host,
252
+ path: '',
253
+ };
254
+
255
+ if (!session.grant.methods.includes(method) || !ALLOWED_METHODS.has(method)) {
256
+ rejectClient(res, receipts, meta, 405, 'method');
257
+ return;
258
+ }
259
+ if (requestHasBody(req)) {
260
+ rejectClient(res, receipts, meta, 400, 'body');
261
+ return;
262
+ }
263
+ if (!hostHeaderAllowed(headerValue(req.headers, 'host'), listenPort)) {
264
+ rejectClient(res, receipts, meta, 400, 'host_override');
265
+ return;
266
+ }
267
+
268
+ const target = inspectRequestTarget(req.url);
269
+ if (!target.ok) {
270
+ rejectClient(res, receipts, meta, 400, target.reason);
271
+ return;
272
+ }
273
+ meta.path = target.pathname;
274
+ if (!pathMatchesGrant(target.pathname, session.grant.pathPrefixes)) {
275
+ rejectClient(res, receipts, meta, 403, 'path');
276
+ return;
277
+ }
278
+
279
+ const provided = headerValue(req.headers, session.grant.credentialHeader);
280
+ if (!placeholderMatches(provided, session.placeholder)) {
281
+ rejectClient(res, receipts, meta, 401, 'placeholder');
282
+ return;
283
+ }
284
+
285
+ const requestHeaders = {};
286
+ for (const [key, value] of Object.entries(req.headers || {})) {
287
+ const lower = String(key).toLowerCase();
288
+ if (lower === 'host' || lower === 'connection' || lower === session.grant.credentialHeader) continue;
289
+ if (lower === 'content-length' || lower === 'transfer-encoding') continue;
290
+ requestHeaders[key] = value;
291
+ }
292
+ requestHeaders.Host = session.grant.host;
293
+ requestHeaders[session.grant.credentialHeader] = session.secret;
294
+
295
+ const upstreamReq = https.request({
296
+ protocol: 'https:',
297
+ hostname: session.upstreamAddress,
298
+ port: session.upstreamPort,
299
+ servername: session.grant.host,
300
+ path: `${target.pathname}${target.search}`,
301
+ method,
302
+ headers: requestHeaders,
303
+ rejectUnauthorized: session.rejectUnauthorized,
304
+ }, (upstreamRes) => {
305
+ const status = Number(upstreamRes.statusCode || 0);
306
+ if (status >= 300 && status < 400) {
307
+ upstreamRes.resume();
308
+ recordReceipt(receipts, {
309
+ grant_id: session.grant.id,
310
+ decision: 'deny',
311
+ method,
312
+ host: session.grant.host,
313
+ path: target.pathname,
314
+ upstream_status: status,
315
+ reason: 'redirect',
316
+ });
317
+ res.statusCode = 502;
318
+ res.setHeader('content-type', 'text/plain; charset=utf-8');
319
+ res.end('gateway denied redirect\n');
320
+ return;
321
+ }
322
+
323
+ let responseBytes = 0;
324
+ res.statusCode = status || 502;
325
+ for (const [key, value] of Object.entries(upstreamRes.headers || {})) {
326
+ const lower = String(key).toLowerCase();
327
+ if (STRIP_RESPONSE_HEADERS.has(lower)) continue;
328
+ if (lower === 'connection' || lower === 'transfer-encoding') continue;
329
+ if (value !== undefined) res.setHeader(key, value);
330
+ }
331
+ upstreamRes.on('data', (chunk) => {
332
+ responseBytes += chunk.length;
333
+ res.write(chunk);
334
+ });
335
+ upstreamRes.on('end', () => {
336
+ recordReceipt(receipts, {
337
+ grant_id: session.grant.id,
338
+ decision: 'allow',
339
+ method,
340
+ host: session.grant.host,
341
+ path: target.pathname,
342
+ upstream_status: status,
343
+ response_bytes: responseBytes,
344
+ });
345
+ res.end();
346
+ });
347
+ upstreamRes.on('error', () => {
348
+ if (!res.headersSent) {
349
+ rejectClient(res, receipts, meta, 502, 'upstream_error');
350
+ } else {
351
+ res.destroy();
352
+ }
353
+ });
354
+ });
355
+
356
+ upstreamReq.on('error', () => {
357
+ rejectClient(res, receipts, meta, 502, 'upstream_connect');
358
+ });
359
+ upstreamReq.end();
360
+ });
361
+
362
+ return new Promise((resolve, reject) => {
363
+ server.once('error', reject);
364
+ server.listen(0, '127.0.0.1', () => {
365
+ listening = true;
366
+ const address = server.address();
367
+ listenPort = address && address.port;
368
+ resolve({
369
+ port: listenPort,
370
+ baseUrl: `http://127.0.0.1:${listenPort}`,
371
+ receipts,
372
+ isListening: () => listening && server.listening,
373
+ close: () => new Promise((closeResolve, closeReject) => {
374
+ if (!server.listening) {
375
+ listening = false;
376
+ closeResolve();
377
+ return;
378
+ }
379
+ server.close((error) => {
380
+ listening = false;
381
+ if (error) closeReject(error);
382
+ else closeResolve();
383
+ });
384
+ }),
385
+ });
386
+ });
387
+ });
388
+ }
389
+
390
+ function buildGatewaySupervisorScript(gatewayModulePath) {
391
+ return [
392
+ "'use strict';",
393
+ "const fs = require('node:fs');",
394
+ "const { spawn, spawnSync } = require('node:child_process');",
395
+ `const sg = require(${JSON.stringify(gatewayModulePath)});`,
396
+ "const [exitFile, stateFile, leaseFile, statusFile, executable, ...args] = process.argv.slice(2);",
397
+ "const leaseFd = fs.openSync(leaseFile, 'w', 0o600);",
398
+ "const statusFd = fs.openSync(statusFile, 'w', 0o600);",
399
+ 'function writeExit(code) {',
400
+ ' const exitCode = Number.isInteger(code) ? code : 128;',
401
+ " try { fs.writeFileSync(exitFile, String(exitCode) + '\\n', { mode: 0o600 }); } catch {}",
402
+ ' process.exit(exitCode);',
403
+ '}',
404
+ '',
405
+ 'async function main() {',
406
+ " const session = sg.parseGatewaySession(fs.readFileSync(0, 'utf8'));",
407
+ ' const gateway = await sg.startSecretGateway(session);',
408
+ ' try { process.stdin.pause(); process.stdin.destroy(); } catch {}',
409
+ ' const childEnv = sg.childEnvironmentWithGateway(process.env, session, gateway);',
410
+ " const child = spawn(executable, args, { cwd: process.cwd(), env: childEnv, detached: true, stdio: ['ignore', 'inherit', 'inherit', leaseFd, statusFd] });",
411
+ ' fs.closeSync(leaseFd);',
412
+ ' fs.closeSync(statusFd);',
413
+ " fs.writeFileSync(stateFile, JSON.stringify({ pgid: child.pid, cwd: process.cwd() }) + '\\n', { mode: 0o600 });",
414
+ ' let stopping = false;',
415
+ ' const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));',
416
+ ' function trackedPids() {',
417
+ ' const pids = new Set();',
418
+ " for (const [bin, argv] of [['/usr/bin/pgrep', ['-g', String(child.pid)]], ['/usr/sbin/lsof', ['-t', leaseFile]], ['/usr/sbin/lsof', ['-a', '-d', 'cwd', '+D', process.cwd(), '-t']]]) {",
419
+ " const found = spawnSync(bin, argv, { encoding: 'utf8' });",
420
+ " for (const value of String(found.stdout || '').trim().split(/\\s+/)) {",
421
+ ' const pid = Number(value);',
422
+ ' if (Number.isInteger(pid) && pid > 0) pids.add(pid);',
423
+ ' }',
424
+ ' }',
425
+ ' pids.delete(process.pid);',
426
+ ' return [...pids];',
427
+ ' }',
428
+ ' async function stop(code) {',
429
+ ' if (stopping) return;',
430
+ ' stopping = true;',
431
+ " if (executable === '/usr/bin/sandbox-exec' && args[0] === '-p' && args[1]) {",
432
+ " spawnSync(executable, ['-p', args[1], '/bin/kill', '-KILL', '-1'], { cwd: process.cwd(), env: process.env, stdio: 'ignore', timeout: 5000 });",
433
+ ' }',
434
+ " for (const [signal, delay] of [['SIGTERM', 100], ['SIGKILL', 100], ['SIGKILL', 100]]) {",
435
+ ' try { process.kill(-child.pid, signal); } catch {}',
436
+ ' for (const pid of trackedPids()) { try { process.kill(pid, signal); } catch {} }',
437
+ ' await wait(delay);',
438
+ ' }',
439
+ ' try { await gateway.close(); } catch {}',
440
+ ' let exitCode = Number.isInteger(code) ? code : 128;',
441
+ ' try {',
442
+ " const savedText = fs.readFileSync(statusFile, 'utf8').trim();",
443
+ ' const saved = Number(savedText);',
444
+ ' if (savedText && Number.isInteger(saved) && saved >= 0 && saved <= 255) exitCode = saved;',
445
+ ' } catch {}',
446
+ ' writeExit(exitCode);',
447
+ ' }',
448
+ " child.once('error', () => { void stop(1); });",
449
+ " child.once('exit', (code) => { void stop(code); });",
450
+ " for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.on(signal, () => { void stop(143); });",
451
+ '}',
452
+ '',
453
+ 'main().catch(() => { writeExit(1); });',
454
+ '',
455
+ ].join('\n');
456
+ }
457
+
458
+ function probeListening(port) {
459
+ return new Promise((resolve) => {
460
+ const socket = net.connect({ host: '127.0.0.1', port }, () => {
461
+ socket.end();
462
+ resolve(true);
463
+ });
464
+ socket.on('error', () => resolve(false));
465
+ });
466
+ }
467
+
468
+ // Spawn the gateway supervisor without spawnSync. Some agent sandboxes deny
469
+ // network to every spawnSync descendant, while a normal spawn child can open
470
+ // HTTPS. The waiter must also keep this event loop alive: a sync sleep loop
471
+ // parks the parent and the sandbox then blocks child TLS too. Poll the exit
472
+ // file on a timer, and yield with spawnSync('/bin/sleep', ['0.05']) inside
473
+ // each tick only as a short OS pause after the timer has already fired.
474
+ function spawnBlocking(command, args, options = {}) {
475
+ const exitFile = options.exitFile;
476
+ if (!exitFile) throw new Error('spawnBlocking requires exitFile');
477
+ try { fs.unlinkSync(exitFile); } catch {}
478
+
479
+ // The session may carry the real secret: it must reach the supervisor only
480
+ // through the stdin pipe, never a temp file on disk.
481
+ const child = spawn(command, args, {
482
+ cwd: options.cwd,
483
+ env: options.env,
484
+ stdio: [options.input != null ? 'pipe' : 'ignore', 'pipe', 'pipe'],
485
+ });
486
+ if (options.input != null && child.stdin) {
487
+ child.stdin.on('error', () => {});
488
+ child.stdin.end(String(options.input));
489
+ }
490
+
491
+ const stdoutChunks = [];
492
+ const stderrChunks = [];
493
+ let spawnError = null;
494
+ if (child.stdout) child.stdout.on('data', (chunk) => {
495
+ stdoutChunks.push(Buffer.from(chunk));
496
+ if (typeof options.onStdoutChunk === 'function') options.onStdoutChunk(chunk);
497
+ });
498
+ if (child.stderr) child.stderr.on('data', (chunk) => {
499
+ stderrChunks.push(Buffer.from(chunk));
500
+ if (typeof options.onStderrChunk === 'function') options.onStderrChunk(chunk);
501
+ });
502
+ child.on('error', (error) => { spawnError = error; });
503
+
504
+ const encode = (chunks) => {
505
+ const buf = Buffer.concat(chunks);
506
+ if (options.encoding) return buf.toString(options.encoding);
507
+ return buf;
508
+ };
509
+
510
+ const started = Date.now();
511
+ const timeout = Number(options.timeout) || 0;
512
+
513
+ return new Promise((resolve) => {
514
+ let settled = false;
515
+ const finish = (result) => {
516
+ if (settled) return;
517
+ settled = true;
518
+ clearInterval(timer);
519
+ resolve(result);
520
+ };
521
+
522
+ const timer = setInterval(() => {
523
+ // Short OS pause after the timer fires; do not replace the timer with a
524
+ // sync sleep loop or child HTTPS stalls in agent sandboxes.
525
+ spawnSync('/bin/sleep', ['0.05'], { stdio: 'ignore' });
526
+
527
+ if (spawnError) {
528
+ finish({
529
+ pid: child.pid,
530
+ status: null,
531
+ signal: null,
532
+ stdout: encode(stdoutChunks),
533
+ stderr: encode(stderrChunks),
534
+ error: spawnError,
535
+ });
536
+ return;
537
+ }
538
+ if (fs.existsSync(exitFile)) {
539
+ let status = 0;
540
+ try {
541
+ const raw = fs.readFileSync(exitFile, 'utf8').trim();
542
+ const parsed = Number(raw);
543
+ if (Number.isInteger(parsed)) status = parsed;
544
+ } catch {}
545
+ try { child.kill('SIGTERM'); } catch {}
546
+ finish({
547
+ pid: child.pid,
548
+ status,
549
+ signal: null,
550
+ stdout: encode(stdoutChunks),
551
+ stderr: encode(stderrChunks),
552
+ error: null,
553
+ });
554
+ return;
555
+ }
556
+ if (timeout && Date.now() - started > timeout) {
557
+ try { child.kill('SIGKILL'); } catch {}
558
+ const error = new Error('spawnBlocking ETIMEDOUT');
559
+ error.code = 'ETIMEDOUT';
560
+ error.errno = -60;
561
+ finish({
562
+ pid: child.pid,
563
+ status: null,
564
+ signal: 'SIGKILL',
565
+ stdout: encode(stdoutChunks),
566
+ stderr: encode(stderrChunks),
567
+ error,
568
+ });
569
+ }
570
+ }, 50);
571
+ if (typeof timer.unref === 'function') timer.unref();
572
+ });
573
+ }
574
+
575
+ module.exports = {
576
+ createPlaceholder,
577
+ normalizeGrant,
578
+ pathMatchesGrant,
579
+ inspectRequestTarget,
580
+ parseGatewaySession,
581
+ applySecretGrantEnvironment,
582
+ childEnvironmentWithGateway,
583
+ startSecretGateway,
584
+ buildGatewaySupervisorScript,
585
+ probeListening,
586
+ spawnBlocking,
587
+ PROXY_ENV_KEYS,
588
+ };
@@ -193,7 +193,7 @@ function buildTeamPresence(input = {}) {
193
193
  for (const rows of missionsByMember.values()) rows.sort((a, b) => activityOrder(a, b, missionActivityMs));
194
194
 
195
195
  const cutoffMs = nowMs - freshnessWindowMs;
196
- const members = [...candidates.entries()]
196
+ const activePeople = [...candidates.entries()]
197
197
  .sort((a, b) => a[1].localeCompare(b[1]))
198
198
  .flatMap(([key, name]) => {
199
199
  const seenMs = lastSeen.get(key) || 0;
@@ -212,6 +212,9 @@ function buildTeamPresence(input = {}) {
212
212
  last_seen: new Date(seenMs).toISOString(),
213
213
  }];
214
214
  });
215
+ const operatorKey = keyFor(input.operator || 'operator');
216
+ const operator = activePeople.find((person) => keyFor(person.name) === operatorKey) || null;
217
+ const members = activePeople.filter((person) => keyFor(person.name) !== operatorKey);
215
218
 
216
219
  return {
217
220
  schema: 'atris.team_presence.v1',
@@ -222,6 +225,7 @@ function buildTeamPresence(input = {}) {
222
225
  waiting_operator: Math.max(0, Number(stream.waiting_operator) || 0),
223
226
  landing_wait: Math.max(0, Number(stream.landing_wait) || 0),
224
227
  },
228
+ operator,
225
229
  members,
226
230
  };
227
231
  }
@@ -235,6 +239,14 @@ function renderTeamPresence(presence) {
235
239
  `waiting on operator: ${presence.totals.waiting_operator}`,
236
240
  `landing wait: ${presence.totals.landing_wait}`,
237
241
  ];
242
+ if (presence.operator) {
243
+ lines.push('operator:');
244
+ lines.push(` ${presence.operator.name}: ${presence.operator.doing}`);
245
+ if (presence.operator.loop) {
246
+ lines.push(` loop: ${presence.operator.loop.mission} [${presence.operator.loop.cadence} | ${presence.operator.loop.runner} | last tick ${presence.operator.loop.last_tick}]`);
247
+ }
248
+ lines.push(` last seen: ${presence.operator.last_seen}`);
249
+ }
238
250
  if (!presence.members.length) {
239
251
  lines.push('awake roster: empty');
240
252
  return lines.join('\n');
package/lib/voice-gate.js CHANGED
@@ -226,11 +226,16 @@ const LANDING_REASON_SENTENCES = {
226
226
  autoland_policy_off: 'self-landing is off, so everything waits for you',
227
227
  certified_independent_review: 'an independent reviewer certified it, so it can land',
228
228
  certified_strict_verify: 'it is certified and its recorded check passed, so it can land',
229
+ candidate_scope_unknown: 'the changed files were not recorded clearly, so taste checks stay advisory for this item',
229
230
  dead_exports: 'the change leaves behind code nothing calls, delete what the hygiene check names and re-certify',
230
231
  declared_protected_lane: 'it touches a protected lane, so it waits for your decision',
231
232
  forced_completion_needs_human: 'it was pushed to done without proof, so only a human can accept it',
232
233
  insufficient_review_passes: 'it has not been reviewed enough times yet, one more pass unblocks it',
233
234
  judge_equals_worker: 'built and judged by the same actor, hand the review to someone else',
235
+ lesson_gate: 'the touched work breaks a detector-backed lesson, fix that named lesson and re-certify',
236
+ lesson_detector_pending: 'a matching lesson check still needs to run before the final landing decision',
237
+ lesson_detector_unrunnable: 'a matching lesson check could not start, repair the check before relying on it',
238
+ lesson_has_no_detector: 'a matching written lesson has no runnable check, so it remains advice only',
234
239
  mission_xp_requires_end_to_end_receipt: 'the proof does not show the whole flow working start to finish, attach that receipt or move it back to do',
235
240
  needs_independent_reviewer: 'built and judged by the same actor, a review from someone else unblocks it',
236
241
  needs_second_actor_review: 'the same actor built and reviewed it, someone else has to look before it lands',
@@ -243,6 +248,7 @@ const LANDING_REASON_SENTENCES = {
243
248
  proof_required: 'no proof was given, say what was run and what it showed',
244
249
  proof_unmerged_or_draft_pr_boundary: 'its proof points at an unmerged draft, merge it or prove the work another way',
245
250
  receipt_verifier_failed: 'a saved receipt failed its re-check, fix what it names and re-certify',
251
+ slop_gate: 'the touched prose breaks a deterministic taste rule, fix the named lines and re-certify',
246
252
  strict_verify_missing: 'no recorded check command to re-run, add one and re-certify',
247
253
  untagged_protected_lane_text: 'the description reads like protected-lane work without the tag, tag it or decide it yourself',
248
254
  verification_pending: 'the recorded check has not been re-run yet, the next hourly pass runs it',