atris 3.63.0 → 3.65.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 (81) hide show
  1. package/README.md +4 -0
  2. package/atris/skills/commerce/SKILL.md +24 -0
  3. package/atris/skills/x-search/SKILL.md +3 -1
  4. package/ax +4 -3
  5. package/bin/atris.js +28 -2
  6. package/cli/atris_code.py +25 -1
  7. package/commands/agent-spawn.js +1 -0
  8. package/commands/auth.js +79 -10
  9. package/commands/autoland.js +11 -1
  10. package/commands/browse.js +2 -2
  11. package/commands/business.js +3 -3
  12. package/commands/buy.js +3 -3
  13. package/commands/deck.js +2 -2
  14. package/commands/engine.js +292 -65
  15. package/commands/feedback.js +2 -2
  16. package/commands/fork.js +2 -2
  17. package/commands/gm.js +2 -1
  18. package/commands/hire.js +189 -0
  19. package/commands/improve.js +142 -8
  20. package/commands/integrations.js +4 -3
  21. package/commands/loops.js +2 -1
  22. package/commands/mail.js +163 -0
  23. package/commands/member.js +47 -9
  24. package/commands/mission.js +4 -3
  25. package/commands/pack-craft.js +2 -2
  26. package/commands/publish.js +2 -2
  27. package/commands/radar.js +3 -2
  28. package/commands/router.js +2 -2
  29. package/commands/stream.js +22 -1
  30. package/commands/task.js +84 -2
  31. package/commands/team.js +355 -67
  32. package/commands/transactions.js +3 -3
  33. package/commands/truth.js +2 -2
  34. package/commands/x-search.js +81 -10
  35. package/commands/youtube.js +2 -1
  36. package/lib/auto-accept-certified.js +83 -9
  37. package/lib/autoland.js +3 -2
  38. package/lib/ax-auto-lane.js +2 -2
  39. package/lib/brief-rules.js +11 -0
  40. package/lib/business-target.js +131 -0
  41. package/lib/chat-log-scan.js +2 -1
  42. package/lib/ci-runner.js +3 -2
  43. package/lib/codex-flight.js +2 -1
  44. package/lib/commerce.js +17 -2
  45. package/lib/deck-history.js +2 -2
  46. package/lib/deck-review.js +3 -3
  47. package/lib/design-api.js +2 -2
  48. package/lib/engine-processes.js +430 -0
  49. package/lib/engine-registry.js +101 -21
  50. package/lib/first-minute.js +2 -2
  51. package/lib/fleet.js +8 -36
  52. package/lib/git-spawn.js +5 -2
  53. package/lib/ignored-cache.js +51 -0
  54. package/lib/known-commands.js +1 -1
  55. package/lib/manifest.js +2 -2
  56. package/lib/map-refs.js +70 -4
  57. package/lib/member-identity.js +56 -0
  58. package/lib/pack-capabilities.js +2 -1
  59. package/lib/permission-grants.js +2 -2
  60. package/lib/pulse.js +5 -5
  61. package/lib/roster-models.js +11 -0
  62. package/lib/roster-runs.js +13 -2
  63. package/lib/roster-stale.js +241 -0
  64. package/lib/runner-command.js +22 -6
  65. package/lib/sandbox-pids.js +113 -0
  66. package/lib/secret-gateway.js +3 -13
  67. package/lib/state-home.js +106 -0
  68. package/lib/task-db.js +4 -3
  69. package/lib/task-prune.js +462 -0
  70. package/lib/team-lineup.js +10 -71
  71. package/lib/team-presence.js +224 -0
  72. package/lib/text-table.js +81 -0
  73. package/lib/x-post-check.js +497 -0
  74. package/package.json +1 -1
  75. package/scripts/det/data/xsearch-answer.json +24 -0
  76. package/scripts/det/data/xsearch-live-2026-09-29.json +93 -0
  77. package/scripts/det/xsearch-bench.js +307 -0
  78. package/scripts/det/ytnotes +61 -15
  79. package/scripts/det/ytrail-bench.js +2 -1
  80. package/utils/auth.js +3 -3
  81. package/utils/update-check.js +2 -2
