atris 3.63.0 → 3.64.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 (65) hide show
  1. package/atris/skills/commerce/SKILL.md +24 -0
  2. package/atris/skills/x-search/SKILL.md +3 -1
  3. package/ax +4 -3
  4. package/bin/atris.js +11 -2
  5. package/cli/atris_code.py +25 -1
  6. package/commands/auth.js +79 -10
  7. package/commands/autoland.js +11 -1
  8. package/commands/browse.js +2 -2
  9. package/commands/business.js +3 -3
  10. package/commands/buy.js +3 -3
  11. package/commands/deck.js +2 -2
  12. package/commands/engine.js +122 -13
  13. package/commands/feedback.js +2 -2
  14. package/commands/fork.js +2 -2
  15. package/commands/gm.js +2 -1
  16. package/commands/improve.js +142 -8
  17. package/commands/integrations.js +4 -3
  18. package/commands/loops.js +2 -1
  19. package/commands/member.js +47 -9
  20. package/commands/mission.js +1 -1
  21. package/commands/pack-craft.js +2 -2
  22. package/commands/publish.js +2 -2
  23. package/commands/radar.js +3 -2
  24. package/commands/router.js +2 -2
  25. package/commands/task.js +84 -2
  26. package/commands/transactions.js +3 -3
  27. package/commands/truth.js +2 -2
  28. package/commands/x-search.js +81 -10
  29. package/commands/youtube.js +2 -1
  30. package/lib/autoland.js +3 -2
  31. package/lib/ax-auto-lane.js +2 -2
  32. package/lib/brief-rules.js +11 -0
  33. package/lib/chat-log-scan.js +2 -1
  34. package/lib/ci-runner.js +3 -2
  35. package/lib/codex-flight.js +2 -1
  36. package/lib/commerce.js +17 -2
  37. package/lib/deck-history.js +2 -2
  38. package/lib/deck-review.js +3 -3
  39. package/lib/design-api.js +2 -2
  40. package/lib/engine-registry.js +85 -18
  41. package/lib/first-minute.js +2 -2
  42. package/lib/fleet.js +8 -36
  43. package/lib/git-spawn.js +5 -2
  44. package/lib/ignored-cache.js +51 -0
  45. package/lib/manifest.js +2 -2
  46. package/lib/map-refs.js +70 -4
  47. package/lib/pack-capabilities.js +2 -1
  48. package/lib/permission-grants.js +2 -2
  49. package/lib/pulse.js +5 -5
  50. package/lib/roster-runs.js +13 -2
  51. package/lib/runner-command.js +22 -6
  52. package/lib/sandbox-pids.js +113 -0
  53. package/lib/secret-gateway.js +3 -13
  54. package/lib/state-home.js +106 -0
  55. package/lib/task-db.js +4 -3
  56. package/lib/task-prune.js +462 -0
  57. package/lib/x-post-check.js +497 -0
  58. package/package.json +1 -1
  59. package/scripts/det/data/xsearch-answer.json +24 -0
  60. package/scripts/det/data/xsearch-live-2026-09-29.json +93 -0
  61. package/scripts/det/xsearch-bench.js +307 -0
  62. package/scripts/det/ytnotes +61 -15
  63. package/scripts/det/ytrail-bench.js +2 -1
  64. package/utils/auth.js +3 -3
  65. package/utils/update-check.js +2 -2
@@ -0,0 +1,11 @@
1
+ 'use strict';
2
+
3
+ // Rule lines every builder brief carries (lib/codex-flight.js buildPrompt and
4
+ // lib/fleet.js buildFleetPrompt). The text lives here once so the two briefs
5
+ // cannot drift apart.
6
+ const SHARED_BRIEF_RULES = [
7
+ '- Keep one concern per PR; split anything larger into separate PRs because git history guides future agents and small PRs are cheap to revert and bisect.',
8
+ '- A guard, limit, or cache must hold on every path to the thing it protects; grep for the other callers, templates, and writers, and test at least one path you were not pointed at.',
9
+ ];
10
+
11
+ module.exports = { SHARED_BRIEF_RULES };
@@ -1,6 +1,7 @@
1
1
  const fs = require('fs');
2
2
  const os = require('os');
3
3
  const path = require('path');
4
+ const { stateHome } = require('./state-home');
4
5
 
5
6
  const SCHEMA = 'atris.chat_scan.v1';
6
7
  const PROMPT_LINE = /^(?:max|pro|fast|code-fast)\s*›\s*(.+)$/i;