@@ -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 = '';
@@ -910,16 +923,25 @@ function rosterPickFromValue(value, role, options = {}) {
910
923
  let namedModel = false;
911
924
  let namedEffort = false;
912
925
  let prep = '';
926
+ let verified = '';
913
927
  for (const raw of extras) {
914
- // "max: 20 min" and "until: 2026-10-24" read like "max 20 min".
915
- const part = raw.replace(/^(max|until)\s*:\s*/i, '$1 ');
928
+ // "max: 20 min", "until: 2026-10-24", and "verified: 2026-09-30" read
929
+ // like "max 20 min".
930
+ const part = raw.replace(/^(max|until|verified)\s*:\s*/i, '$1 ');
916
931
  const backupMatch = /^backup\s+(.+)$/i.exec(part);
917
932
  const untilMatch = /^until\s+(.+)$/i.exec(part);
918
933
  const maxMatch = /^max\b/i.test(part) && !/^max$/i.test(part);
919
934
  const modelMatch = /^model\s*(?::\s*|\s+)(.+)$/i.exec(part);
920
935
  const effortMatch = /^effort\s*(?::\s*|\s+)(.+)$/i.exec(part);
921
936
  const prepMatch = /^prep\s*(?::\s*|\s+)(.+)$/i.exec(part);
922
- if (prepMatch && !prep) {
937
+ const verifiedMatch = /^verified\s+(.+)$/i.exec(part);
938
+ if (verifiedMatch && !verified) {
939
+ // "verified 2026-09-30": the day a person last saw this model answer.
940
+ // The roster flags a pin whose newest proof is over 30 days old.
941
+ const words = verifiedMatch[1].trim();
942
+ verified = parseRosterUntilWords(words);
943
+ if (!verified) throw new Error(`"verified ${words}" is not a date; use verified 2026-09-30`);
944
+ } else if (prepMatch && !prep) {
923
945
  // "prep: search": that job's lead reads the task first and hands this
924
946
  // worker a trimmed brief. Whether it names this same job is checked
925
947
  // where the job is known.
@@ -946,7 +968,7 @@ function rosterPickFromValue(value, role, options = {}) {
946
968
  throw new Error('a worker line takes no backup; put the backup on the next line');
947
969
  } else if (backupMatch && !backup) {
948
970
  backup = resolveRosterWords(backupMatch[1]);
949
- if (!engineCanTake(backup.engine, role)) throw new Error(`the backup ${backup.engine} cannot do ${kind} work`);
971
+ if (!options.anyEngine && !engineCanTake(backup.engine, role)) throw new Error(`the backup ${backup.engine} cannot do ${kind} work`);
950
972
  } else if (untilMatch && !until) {
951
973
  const words = untilMatch[1].trim();
952
974
  until = parseRosterUntilWords(words);
@@ -975,6 +997,7 @@ function rosterPickFromValue(value, role, options = {}) {
975
997
  until,
976
998
  ...(until ? {} : { never_expires: true }),
977
999
  ...(untilYearless ? { until_needs_year: untilYearless } : {}),
1000
+ ...(verified ? { verified } : {}),
978
1001
  };
979
1002
  }
980
1003
 
@@ -1017,6 +1040,7 @@ function rosterWorkerFromValue(value, role, options = {}) {
1017
1040
  until: pick.until,
1018
1041
  ...(pick.never_expires ? { never_expires: true } : {}),
1019
1042
  ...(pick.until_needs_year ? { until_needs_year: pick.until_needs_year } : {}),
1043
+ ...(pick.verified ? { verified: pick.verified } : {}),
1020
1044
  };
1021
1045
  }
1022
1046
 
@@ -1065,6 +1089,7 @@ function pickWorkers(pick) {
1065
1089
  ...(pick.prep ? { prep: pick.prep } : {}),
1066
1090
  until: pick.until,
1067
1091
  ...(pick.never_expires ? { never_expires: true } : {}),
1092
+ ...(pick.verified ? { verified: pick.verified } : {}),
1068
1093
  ...(pick.line ? { line: pick.line } : {}),
1069
1094
  }];
1070
1095
  if (pick.backup) {
@@ -1112,7 +1137,7 @@ function parseRosterMarkdown(text, file, options = {}) {
1112
1137
  const workers = [];
1113
1138
  for (const entry of lines) {
1114
1139
  try {
1115
- const worker = rosterWorkerFromValue(entry.value, ENGINE_JOBS[kind], options);
1140
+ const worker = rosterWorkerFromValue(entry.value, ENGINE_JOBS[kind], { ...options, anyEngine: jobTakesAnyEngine(key) });
1116
1141
  const selfPrep = selfPrepWarning(worker, key, label);
1117
1142
  if (selfPrep) warn(entry, selfPrep);
1118
1143
  workers.push({ ...worker, line: entry.lineNumber, text: entry.raw });
@@ -1184,7 +1209,7 @@ function parseRosterMarkdown(text, file, options = {}) {
1184
1209
  continue;
1185
1210
  }
1186
1211
  try {
1187
- const pick = rosterPickFromValue(entry.value, ENGINE_JOBS[kind], options);
1212
+ const pick = rosterPickFromValue(entry.value, ENGINE_JOBS[kind], { ...options, anyEngine: jobTakesAnyEngine(key) });
1188
1213
  const selfPrep = selfPrepWarning(pick, key, label);
1189
1214
  if (selfPrep) warn(entry, selfPrep);
1190
1215
  picks[key] = { ...pick, ...(builtIn ? {} : { like: kind }), file: display, line: entry.lineNumber };
@@ -1264,7 +1289,7 @@ function sessionRosterDir(options = {}) {
1264
1289
  if (options.sessionRosterDir) return options.sessionRosterDir;
1265
1290
  if (process.env.ATRIS_ROSTER_SESSIONS_DIR) return process.env.ATRIS_ROSTER_SESSIONS_DIR;
1266
1291
  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');
1292
+ return path.join(stateHome(), '.atris', 'sessions');
1268
1293
  }
1269
1294
 
1270
1295
  // Like the all-projects roster: off in test runs unless a test points the
@@ -1370,6 +1395,7 @@ function workerLineText(worker) {
1370
1395
  if (Number(worker.max_seconds) > 0) parts.push(`max: ${rosterMaxText(worker.max_seconds).replace(/^max /, '')}`);
1371
1396
  if (worker.prep) parts.push(`prep: ${rosterJobLabel(worker.prep)}`);
1372
1397
  if (worker.until) parts.push(`until ${worker.until}`);
1398
+ if (worker.verified) parts.push(`verified ${worker.verified}`);
1373
1399
  return `- ${parts.join(', ')}`;
1374
1400
  }
1375
1401
 
@@ -1531,13 +1557,14 @@ function assignedWorkerLines(key, engineName, options, context) {
1531
1557
  if (likeName && !likeKind) throw new Error(`unknown kind "${likeName}". use --like search, build, or review`);
1532
1558
  if (builtIn && likeKind && ENGINE_JOBS[likeKind] !== key) throw new Error(`${label} is a built-in job; --like is only for your own jobs`);
1533
1559
  const savedKind = builtIn ? '' : Object.keys(ENGINE_JOBS).find((kind) => ENGINE_JOBS[kind] === customPickRole(savedPick)) || '';
1534
- const kind = builtIn ? label : likeKind || inferJobKind(jobName) || savedKind;
1560
+ const kind = builtIn ? label : likeKind || inferJobKind(jobName) || savedKind || ANY_ENGINE_JOBS[key] || '';
1535
1561
  if (!kind) throw new Error(`say what kind of job "${label}" is: add --like search, --like build, or --like review`);
1536
1562
  const role = ENGINE_JOBS[kind];
1537
1563
  const engineId = rosterToolName(engineName);
1538
1564
  const engine = registry.engines.find((entry) => entry.id === engineId);
1539
1565
  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}`}`);
1566
+ const anyEngine = jobTakesAnyEngine(key);
1567
+ if (!anyEngine && !engineHasRole(engine, role)) throw new Error(`${engine.id} cannot do ${builtIn ? label : `${kind} work, so it cannot take ${label}`}`);
1541
1568
  // --backup takes engine words with an optional model and effort, the
1542
1569
  // same as a written line: --backup "claude opus 5.5".
1543
1570
  const backupName = String(options.backup || '').trim();
@@ -1552,7 +1579,7 @@ function assignedWorkerLines(key, engineName, options, context) {
1552
1579
  }
1553
1580
  const backup = backupRead ? registry.engines.find((entry) => entry.id === backupRead.engine) : null;
1554
1581
  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}`}`);
1582
+ if (backup && !anyEngine && !engineHasRole(backup, role)) throw new Error(`${backup.id} cannot do ${builtIn ? label : `${kind} work, so it cannot back up ${label}`}`);
1556
1583
  // No --days means the worker holds until someone changes the line.
1557
1584
  const days = options.days === undefined ? null : Number(options.days);
1558
1585
  if (days !== null && (!Number.isSafeInteger(days) || days < 1)) throw new Error('days must be a positive whole number');
@@ -1745,11 +1772,12 @@ const ROSTER_LAYERS = Object.freeze(['session', 'project', 'machine']);
1745
1772
 
1746
1773
  // A pick's workers, each with the engine it would run (model, effort, and
1747
1774
  // 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) {
1775
+ // or not ready (down, or it cannot do this kind of work). anyEngine drops
1776
+ // the kind check for a job any engine may fill (see ANY_ENGINE_JOBS).
1777
+ function rosterWorkerWalk(pick, role, registry, now, { anyEngine = false } = {}) {
1750
1778
  const ready = registry.engines
1751
1779
  .filter((engine) => engineReadyAt(engine, now))
1752
- .filter((engine) => engineHasRole(engine, role));
1780
+ .filter((engine) => anyEngine || engineHasRole(engine, role));
1753
1781
  return pickWorkers(pick).map((worker) => {
1754
1782
  if (worker.error) return { worker, skip: 'bad line', engine: null, raw: null };
1755
1783
  if (rosterPickExpired(worker, now)) return { worker, skip: 'expired', engine: null, raw: null };
@@ -1784,10 +1812,10 @@ function rosterWorkerWalk(pick, role, registry, now) {
1784
1812
  // leads; null means the next pick in line decides. A custom pick only counts
1785
1813
  // when its kind matches the role being resolved. team is every worker that
1786
1814
  // can run, lead first.
1787
- function rosterChoiceForPick(pick, role, registry, { key, label, source, scopeText = '', now } = {}) {
1815
+ function rosterChoiceForPick(pick, role, registry, { key, label, source, scopeText = '', now, anyEngine } = {}) {
1788
1816
  if (!pick || !pick.engine) return null;
1789
1817
  if (key !== undefined && pickRoleForKey(key, pick) !== role) return null;
1790
- const walk = rosterWorkerWalk(pick, role, registry, now);
1818
+ const walk = rosterWorkerWalk(pick, role, registry, now, { anyEngine: anyEngine === undefined ? jobTakesAnyEngine(key) : anyEngine });
1791
1819
  const leadIndex = walk.findIndex((step) => !step.skip);
1792
1820
  if (leadIndex === -1) return null;
1793
1821
  const lead = walk[leadIndex];
@@ -1942,6 +1970,55 @@ function resolveJobTeam(job, root = process.cwd(), options = {}) {
1942
1970
  return { job: label, key, role, source: resolved.source, lead: resolved.engine || null, team, reason: resolved.reason };
1943
1971
  }
1944
1972
 
1973
+ // Who writes YouTube notes when no roster layer names a notes job: agy on
1974
+ // its plain flash model at low effort, then haiku. Low effort is what keeps a
1975
+ // notes run near 8 seconds of writer time. Each model is a name from that
1976
+ // engine's seed catalog above. Change the default here and only here.
1977
+ const NOTES_DEFAULT_WORKERS = Object.freeze([
1978
+ Object.freeze({ engine: 'agy', model: 'gemini-3.8-flash', effort: 'low' }),
1979
+ Object.freeze({ engine: 'haiku', model: 'haiku', effort: null }),
1980
+ ]);
1981
+
1982
+ // The notes job answered from saved state only: the roster layers in order
1983
+ // (this session, this project, all projects), each job's workers in order
1984
+ // with the roster's own skip rules (expired, cooling, not ready), else the
1985
+ // built-in default. installed comes from the registry's saved policy, so
1986
+ // this never probes the machine on a settled registry. A roster pick with
1987
+ // no usable worker falls through to the next layer, then to the default.
1988
+ function resolveNotesJob(root = process.cwd(), options = {}) {
1989
+ const key = 'notes';
1990
+ const registry = readEngineRegistry(root, { now: options.now });
1991
+ const state = options.rosterState || readRosterState(root, { now: options.now, registry });
1992
+ const installedOf = (id) => {
1993
+ const engine = registry.engines.find((entry) => entry.id === id);
1994
+ return Boolean(engine && engine.installed === true);
1995
+ };
1996
+ const named = [];
1997
+ for (const scope of ROSTER_LAYERS) {
1998
+ const pick = rosterPickObject(state[scope] && state[scope].picks && state[scope].picks[key]);
1999
+ if (!pick) continue;
2000
+ named.push(scope);
2001
+ const role = customPickRole(pick) || ENGINE_JOBS[ANY_ENGINE_JOBS[key]];
2002
+ const usable = rosterWorkerWalk(pick, role, registry, options.now, { anyEngine: true }).filter((step) => !step.skip);
2003
+ if (!usable.length) continue;
2004
+ const [lead, ...rest] = usable.map((step) => ({
2005
+ engine: step.engine.id,
2006
+ model: step.engine.roster_model || null,
2007
+ effort: step.engine.roster_effort || null,
2008
+ installed: step.raw.installed === true,
2009
+ }));
2010
+ return { job: key, ...lead, backup: rest, source: 'roster', from: ROSTER_SOURCES[scope] };
2011
+ }
2012
+ const [lead, ...rest] = NOTES_DEFAULT_WORKERS.map((worker) => ({ ...worker, installed: installedOf(worker.engine) }));
2013
+ return {
2014
+ job: key,
2015
+ ...lead,
2016
+ backup: rest,
2017
+ source: 'default',
2018
+ ...(named.length ? { skipped: `the notes roster pick (${named.map((scope) => ROSTER_SOURCES[scope]).join(', ')}) has no worker ready now` } : {}),
2019
+ };
2020
+ }
2021
+
1945
2022
  // True when a roster line (not the router) decided.
1946
2023
  function rosterDecided(source) {
1947
2024
  return ROSTER_LAYERS.includes(source);
@@ -2136,6 +2213,9 @@ module.exports = {
2136
2213
  rosterToolLabel,
2137
2214
  rosterWorkerWalk,
2138
2215
  resolveJobTeam,
2216
+ resolveNotesJob,
2217
+ NOTES_DEFAULT_WORKERS,
2218
+ jobTakesAnyEngine,
2139
2219
  rosterSessionKey,
2140
2220
  clearSessionRoster,
2141
2221
  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 };
@@ -4,7 +4,7 @@ const knownCommands = ['init', 'guide', 'doctor', 'doc-health', 'install', 'work
4
4
  'activate', '_activate', 'agent', 'team', 'chat', 'fast', 'ax', 'console', 'serve', 'login', 'logout', 'whoami', 'switch', 'use', 'accounts', '_resolve', '_profile-email', '_switch-session', 'shell-init', 'update', 'upgrade', 'version', 'help', 'next', 'atris',
5
5
  'clean', 'close', 'harvest', 'verify', 'recover', 'search', 'scout', 'skill', 'member', 'codex-goal', 'app', 'apps', 'learn', 'lesson', 'taste', 'teach', 'plugin', 'experiments', 'bench', 'router', 'tree', 'receipt', 'proof', 'openclaw', 'pull', 'push', 'watch', 'cloud', 'live', 'align', 'terminal', 'computer', 'diff', 'business', 'sync', 'youtube', 'x-search',
6
6
  'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'decide', 'agents', 'probe', 'worktree', 'land', 'caretaker', 'autoland', 'drive', 'aeo', 'slop', 'voice', 'strings', 'write', 'security-review', 'secure', 'deck', 'site', 'theme', 'card', 'reel', 'improve', 'study', 'rainmaker', 'xp', 'play', 'gm', 'game', 'x', 'recap', 'report', 'signup', 'clarity', 'interview', 'meet', 'moves', 'unknowns', 'avail', 'sync-checkout',
7
- 'ci', 'github', 'vercel', 'supabase', 'linear', 'stripe', 'balance', 'usage', 'api-key', 'topup', 'buy', 'transactions', 'design', 'mcp', 'gmail', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
7
+ 'ci', 'github', 'vercel', 'supabase', 'linear', 'stripe', 'balance', 'usage', 'api-key', 'topup', 'buy', 'transactions', 'hire', 'hires', 'mail', 'design', 'mcp', 'gmail', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
8
8
  'fork', 'browse', 'publish', 'pack', 'sleep', 'wake', 'feedback', 'errors', 'wiki', 'code-review', 'cr', 'soul', 'fleet', 'fleet-report', 'loops', 'self-improve', 'compile', 'spaceship', 'truth', 'sign', 'engine', 'engines', 'playbook', 'feed', 'brief', 'social', 'host', 'people', 'follow', 'unfollow', 'friends', 'msg', 'inbox', 'invite', 'join', 'rsi'];
9
9
 
10
10
  // Damerau-Levenshtein edit distance between two short strings. Counts an
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
  /**
package/lib/map-refs.js CHANGED
@@ -6,8 +6,10 @@
6
6
  // A name elsewhere in the sentence never binds: map sentences often name a helper
7
7
  // the ref does not point at, and binding it would move the ref to the wrong place.
8
8
 
9
+ const crypto = require('crypto');
9
10
  const fs = require('fs');
10
11
  const path = require('path');
12
+ const ignoredCache = require('./ignored-cache');
11
13
 
12
14
  const WINDOW = 3;
13
15
  const IDENT = /^[A-Za-z_$][\w$]*$/;
@@ -123,6 +125,14 @@ function classify(ref, file) {
123
125
  }
124
126
 
125
127
  function checkMapRefs(root, mapText) {
128
+ const parsed = parseRefs(mapText);
129
+ const results = classifyRefs(root, parsed);
130
+ return summarize(parsed.map((ref, i) => ({ ...ref, ...results[i] })));
131
+ }
132
+
133
+ // What the code says about each parsed ref: its status, plus the line it moved
134
+ // to or why it is missing. Each file a ref points at is read once.
135
+ function classifyRefs(root, parsed) {
126
136
  const cache = new Map();
127
137
  const linesOf = file => {
128
138
  if (!cache.has(file)) {
@@ -144,11 +154,10 @@ function checkMapRefs(root, mapText) {
144
154
  }
145
155
  return cache.get(file);
146
156
  };
147
- const refs = parseRefs(mapText).map(ref => {
157
+ return parsed.map(ref => {
148
158
  const file = linesOf(ref.path);
149
- return { ...ref, ...(file ? classify(ref, file) : { status: 'missing-file' }) };
159
+ return file ? classify(ref, file) : { status: 'missing-file' };
150
160
  });
151
- return summarize(refs);
152
161
  }
153
162
 
154
163
  function summarize(refs) {
@@ -197,13 +206,70 @@ function existingMapDocs(root) {
197
206
  });
198
207
  }
199
208
 
209
+ // Boot scores the docs every session, and checking every ref means reading
210
+ // every file the map points at (megabytes on this repo). The result for each
211
+ // map file is saved in the ignored .atris/cache and reused only while the map
212
+ // text and the stat stamp of every file it points at are unchanged.
213
+ const REFS_CACHE_NAME = 'map-refs.json';
214
+
215
+ // Size, modified time, change time, and inode of a file a ref points at; null
216
+ // when it is not there. Change time moves on every write and cannot be set
217
+ // back by hand, so an edit that keeps size and modified time still misses.
218
+ function fileStamp(full) {
219
+ try {
220
+ const stat = fs.statSync(full);
221
+ return `${stat.isFile() ? 'f' : 'o'}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}:${stat.ino}`;
222
+ } catch (error) {
223
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null;
224
+ throw error;
225
+ }
226
+ }
227
+
228
+ function refsCacheKey(root, text, parsed) {
229
+ const files = [...new Set(parsed.map(ref => ref.path))];
230
+ const stamps = files.map(file => [file, fileStamp(path.resolve(root, file))]);
231
+ return crypto.createHash('sha1').update(JSON.stringify([text, stamps])).digest('hex');
232
+ }
233
+
234
+ function loadRefsCache(root) {
235
+ const cache = { root, docs: {}, dirty: false, writable: ignoredCache.cacheEnabled(root) };
236
+ if (!cache.writable) return cache;
237
+ const parsed = ignoredCache.readCacheJson(root, REFS_CACHE_NAME);
238
+ if (parsed && parsed.version === 1 && parsed.docs && typeof parsed.docs === 'object') cache.docs = parsed.docs;
239
+ return cache;
240
+ }
241
+
242
+ function saveRefsCache(cache) {
243
+ if (!cache.writable || !cache.dirty) return;
244
+ ignoredCache.writeCacheJson(cache.root, REFS_CACHE_NAME, { version: 1, docs: cache.docs });
245
+ }
246
+
247
+ function cachedMapRefs(root, doc, text, cache) {
248
+ const parsed = parseRefs(text);
249
+ const key = cache.writable ? refsCacheKey(root, text, parsed) : '';
250
+ const hit = key && cache.docs[doc];
251
+ let results;
252
+ if (hit && hit.key === key && Array.isArray(hit.results) && hit.results.length === parsed.length) {
253
+ results = hit.results;
254
+ } else {
255
+ results = classifyRefs(root, parsed);
256
+ if (key) {
257
+ cache.docs[doc] = { key, results };
258
+ cache.dirty = true;
259
+ }
260
+ }
261
+ return parsed.map((ref, i) => ({ ...ref, ...results[i] }));
262
+ }
263
+
200
264
  // mapText is the already-read atris/MAP.md, so boot does not read it twice.
201
265
  function checkMapDocs(root, mapText) {
266
+ const cache = loadRefsCache(root);
202
267
  const refs = existingMapDocs(root).flatMap(doc => {
203
268
  const text = doc === MAP_REF_DOCS[0] && typeof mapText === 'string'
204
269
  ? mapText : fs.readFileSync(path.resolve(root, doc), 'utf8');
205
- return checkMapRefs(root, text).refs.map(ref => ({ doc, ...ref }));
270
+ return cachedMapRefs(root, doc, text, cache).map(ref => ({ doc, ...ref }));
206
271
  });
272
+ saveRefsCache(cache);
207
273
  return summarize(refs);
208
274
  }
209
275