@@ -73,7 +74,7 @@ function axLogDirs(cwd = process.cwd(), options = {}) {
73
74
  const local = path.join(cwd, 'atris', 'runs');
74
75
  if (fs.existsSync(local)) dirs.push(local);
75
76
  if (options.global) {
76
- const home = path.join(os.homedir(), '.atris', 'runs');
77
+ const home = path.join(stateHome(), '.atris', 'runs');
77
78
  if (fs.existsSync(home) && home !== local) dirs.push(home);
78
79
  }
79
80
  return dirs;
package/lib/ci-runner.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  const fs = require('node:fs');
4
4
  const path = require('node:path');
5
+ const { stateHome } = require('./state-home');
5
6
  const os = require('node:os');
6
7
  const https = require('node:https');
7
8
  const { spawn, spawnSync } = require('node:child_process');
@@ -9,7 +10,7 @@ const { spawn, spawnSync } = require('node:child_process');
9
10
  const RUNNER_VERSION = '2.336.0';
10
11
  const GITHUB_API_VERSION = '2026-03-10';
11
12
 
12
- function usageFilePath(home = os.homedir()) {
13
+ function usageFilePath(home = stateHome()) {
13
14
  return path.join(home, '.atris', 'ci', 'usage.jsonl');
14
15
  }
15
16
 
@@ -309,7 +310,7 @@ async function ensureRunner(options = {}) {
309
310
  const version = options.version || RUNNER_VERSION;
310
311
  const platform = options.platform || process.platform;
311
312
  const arch = options.arch || process.arch;
312
- const home = options.home || os.homedir();
313
+ const home = options.home || stateHome();
313
314
  const log = options.log || console.log;
314
315
  const asset = runnerAssetName(platform, arch, version);
315
316
  const runnerPlatform = platform === 'darwin' ? 'osx' : platform;
@@ -4,6 +4,7 @@ const fs = require('fs');
4
4
  const os = require('os');
5
5
  const path = require('path');
6
6
  const { spawn, spawnSync } = require('child_process');
7
+ const { SHARED_BRIEF_RULES } = require('./brief-rules');
7
8
 
8
9
  // Worktree flights need full file access to edit and ship the requested repo.
9
10
  const FLIGHT_SANDBOX = 'danger-full-access';
@@ -59,7 +60,7 @@ function buildPrompt({ worktreePath, branch, brief, verifyCmd }) {
59
60
  '',
60
61
  'Instructions:',
61
62
  `- First run: atris worktree guard`,
62
- '- Keep one concern per PR; split anything larger into separate PRs because git history guides future agents and small PRs are cheap to revert and bisect.',
63
+ ...SHARED_BRIEF_RULES,
63
64
  `- Verify with this bare command and use its real exit code: ${verify}`,
64
65
  '- Stage and commit only files you changed.',
65
66
  `- Commit with a plain-English message and include this trailer: ${COAUTHOR}`,
package/lib/commerce.js CHANGED
@@ -3,7 +3,8 @@
3
3
  // Shared helpers for `atris buy` and `atris transactions`.
4
4
  // Backend: /api/commerce/* and /api/transactions (commerce_router.py).
5
5
 
6
- const { ROLLOUT_MESSAGE, oneLine } = require('./developer-api');
6
+ const { ROLLOUT_MESSAGE, oneLine, tokenFrom } = require('./developer-api');
7
+ const { decodeJwtClaims } = require('../utils/auth');
7
8
 
8
9
  // Flags whose values the backend reads as numbers.
9
10
  const NUMERIC_KEYS = new Set(['quantity', 'dollars', 'limit_price', 'min_each_usd', 'max_each_usd', 'limit']);
@@ -72,4 +73,18 @@ function errorFrom(result) {
72
73
  return oneLine(result && result.error);
73
74
  }
74
75
 
75
- module.exports = { parseFlags, formatCents, untilText, errorFrom };
76
+ // The commerce key (minted with `atris login --agent --commerce`) when it is
77
+ // present, unexpired, and carries the scope this call needs; else the login.
78
+ function commerceToken(creds, scope, now = Date.now()) {
79
+ const key = creds && typeof creds.commerce_agent_token === 'string' ? creds.commerce_agent_token.trim() : '';
80
+ if (key) {
81
+ const claims = decodeJwtClaims(key) || {};
82
+ const scopes = Array.isArray(claims.scopes) ? claims.scopes
83
+ : (Array.isArray(creds.commerce_agent_token_scopes) ? creds.commerce_agent_token_scopes : []);
84
+ const expiry = Number.isFinite(claims.exp) ? claims.exp * 1000 : Date.parse(creds.commerce_agent_token_expires_at || '');
85
+ if (scopes.includes(scope) && Number.isFinite(expiry) && expiry > now) return key;
86
+ }
87
+ return tokenFrom(creds);
88
+ }
89
+
90
+ module.exports = { parseFlags, formatCents, untilText, errorFrom, commerceToken };
@@ -4,11 +4,11 @@
4
4
  // you track iterations of a spec over time.
5
5
 
6
6
  const fs = require('fs');
7
- const os = require('os');
8
7
  const path = require('path');
8
+ const { stateHome } = require('./state-home');
9
9
  const crypto = require('crypto');
10
10
 
11
- const DEFAULT_HISTORY_PATH = path.join(os.homedir(), '.atris', 'deck-history.jsonl');
11
+ const DEFAULT_HISTORY_PATH = path.join(stateHome(), '.atris', 'deck-history.jsonl');
12
12
 
13
13
  // Short, stable content hash of a spec, same spec -> same hash, so you can
14
14
  // tell a no-op rebuild from a real change.
@@ -6,13 +6,13 @@
6
6
 
7
7
  const fs = require('fs');
8
8
  const https = require('https');
9
- const os = require('os');
10
9
  const path = require('path');
10
+ const { stateHome } = require('./state-home');
11
11
 
12
12
  const SCHEMA = 'atris.deck_review.v1';
13
13
  const RECEIPT_SCHEMA = 'atris.deck_receipt.v1';
14
- const DEFAULT_OUT_ROOT = path.join(os.homedir(), '.atris', 'deck-review');
15
- const DEFAULT_RECEIPTS_PATH = path.join(os.homedir(), '.atris', 'deck-receipts.jsonl');
14
+ const DEFAULT_OUT_ROOT = path.join(stateHome(), '.atris', 'deck-review');
15
+ const DEFAULT_RECEIPTS_PATH = path.join(stateHome(), '.atris', 'deck-receipts.jsonl');
16
16
 
17
17
  const AGENT_CHECKLIST = [
18
18
  'Heading and sub do not overlap',
package/lib/design-api.js CHANGED
@@ -14,12 +14,12 @@
14
14
  // POST /design/search {"query", "limit"} -> sync result
15
15
 
16
16
  const fs = require('fs');
17
- const os = require('os');
18
17
  const path = require('path');
18
+ const { stateHome } = require('./state-home');
19
19
  const { getApiBaseUrl, httpRequest } = require('../utils/api');
20
20
  const { loadCredentials } = require('../utils/auth');
21
21
 
22
- const DESIGN_KEY_FILE = path.join(os.homedir(), '.atris', 'design-api-key');
22
+ const DESIGN_KEY_FILE = path.join(stateHome(), '.atris', 'design-api-key');
23
23
 
24
24
  const POLL_INTERVAL_MS = 3000;
25
25
  const POLL_TIMEOUT_MS = 3 * 60 * 1000;
@@ -3,6 +3,7 @@
3
3
  const fs = require('fs');
4
4
  const os = require('os');
5
5
  const path = require('path');
6
+ const { stateHome } = require('./state-home');
6
7
  const childProcess = require('child_process');
7
8
  const crypto = require('crypto');
8
9
  const {
@@ -155,7 +156,10 @@ const ENGINE_SEED_META = Object.freeze({
155
156
  agy: Object.freeze({
156
157
  tier: 'pro',
157
158
  roles: Object.freeze(['executor']),
159
+ // The plain flash name takes its effort from --effort; the -high names
160
+ // bake the effort into the model.
158
161
  models: Object.freeze([
162
+ 'gemini-3.8-flash',
159
163
  'gemini-3.8-flash-high',
160
164
  'gemini-3.7-flash-high',
161
165
  'gemini-3.1-pro-high',
@@ -402,6 +406,15 @@ const MACHINE_ROSTER_SCHEMA = 'atris.machine_roster.v1';
402
406
  // The owner's own job for small, tightly specified builds. A build resolved
403
407
  // with lowStakes checks this pick first, then the plain build pick.
404
408
  const SMALL_BUILD_JOB = 'small-build';
409
+ // Jobs any engine may fill, keyed to the kind they count as when assign is
410
+ // not told one. YouTube notes are plain read-only text, so agy (build-only)
411
+ // and haiku (search and review) can both write them. Every other job keeps
412
+ // its kind check.
413
+ const ANY_ENGINE_JOBS = Object.freeze({ notes: 'search' });
414
+
415
+ function jobTakesAnyEngine(key) {
416
+ return Object.prototype.hasOwnProperty.call(ANY_ENGINE_JOBS, String(key || ''));
417
+ }
405
418
 
406
419
  function rosterJob(name) {
407
420
  const job = String(name || '').trim().toLowerCase();
@@ -472,10 +485,10 @@ function customRosterJobKeys(roster) {
472
485
  // an engine and model that can run, and that engine (or its backup) is ready.
473
486
  // A pick that cannot run never sets the job's kind, so it cannot block a
474
487
  // ready all-projects pick of another kind.
475
- function rosterPickLive(pick, now, registry) {
488
+ function rosterPickLive(pick, now, registry, key) {
476
489
  const role = customPickRole(pick);
477
490
  if (!role || !canonicalEngineName(pick.engine)) return false;
478
- return Boolean(rosterChoiceForPick(pick, role, registry, { label: rosterJobLabel(role), now }));
491
+ return Boolean(rosterChoiceForPick(pick, role, registry, { label: rosterJobLabel(role), now, anyEngine: jobTakesAnyEngine(key) }));
479
492
  }
480
493
 
481
494
  // The role a job name resolves as: a built-in job's own role, else the kind
@@ -491,7 +504,7 @@ function rosterJobRole(name, root = process.cwd(), options = {}) {
491
504
  const machine = options.machineRosterPicks || readMachineRoster(options);
492
505
  const picks = [session && session[key], project && project[key], machine && machine[key]].map(rosterPickObject).filter(Boolean);
493
506
  const registry = options.registry || readEngineRegistry(root, { persist: false });
494
- const live = picks.find((pick) => rosterPickLive(pick, options.now, registry));
507
+ const live = picks.find((pick) => rosterPickLive(pick, options.now, registry, key));
495
508
  if (live) return customPickRole(live);
496
509
  const kind = inferJobKind(name);
497
510
  if (kind) return ENGINE_JOBS[kind];
@@ -612,7 +625,7 @@ function routerSkipsRole(engine, role) {
612
625
  function machineRosterFile(options = {}) {
613
626
  return options.machineRosterFile
614
627
  || process.env.ATRIS_MACHINE_ROSTER_PATH
615
- || path.join(os.homedir(), '.atris', 'roster.json');
628
+ || path.join(stateHome(), '.atris', 'roster.json');
616
629
  }
617
630
 
618
631
  // The all-projects ROSTER.md. A scratch roster.json path (tests, sandboxes)
@@ -624,7 +637,7 @@ function machineRosterMarkdownFile(options = {}) {
624
637
  if (options.machineRosterFile || process.env.ATRIS_MACHINE_ROSTER_PATH) {
625
638
  return path.join(path.dirname(machineRosterFile(options)), 'ROSTER.md');
626
639
  }
627
- return path.join(os.homedir(), '.atris', 'ROSTER.md');
640
+ return path.join(stateHome(), '.atris', 'ROSTER.md');
628
641
  }
629
642
 
630
643
  function projectRosterFile(root = process.cwd()) {
@@ -902,7 +915,7 @@ function rosterPickFromValue(value, role, options = {}) {
902
915
  extras = [inlineMax[1], ...extras];
903
916
  }
904
917
  const primary = resolveRosterWords(main);
905
- if (!engineCanTake(primary.engine, role)) throw new Error(`${primary.engine} cannot do ${kind} work`);
918
+ if (!options.anyEngine && !engineCanTake(primary.engine, role)) throw new Error(`${primary.engine} cannot do ${kind} work`);
906
919
  let backup = null;
907
920
  let until = '';
908
921
  let untilYearless = '';
@@ -946,7 +959,7 @@ function rosterPickFromValue(value, role, options = {}) {
946
959
  throw new Error('a worker line takes no backup; put the backup on the next line');
947
960
  } else if (backupMatch && !backup) {
948
961
  backup = resolveRosterWords(backupMatch[1]);
949
- if (!engineCanTake(backup.engine, role)) throw new Error(`the backup ${backup.engine} cannot do ${kind} work`);
962
+ if (!options.anyEngine && !engineCanTake(backup.engine, role)) throw new Error(`the backup ${backup.engine} cannot do ${kind} work`);
950
963
  } else if (untilMatch && !until) {
951
964
  const words = untilMatch[1].trim();
952
965
  until = parseRosterUntilWords(words);
@@ -1112,7 +1125,7 @@ function parseRosterMarkdown(text, file, options = {}) {
1112
1125
  const workers = [];
1113
1126
  for (const entry of lines) {
1114
1127
  try {
1115
- const worker = rosterWorkerFromValue(entry.value, ENGINE_JOBS[kind], options);
1128
+ const worker = rosterWorkerFromValue(entry.value, ENGINE_JOBS[kind], { ...options, anyEngine: jobTakesAnyEngine(key) });
1116
1129
  const selfPrep = selfPrepWarning(worker, key, label);
1117
1130
  if (selfPrep) warn(entry, selfPrep);
1118
1131
  workers.push({ ...worker, line: entry.lineNumber, text: entry.raw });
@@ -1184,7 +1197,7 @@ function parseRosterMarkdown(text, file, options = {}) {
1184
1197
  continue;
1185
1198
  }
1186
1199
  try {
1187
- const pick = rosterPickFromValue(entry.value, ENGINE_JOBS[kind], options);
1200
+ const pick = rosterPickFromValue(entry.value, ENGINE_JOBS[kind], { ...options, anyEngine: jobTakesAnyEngine(key) });
1188
1201
  const selfPrep = selfPrepWarning(pick, key, label);
1189
1202
  if (selfPrep) warn(entry, selfPrep);
1190
1203
  picks[key] = { ...pick, ...(builtIn ? {} : { like: kind }), file: display, line: entry.lineNumber };
@@ -1264,7 +1277,7 @@ function sessionRosterDir(options = {}) {
1264
1277
  if (options.sessionRosterDir) return options.sessionRosterDir;
1265
1278
  if (process.env.ATRIS_ROSTER_SESSIONS_DIR) return process.env.ATRIS_ROSTER_SESSIONS_DIR;
1266
1279
  if (options.machineRosterFile || process.env.ATRIS_MACHINE_ROSTER_PATH) return path.join(path.dirname(machineRosterFile(options)), 'sessions');
1267
- return path.join(os.homedir(), '.atris', 'sessions');
1280
+ return path.join(stateHome(), '.atris', 'sessions');
1268
1281
  }
1269
1282
 
1270
1283
  // Like the all-projects roster: off in test runs unless a test points the
@@ -1531,13 +1544,14 @@ function assignedWorkerLines(key, engineName, options, context) {
1531
1544
  if (likeName && !likeKind) throw new Error(`unknown kind "${likeName}". use --like search, build, or review`);
1532
1545
  if (builtIn && likeKind && ENGINE_JOBS[likeKind] !== key) throw new Error(`${label} is a built-in job; --like is only for your own jobs`);
1533
1546
  const savedKind = builtIn ? '' : Object.keys(ENGINE_JOBS).find((kind) => ENGINE_JOBS[kind] === customPickRole(savedPick)) || '';
1534
- const kind = builtIn ? label : likeKind || inferJobKind(jobName) || savedKind;
1547
+ const kind = builtIn ? label : likeKind || inferJobKind(jobName) || savedKind || ANY_ENGINE_JOBS[key] || '';
1535
1548
  if (!kind) throw new Error(`say what kind of job "${label}" is: add --like search, --like build, or --like review`);
1536
1549
  const role = ENGINE_JOBS[kind];
1537
1550
  const engineId = rosterToolName(engineName);
1538
1551
  const engine = registry.engines.find((entry) => entry.id === engineId);
1539
1552
  if (!engine) throw new Error(`unknown engine "${engineName}"`);
1540
- if (!engineHasRole(engine, role)) throw new Error(`${engine.id} cannot do ${builtIn ? label : `${kind} work, so it cannot take ${label}`}`);
1553
+ const anyEngine = jobTakesAnyEngine(key);
1554
+ if (!anyEngine && !engineHasRole(engine, role)) throw new Error(`${engine.id} cannot do ${builtIn ? label : `${kind} work, so it cannot take ${label}`}`);
1541
1555
  // --backup takes engine words with an optional model and effort, the
1542
1556
  // same as a written line: --backup "claude opus 5.5".
1543
1557
  const backupName = String(options.backup || '').trim();
@@ -1552,7 +1566,7 @@ function assignedWorkerLines(key, engineName, options, context) {
1552
1566
  }
1553
1567
  const backup = backupRead ? registry.engines.find((entry) => entry.id === backupRead.engine) : null;
1554
1568
  if (backupName && !backup) throw new Error(`unknown backup engine "${backupName}"`);
1555
- if (backup && !engineHasRole(backup, role)) throw new Error(`${backup.id} cannot do ${builtIn ? label : `${kind} work, so it cannot back up ${label}`}`);
1569
+ if (backup && !anyEngine && !engineHasRole(backup, role)) throw new Error(`${backup.id} cannot do ${builtIn ? label : `${kind} work, so it cannot back up ${label}`}`);
1556
1570
  // No --days means the worker holds until someone changes the line.
1557
1571
  const days = options.days === undefined ? null : Number(options.days);
1558
1572
  if (days !== null && (!Number.isSafeInteger(days) || days < 1)) throw new Error('days must be a positive whole number');
@@ -1745,11 +1759,12 @@ const ROSTER_LAYERS = Object.freeze(['session', 'project', 'machine']);
1745
1759
 
1746
1760
  // A pick's workers, each with the engine it would run (model, effort, and
1747
1761
  // time cap pinned) or the reason it is skipped: bad line, expired, bad model,
1748
- // or not ready (down, or it cannot do this kind of work).
1749
- function rosterWorkerWalk(pick, role, registry, now) {
1762
+ // or not ready (down, or it cannot do this kind of work). anyEngine drops
1763
+ // the kind check for a job any engine may fill (see ANY_ENGINE_JOBS).
1764
+ function rosterWorkerWalk(pick, role, registry, now, { anyEngine = false } = {}) {
1750
1765
  const ready = registry.engines
1751
1766
  .filter((engine) => engineReadyAt(engine, now))
1752
- .filter((engine) => engineHasRole(engine, role));
1767
+ .filter((engine) => anyEngine || engineHasRole(engine, role));
1753
1768
  return pickWorkers(pick).map((worker) => {
1754
1769
  if (worker.error) return { worker, skip: 'bad line', engine: null, raw: null };
1755
1770
  if (rosterPickExpired(worker, now)) return { worker, skip: 'expired', engine: null, raw: null };
@@ -1784,10 +1799,10 @@ function rosterWorkerWalk(pick, role, registry, now) {
1784
1799
  // leads; null means the next pick in line decides. A custom pick only counts
1785
1800
  // when its kind matches the role being resolved. team is every worker that
1786
1801
  // can run, lead first.
1787
- function rosterChoiceForPick(pick, role, registry, { key, label, source, scopeText = '', now } = {}) {
1802
+ function rosterChoiceForPick(pick, role, registry, { key, label, source, scopeText = '', now, anyEngine } = {}) {
1788
1803
  if (!pick || !pick.engine) return null;
1789
1804
  if (key !== undefined && pickRoleForKey(key, pick) !== role) return null;
1790
- const walk = rosterWorkerWalk(pick, role, registry, now);
1805
+ const walk = rosterWorkerWalk(pick, role, registry, now, { anyEngine: anyEngine === undefined ? jobTakesAnyEngine(key) : anyEngine });
1791
1806
  const leadIndex = walk.findIndex((step) => !step.skip);
1792
1807
  if (leadIndex === -1) return null;
1793
1808
  const lead = walk[leadIndex];
@@ -1942,6 +1957,55 @@ function resolveJobTeam(job, root = process.cwd(), options = {}) {
1942
1957
  return { job: label, key, role, source: resolved.source, lead: resolved.engine || null, team, reason: resolved.reason };
1943
1958
  }
1944
1959
 
1960
+ // Who writes YouTube notes when no roster layer names a notes job: agy on
1961
+ // its plain flash model at low effort, then haiku. Low effort is what keeps a
1962
+ // notes run near 8 seconds of writer time. Each model is a name from that
1963
+ // engine's seed catalog above. Change the default here and only here.
1964
+ const NOTES_DEFAULT_WORKERS = Object.freeze([
1965
+ Object.freeze({ engine: 'agy', model: 'gemini-3.8-flash', effort: 'low' }),
1966
+ Object.freeze({ engine: 'haiku', model: 'haiku', effort: null }),
1967
+ ]);
1968
+
1969
+ // The notes job answered from saved state only: the roster layers in order
1970
+ // (this session, this project, all projects), each job's workers in order
1971
+ // with the roster's own skip rules (expired, cooling, not ready), else the
1972
+ // built-in default. installed comes from the registry's saved policy, so
1973
+ // this never probes the machine on a settled registry. A roster pick with
1974
+ // no usable worker falls through to the next layer, then to the default.
1975
+ function resolveNotesJob(root = process.cwd(), options = {}) {
1976
+ const key = 'notes';
1977
+ const registry = readEngineRegistry(root, { now: options.now });
1978
+ const state = options.rosterState || readRosterState(root, { now: options.now, registry });
1979
+ const installedOf = (id) => {
1980
+ const engine = registry.engines.find((entry) => entry.id === id);
1981
+ return Boolean(engine && engine.installed === true);
1982
+ };
1983
+ const named = [];
1984
+ for (const scope of ROSTER_LAYERS) {
1985
+ const pick = rosterPickObject(state[scope] && state[scope].picks && state[scope].picks[key]);
1986
+ if (!pick) continue;
1987
+ named.push(scope);
1988
+ const role = customPickRole(pick) || ENGINE_JOBS[ANY_ENGINE_JOBS[key]];
1989
+ const usable = rosterWorkerWalk(pick, role, registry, options.now, { anyEngine: true }).filter((step) => !step.skip);
1990
+ if (!usable.length) continue;
1991
+ const [lead, ...rest] = usable.map((step) => ({
1992
+ engine: step.engine.id,
1993
+ model: step.engine.roster_model || null,
1994
+ effort: step.engine.roster_effort || null,
1995
+ installed: step.raw.installed === true,
1996
+ }));
1997
+ return { job: key, ...lead, backup: rest, source: 'roster', from: ROSTER_SOURCES[scope] };
1998
+ }
1999
+ const [lead, ...rest] = NOTES_DEFAULT_WORKERS.map((worker) => ({ ...worker, installed: installedOf(worker.engine) }));
2000
+ return {
2001
+ job: key,
2002
+ ...lead,
2003
+ backup: rest,
2004
+ source: 'default',
2005
+ ...(named.length ? { skipped: `the notes roster pick (${named.map((scope) => ROSTER_SOURCES[scope]).join(', ')}) has no worker ready now` } : {}),
2006
+ };
2007
+ }
2008
+
1945
2009
  // True when a roster line (not the router) decided.
1946
2010
  function rosterDecided(source) {
1947
2011
  return ROSTER_LAYERS.includes(source);
@@ -2136,6 +2200,9 @@ module.exports = {
2136
2200
  rosterToolLabel,
2137
2201
  rosterWorkerWalk,
2138
2202
  resolveJobTeam,
2203
+ resolveNotesJob,
2204
+ NOTES_DEFAULT_WORKERS,
2205
+ jobTakesAnyEngine,
2139
2206
  rosterSessionKey,
2140
2207
  clearSessionRoster,
2141
2208
  NO_SESSION_MESSAGE,
@@ -3,6 +3,7 @@
3
3
  const fs = require('fs');
4
4
  const os = require('os');
5
5
  const path = require('path');
6
+ const { stateHome } = require('./state-home');
6
7
  const { spawnSync } = require('child_process');
7
8
  const { isGenericScratchRoot } = require('./scratch-root');
8
9
 
@@ -57,8 +58,7 @@ function unixUser(env = process.env) {
57
58
 
58
59
  function loadSavedAccount() {
59
60
  try {
60
- const home = process.env.HOME || os.homedir();
61
- const file = path.join(home, '.atris', 'credentials.json');
61
+ const file = path.join(stateHome(), '.atris', 'credentials.json');
62
62
  if (!fs.existsSync(file)) return null;
63
63
  const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
64
64
  return parsed && typeof parsed === 'object' ? parsed : null;
package/lib/fleet.js CHANGED
@@ -36,11 +36,13 @@ const { listWorktrees, defaultMainlineBase } = require('../commands/worktree');
36
36
  const { isConductorStatusLine } = require('./conductor-artifacts');
37
37
  const { matchLessons } = require('./lesson-preflight');
38
38
  const { sleepSync } = require('./sleep-sync');
39
+ const { trackedSandboxPidsSync } = require('./sandbox-pids');
39
40
  const { ownerIdentity } = require('../utils/owner-identity');
40
41
  const { matchTaste } = require('./taste-lessons');
41
42
  const { buildVerifiedScoutPack, appendVerifiedScoutPack } = require('./dispatch-scout');
42
43
  const { runPrepPass, withPrepBrief, prepRecordFields } = require('./roster-prep');
43
44
  const { mapNotesPath } = require('./map-refs');
45
+ const { SHARED_BRIEF_RULES } = require('./brief-rules');
44
46
  const {
45
47
  applySecretGrantEnvironment,
46
48
  buildGatewaySupervisorScript,
@@ -117,7 +119,7 @@ function buildFleetPrompt(task, { worktreePath, yolo = false } = {}) {
117
119
  mapNotesPath(worktreePath || process.cwd())
118
120
  ? '- Read atris/MAP.md first to locate the code, then atris/refs/MAP-NOTES.md for exact file:line refs; never guess file locations.'
119
121
  : '- Read atris/MAP.md first to locate the code; never guess file locations.',
120
- '- Keep one concern per PR; split anything larger into separate PRs because git history guides future agents and small PRs are cheap to revert and bisect.',
122
+ ...SHARED_BRIEF_RULES,
121
123
  '- Run git status first. Stage ONLY files you changed. Never revert or touch files another agent modified.',
122
124
  '- Include or update a focused regression test; run it with npm run test:fast && node --test <focused files> before committing.',
123
125
  `- ${commitRule}`,
@@ -284,31 +286,11 @@ function buildEngineCommand(engineName, promptFile, { yolo = false, sealed = fal
284
286
  }
285
287
 
286
288
  function trackedSandboxPids(stateFile, leaseFile) {
287
- const pids = new Set();
288
289
  let state = {};
289
290
  try { state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); } catch {}
290
291
  const pgid = Number(state.pgid);
291
- if (Number.isInteger(pgid) && pgid > 0) {
292
- const grouped = spawnSync('/usr/bin/pgrep', ['-g', String(pgid)], { encoding: 'utf8' });
293
- for (const value of String(grouped.stdout || '').trim().split(/\s+/)) {
294
- const pid = Number(value);
295
- if (Number.isInteger(pid) && pid > 0) pids.add(pid);
296
- }
297
- }
298
- const leased = spawnSync('/usr/sbin/lsof', ['-t', leaseFile], { encoding: 'utf8' });
299
- for (const value of String(leased.stdout || '').trim().split(/\s+/)) {
300
- const pid = Number(value);
301
- if (Number.isInteger(pid) && pid > 0) pids.add(pid);
302
- }
303
- if (state.cwd) {
304
- const rooted = spawnSync('/usr/sbin/lsof', ['-a', '-d', 'cwd', '+D', String(state.cwd), '-t'], { encoding: 'utf8' });
305
- for (const value of String(rooted.stdout || '').trim().split(/\s+/)) {
306
- const pid = Number(value);
307
- if (Number.isInteger(pid) && pid > 0) pids.add(pid);
308
- }
309
- }
310
- pids.delete(process.pid);
311
- return { pgid, pids: [...pids] };
292
+ const pids = trackedSandboxPidsSync({ pgid, leaseFile, cwd: state.cwd ? String(state.cwd) : '' });
293
+ return { pgid, pids };
312
294
  }
313
295
 
314
296
  function terminateTrackedSandbox(stateFile, leaseFile) {
@@ -480,6 +462,7 @@ function runInReapedProcessGroup(executable, args, options, controlDir, statusFi
480
462
  "'use strict';",
481
463
  "const fs = require('node:fs');",
482
464
  "const { spawn, spawnSync } = require('node:child_process');",
465
+ `const sandboxPids = require(${JSON.stringify(require.resolve('./sandbox-pids'))});`,
483
466
  "const [stateFile, leaseFile, statusFile, executable, ...args] = process.argv.slice(2);",
484
467
  "const leaseFd = fs.openSync(leaseFile, 'w', 0o600);",
485
468
  "const statusFd = fs.openSync(statusFile, 'w', 0o600);",
@@ -489,18 +472,7 @@ function runInReapedProcessGroup(executable, args, options, controlDir, statusFi
489
472
  "fs.writeFileSync(stateFile, JSON.stringify({ pgid: child.pid, cwd: process.cwd() }) + '\\n', { mode: 0o600 });",
490
473
  "let stopping = false;",
491
474
  "const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));",
492
- "function trackedPids() {",
493
- " const pids = new Set();",
494
- " 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']]]) {",
495
- " const found = spawnSync(bin, argv, { encoding: 'utf8' });",
496
- " for (const value of String(found.stdout || '').trim().split(/\\s+/)) {",
497
- " const pid = Number(value);",
498
- " if (Number.isInteger(pid) && pid > 0) pids.add(pid);",
499
- " }",
500
- " }",
501
- " pids.delete(process.pid);",
502
- " return [...pids];",
503
- "}",
475
+ "const trackedPids = () => sandboxPids.trackedSandboxPids({ pgid: child.pid, leaseFile, cwd: process.cwd() });",
504
476
  "async function stop(code) {",
505
477
  " if (stopping) return;",
506
478
  " stopping = true;",
@@ -509,7 +481,7 @@ function runInReapedProcessGroup(executable, args, options, controlDir, statusFi
509
481
  " }",
510
482
  " for (const [signal, delay] of [['SIGTERM', 100], ['SIGKILL', 100], ['SIGKILL', 100]]) {",
511
483
  " try { process.kill(-child.pid, signal); } catch {}",
512
- " for (const pid of trackedPids()) { try { process.kill(pid, signal); } catch {} }",
484
+ " for (const pid of await trackedPids()) { try { process.kill(pid, signal); } catch {} }",
513
485
  " await wait(delay);",
514
486
  " }",
515
487
  " let exitCode = Number.isInteger(code) ? code : 128;",
package/lib/git-spawn.js CHANGED
@@ -4,8 +4,11 @@ const { spawnSync } = require('child_process');
4
4
 
5
5
  // One synchronous git runner for commands that shell out to git.
6
6
  // check: true throws on a non-zero exit; check: false hands back the raw result.
7
- function runGit(args, { cwd = process.cwd(), check = true, maxBuffer } = {}) {
8
- const result = spawnSync('git', args, { cwd, encoding: 'utf8', ...(maxBuffer ? { maxBuffer } : {}) });
7
+ // input, when given, is written to git's stdin (for --stdin batch reads).
8
+ function runGit(args, { cwd = process.cwd(), check = true, maxBuffer, input } = {}) {
9
+ const result = spawnSync('git', args, {
10
+ cwd, encoding: 'utf8', ...(maxBuffer ? { maxBuffer } : {}), ...(input !== undefined ? { input } : {}),
11
+ });
9
12
  if (check && result.status !== 0) {
10
13
  throw new Error(`git ${args.join(' ')} failed: ${(result.stderr || result.stdout || '').trim()}`);
11
14
  }
@@ -0,0 +1,51 @@
1
+ 'use strict';
2
+
3
+ // Speed caches that must never be committed. They live in .atris/cache, a
4
+ // folder that carries its own .gitignore of `*`, so a project with no ignore
5
+ // rule for .atris still never stages them with `git add -A`. Caches are only
6
+ // kept in folders that already have .atris; elsewhere nothing is written.
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+
11
+ const CACHE_DIR = path.join('.atris', 'cache');
12
+
13
+ function cacheEnabled(root) {
14
+ try {
15
+ return fs.statSync(path.join(root, '.atris')).isDirectory();
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
20
+
21
+ function cachePath(root, name) {
22
+ return path.join(root, CACHE_DIR, name);
23
+ }
24
+
25
+ // The parsed cache file, or null when it is missing or unreadable.
26
+ function readCacheJson(root, name) {
27
+ try {
28
+ return JSON.parse(fs.readFileSync(cachePath(root, name), 'utf8'));
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ // Returns false when the write failed; a cache is only a speedup.
35
+ function writeCacheJson(root, name, value) {
36
+ const dir = path.join(root, CACHE_DIR);
37
+ try {
38
+ fs.mkdirSync(dir, { recursive: true });
39
+ const ignore = path.join(dir, '.gitignore');
40
+ if (!fs.existsSync(ignore)) fs.writeFileSync(ignore, '*\n');
41
+ const file = cachePath(root, name);
42
+ const tmp = `${file}.${process.pid}.tmp`;
43
+ fs.writeFileSync(tmp, `${JSON.stringify(value)}\n`);
44
+ fs.renameSync(tmp, file);
45
+ return true;
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ module.exports = { CACHE_DIR, cacheEnabled, cachePath, readCacheJson, writeCacheJson };
package/lib/manifest.js CHANGED
@@ -1,7 +1,7 @@
1
1
  const crypto = require('crypto');
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
- const os = require('os');
4
+ const { stateHome } = require('./state-home');
5
5
  const { computeContentHash } = require('./journal');
6
6
 
7
7
  /**
@@ -9,7 +9,7 @@ const { computeContentHash } = require('./journal');
9
9
  * Stored at ~/.atris/businesses/{slug}/manifest.json
10
10
  */
11
11
  function getManifestPath(slug) {
12
- return path.join(os.homedir(), '.atris', 'businesses', slug, 'manifest.json');
12
+ return path.join(stateHome(), '.atris', 'businesses', slug, 'manifest.json');
13
13
  }
14
14
 
15
15
  /**