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
@@ -0,0 +1,189 @@
1
+ 'use strict';
2
+
3
+ // atris hire "<name>" --job "<idea>" one call: member, inbox, allowance, nightly loop
4
+ // atris hires the morning check-in: one line per hire, then its 24h / 7d scoreboard
5
+ // Backend: POST /api/business/{id}/hire, GET /api/business/{id}/hires
6
+
7
+ const { apiRequestJson } = require('../utils/api');
8
+ const { NOT_LOGGED_IN } = require('../lib/developer-api');
9
+ const {
10
+ parseArgs, businessOrSay, loginToken, errorText, relativeTime, dollars, clip,
11
+ } = require('../lib/business-target');
12
+
13
+ const HIRE_FLAGS = { switches: ['outreach'], values: ['job', 'budget', 'schedule', 'timezone'] };
14
+
15
+ function hireHelp(log = console.log) {
16
+ log(`usage: atris hire "<name>" --job "<idea>" [--budget 50] [--outreach] [--schedule nightly] [--business <slug>] [--json]
17
+
18
+ hire an agent in one step. it gets a name, a job, its own email inbox,
19
+ a monthly spending allowance, and a work loop that runs overnight.
20
+
21
+ --job the idea it works on, in plain words (required)
22
+ --budget most it may ask you to approve per month, in dollars (default 0 = no spending)
23
+ you still approve every purchase yourself
24
+ --outreach mark it as doing outreach (recorded; it uses a normal inbox for now)
25
+ --schedule nightly (default, 2 AM your time), hourly, or a cron like "0 3 * * *"
26
+ --timezone defaults to this computer's timezone
27
+ --business which business; defaults to the business folder you are in
28
+
29
+ running it again with the same name is safe: you get the same hire back,
30
+ and anything missing (like the inbox) is filled in. --budget changes the allowance.
31
+
32
+ then each morning: atris hires`);
33
+ }
34
+
35
+ function hiresHelp(log = console.log) {
36
+ log(`usage: atris hires [--business <slug>] [--json]
37
+
38
+ the morning check-in: one line per hire with its email in the last day,
39
+ what it spent this month, and what its last overnight run did. under it,
40
+ its scoreboard for the last 24 hours and 7 days: emails sent, how many got
41
+ a reply from a person, bounces, and spend.`);
42
+ }
43
+
44
+ function localTimezone() {
45
+ try {
46
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || '';
47
+ } catch {
48
+ return '';
49
+ }
50
+ }
51
+
52
+ function renderHire(data = {}, now = Date.now()) {
53
+ const m = data.member || {};
54
+ const lines = [data.created ? `Hired ${m.name}.` : `${m.name} was already hired. Same hire, nothing duplicated.`];
55
+ if (m.job) lines.push(`Job: ${clip(m.job, 200)}`);
56
+ if (data.inbox && data.inbox.address) lines.push(`Inbox: ${data.inbox.address}`);
57
+ const a = data.allowance || {};
58
+ lines.push(Number(a.monthly_usd) > 0
59
+ ? `Allowance: ${dollars(a.monthly_usd)} a month. You still approve every purchase.`
60
+ : 'Allowance: none. It cannot ask to spend money.');
61
+ if (a.note) lines.push(` ${a.note}`);
62
+ const s = data.schedule || {};
63
+ let when = s.label ? `Works ${s.label}.` : 'Works on its schedule.';
64
+ if (s.enabled === false) when += ' Asleep right now.';
65
+ else if (data.next_run_at) when += ` First run ${relativeTime(data.next_run_at, now)}.`;
66
+ lines.push(when);
67
+ if (data.outreach && data.outreach.requested) lines.push(`Outreach: ${data.outreach.note || 'recorded.'}`);
68
+ lines.push('', 'Check in each morning: atris hires');
69
+ return lines.join('\n');
70
+ }
71
+
72
+ function hireLine(h = {}, now = Date.now()) {
73
+ const parts = [];
74
+ if (h.status && h.status !== 'active') {
75
+ const why = { needs_inbox: 'paused, no inbox yet', needs_allowance: 'paused, allowance not set up', setting_up: 'still setting up',
76
+ pending_schedule: 'set up, nightly work not switched on yet (run the same hire again)' };
77
+ parts.push(why[h.status] || `paused (${h.status})`);
78
+ }
79
+ const mail = h.emails_last_24h;
80
+ if (mail) parts.push(`sent ${mail.sent}, got ${mail.received} emails today`);
81
+ else parts.push('no inbox');
82
+ const a = h.allowance || {};
83
+ if (Number(a.monthly_usd) > 0) {
84
+ parts.push(a.spent_this_month_usd == null
85
+ ? `${dollars(a.monthly_usd)} a month`
86
+ : `spent ${dollars(a.spent_this_month_usd)} of ${dollars(a.monthly_usd)}`);
87
+ }
88
+ if (h.last_run_at) {
89
+ const result = h.last_result ? `: ${clip(h.last_result, 80)}` : '';
90
+ parts.push(`last ran ${relativeTime(h.last_run_at, now)}${result}`);
91
+ } else if (h.schedule && h.schedule.enabled === false) {
92
+ parts.push('asleep, has not run');
93
+ } else if (h.next_run_at) {
94
+ parts.push(`first run ${relativeTime(h.next_run_at, now)}`);
95
+ } else {
96
+ parts.push('has not run yet');
97
+ }
98
+ return `${h.name}: ${parts.join(' · ')}`;
99
+ }
100
+
101
+ function windowText(label, w) {
102
+ if (!w || w.sent == null) return '';
103
+ const replies = Number(w.replies) || 0;
104
+ const value = w.reply_rate == null || w.reply_rate === '' ? NaN : Number(w.reply_rate);
105
+ const rate = Number.isFinite(value) ? ` (${Math.round(value * 100)}%)` : '';
106
+ const parts = [`sent ${w.sent}`, `${replies} ${replies === 1 ? 'reply' : 'replies'}${rate}`,
107
+ `${Number(w.bounces) || 0} bounced`];
108
+ if (w.spend_cents != null) parts.push(`spent ${dollars(Number(w.spend_cents) / 100)}`);
109
+ return `${label}: ${parts.join(', ')}`;
110
+ }
111
+
112
+ // The second line under a hire: how its mail is landing. Empty for an older backend.
113
+ function scoreLine(board, now = Date.now()) {
114
+ if (!board) return '';
115
+ const parts = [windowText('last 24h', board.last_24h), windowText('last 7 days', board.last_7d)].filter(Boolean);
116
+ if (!parts.length) return '';
117
+ if (board.last_action_at) parts.push(`last action ${relativeTime(board.last_action_at, now)}`);
118
+ if (board.capped) parts.push('counts cover only the newest mail');
119
+ return ` ${parts.join(' · ')}`;
120
+ }
121
+
122
+ function renderHires(data = {}, now = Date.now()) {
123
+ const rows = Array.isArray(data.hires) ? data.hires : [];
124
+ if (!rows.length) return 'No hires yet. Start one: atris hire "Maya" --job "grow the newsletter"';
125
+ return rows.map((h) => [hireLine(h, now), scoreLine(h.scoreboard, now)].filter(Boolean).join('\n')).join('\n');
126
+ }
127
+
128
+ function io(deps) {
129
+ return { log: deps.log || console.log, err: deps.err || console.error };
130
+ }
131
+
132
+ async function hireCommand(args = [], deps = {}) {
133
+ const out = io(deps);
134
+ const { flags, pos, error } = parseArgs(args, HIRE_FLAGS);
135
+ if (flags.help || pos[0] === 'help') { hireHelp(out.log); return 0; }
136
+ if (error) { out.err(`${error}. see: atris hire --help`); return 1; }
137
+
138
+ const name = pos.join(' ').trim();
139
+ const job = typeof flags.job === 'string' ? flags.job.trim() : '';
140
+ if (!name) { out.err('give the hire a name: atris hire "Maya" --job "<idea>"'); return 1; }
141
+ if (!job) { out.err('give the hire a job: --job "<the idea to grow>"'); return 1; }
142
+ const body = { name, job, outreach: flags.outreach === true };
143
+ if (flags.budget != null) {
144
+ const budget = Number(flags.budget);
145
+ if (!Number.isInteger(budget) || budget < 0 || budget > 1000) {
146
+ out.err('--budget must be whole dollars from 0 to 1000');
147
+ return 1;
148
+ }
149
+ body.monthly_allowance_usd = budget;
150
+ }
151
+ if (typeof flags.schedule === 'string') body.schedule = flags.schedule;
152
+ const tz = typeof flags.timezone === 'string' ? flags.timezone : (deps.timezone || localTimezone());
153
+ if (tz) body.timezone = tz;
154
+
155
+ const business = businessOrSay(flags, deps, out.err);
156
+ if (!business) return 1;
157
+ const token = await loginToken(deps);
158
+ if (!token) { out.err(NOT_LOGGED_IN); return 1; }
159
+
160
+ const request = deps.apiRequestJson || apiRequestJson;
161
+ const result = await request(`/business/${encodeURIComponent(business.id)}/hire`, {
162
+ method: 'POST', token, body, timeoutMs: 60000, retries: 0,
163
+ });
164
+ if (!result || !result.ok) { out.err(`Could not hire ${name}: ${errorText(result)}`); return 1; }
165
+ if (flags.json) out.log(JSON.stringify(result.data));
166
+ else out.log(renderHire(result.data, deps.now ? deps.now() : Date.now()));
167
+ return 0;
168
+ }
169
+
170
+ async function hiresCommand(args = [], deps = {}) {
171
+ const out = io(deps);
172
+ const { flags, pos, error } = parseArgs(args, {});
173
+ if (flags.help || pos[0] === 'help') { hiresHelp(out.log); return 0; }
174
+ if (error) { out.err(`${error}. see: atris hires --help`); return 1; }
175
+ if (pos.length) { out.err(`atris hires takes no other words (got "${pos[0]}"). see: atris hires --help`); return 1; }
176
+ const business = businessOrSay(flags, deps, out.err);
177
+ if (!business) return 1;
178
+ const token = await loginToken(deps);
179
+ if (!token) { out.err(NOT_LOGGED_IN); return 1; }
180
+
181
+ const request = deps.apiRequestJson || apiRequestJson;
182
+ const result = await request(`/business/${encodeURIComponent(business.id)}/hires`, { method: 'GET', token });
183
+ if (!result || !result.ok) { out.err(`Could not load hires: ${errorText(result)}`); return 1; }
184
+ if (flags.json) out.log(JSON.stringify(result.data));
185
+ else out.log(renderHires(result.data, deps.now ? deps.now() : Date.now()));
186
+ return 0;
187
+ }
188
+
189
+ module.exports = { hireCommand, hiresCommand, renderHire, renderHires, hireLine, scoreLine };
@@ -18,10 +18,13 @@
18
18
  * fallback, and scorecard writes can all be faked in tests.
19
19
  */
20
20
 
21
+ const crypto = require('crypto');
21
22
  const fs = require('fs');
22
23
  const os = require('os');
23
24
  const path = require('path');
24
25
  const { spawnSync } = require('child_process');
26
+ const gitSpawn = require('../lib/git-spawn');
27
+ const ignoredCache = require('../lib/ignored-cache');
25
28
  const { apiRequestJson, getApiBaseUrl } = require('../utils/api');
26
29
  const { loadCredentials } = require('../utils/auth');
27
30
  const pulse = require('../lib/pulse');
@@ -1085,8 +1088,9 @@ function parseRevisionsArgs(argv = []) {
1085
1088
  return opts;
1086
1089
  }
1087
1090
 
1088
- function gitLines(cwdRoot, gitArgs) {
1089
- const r = spawnSync('git', gitArgs, { cwd: cwdRoot, encoding: 'utf8' });
1091
+ // Through the module object so tests can count git spawns with a spy.
1092
+ function gitLines(cwdRoot, gitArgs, { input, maxBuffer } = {}) {
1093
+ const r = gitSpawn.runGit(gitArgs, { cwd: cwdRoot, check: false, input, maxBuffer });
1090
1094
  if (r.status !== 0) {
1091
1095
  const err = new Error(String(r.stderr || `git ${gitArgs[0]} exited ${r.status}`).trim());
1092
1096
  err.gitFailed = true;
@@ -1095,17 +1099,129 @@ function gitLines(cwdRoot, gitArgs) {
1095
1099
  return String(r.stdout || '');
1096
1100
  }
1097
1101
 
1102
+ // File names come back NUL-separated and never quoted, whatever core.quotePath
1103
+ // says, so a name reads the same fresh from git and from the saved cache.
1104
+ const NAME_ARGS = ['-c', 'core.quotePath=false'];
1105
+
1106
+ function splitNames(out) {
1107
+ return String(out || '').split('\0').filter(Boolean);
1108
+ }
1109
+
1098
1110
  /**
1099
1111
  * Files changed by one commit. Merge commits are attributed by their
1100
1112
  * first-parent diff (what the merge actually brought onto the mainline);
1101
1113
  * plain commits use diff-tree. Root commits list their initial files.
1114
+ * The rename choice is spelled out so a user's diff.renames setting cannot
1115
+ * change the lists: merges find renames (git diff's default), plain commits
1116
+ * do not (diff-tree's default).
1102
1117
  */
1103
1118
  function commitFiles(cwdRoot, commit) {
1104
1119
  const parents = commit.parents;
1105
1120
  const out = parents.length >= 2
1106
- ? gitLines(cwdRoot, ['diff', '--name-only', `${commit.hash}^1`, commit.hash])
1107
- : gitLines(cwdRoot, ['diff-tree', '--no-commit-id', '--name-only', '-r', '--root', commit.hash]);
1108
- return out.split('\n').map((line) => line.trim()).filter(Boolean);
1121
+ ? gitLines(cwdRoot, [...NAME_ARGS, 'diff', '--name-only', '--find-renames', '-z', `${commit.hash}^1`, commit.hash])
1122
+ : gitLines(cwdRoot, [...NAME_ARGS, 'diff-tree', '--no-commit-id', '--name-only', '--no-renames', '-z', '-r', '--root', commit.hash]);
1123
+ return splitNames(out);
1124
+ }
1125
+
1126
+ /**
1127
+ * Files changed by many plain commits in one git process: the same diff-tree
1128
+ * per-commit call above, fed every hash on stdin. --always prints the hash
1129
+ * even for an empty commit. Raw output frames every file as a ':'-led status
1130
+ * field followed by its path, so a path is never mistaken for a commit hash,
1131
+ * even a path spelled exactly like the next one.
1132
+ */
1133
+ function plainCommitFiles(cwdRoot, commits) {
1134
+ const found = new Map(commits.map((commit) => [commit.hash, []]));
1135
+ if (!commits.length) return found;
1136
+ const hashes = commits.map((commit) => commit.hash);
1137
+ const out = gitLines(cwdRoot, [...NAME_ARGS, 'diff-tree', '--stdin', '--always', '--raw', '--no-renames', '-z', '-r', '--root'], {
1138
+ input: `${hashes.join('\n')}\n`,
1139
+ // Many commits' lists in one reply; the per-commit calls each had 1MB.
1140
+ maxBuffer: Math.max(1, hashes.length) * 1024 * 1024,
1141
+ });
1142
+ const fields = splitNames(out);
1143
+ let current = null;
1144
+ for (let i = 0; i < fields.length; i++) {
1145
+ const field = fields[i];
1146
+ if (field.startsWith(':')) {
1147
+ // Status field; with --no-renames exactly one path follows it.
1148
+ if (current && i + 1 < fields.length) current.push(fields[i + 1]);
1149
+ i += 1;
1150
+ } else {
1151
+ current = found.get(field) || null;
1152
+ }
1153
+ }
1154
+ return found;
1155
+ }
1156
+
1157
+ // Where git keeps state shared by every checkout of this repo (a linked
1158
+ // worktree points at its main repo). null when it cannot be found on disk.
1159
+ function gitCommonDir(root) {
1160
+ if (process.env.GIT_DIR || process.env.GIT_COMMON_DIR) return null;
1161
+ let dir = path.resolve(root);
1162
+ for (;;) {
1163
+ const dotGit = path.join(dir, '.git');
1164
+ let stat = null;
1165
+ try { stat = fs.statSync(dotGit); } catch { stat = null; }
1166
+ if (stat && stat.isDirectory()) return dotGit;
1167
+ if (stat && stat.isFile()) {
1168
+ const match = fs.readFileSync(dotGit, 'utf8').match(/^gitdir:\s*(.+)$/m);
1169
+ if (!match) return null;
1170
+ const gitDir = path.resolve(dir, match[1].trim());
1171
+ try {
1172
+ return path.resolve(gitDir, fs.readFileSync(path.join(gitDir, 'commondir'), 'utf8').trim());
1173
+ } catch {
1174
+ return gitDir;
1175
+ }
1176
+ }
1177
+ const parent = path.dirname(dir);
1178
+ if (parent === dir) return null;
1179
+ dir = parent;
1180
+ }
1181
+ }
1182
+
1183
+ // A shallow clone cuts history at boundary commits, and those commits list
1184
+ // different files once the history is fetched. The saved lists are only good
1185
+ // for the shallow state they were read under; null means do not cache.
1186
+ function shallowStamp(root) {
1187
+ const common = gitCommonDir(root);
1188
+ if (!common) return null;
1189
+ try {
1190
+ return crypto.createHash('sha1').update(fs.readFileSync(path.join(common, 'shallow'))).digest('hex');
1191
+ } catch (error) {
1192
+ return error.code === 'ENOENT' ? 'full' : null;
1193
+ }
1194
+ }
1195
+
1196
+ // A commit's file list never changes, so it is saved by hash in the ignored
1197
+ // .atris/cache and the gauge (shown on every boot) only asks git about commits
1198
+ // it has not seen. Hashes this read used come first; older ones stay until the
1199
+ // cap, so a 7-day boot read and a 14-day report read do not evict each other.
1200
+ const REVISION_FILES_CACHE = 'revision-files.json';
1201
+ const REVISION_FILES_CACHE_MAX = 5000;
1202
+
1203
+ function loadRevisionFilesCache(root) {
1204
+ const shallow = ignoredCache.cacheEnabled(root) ? shallowStamp(root) : null;
1205
+ const cache = { root, shallow, entries: {}, used: {}, dirty: false, writable: Boolean(shallow) };
1206
+ if (!cache.writable) return cache;
1207
+ const parsed = ignoredCache.readCacheJson(root, REVISION_FILES_CACHE);
1208
+ if (parsed && parsed.version === 1 && parsed.shallow === shallow && parsed.entries && typeof parsed.entries === 'object') {
1209
+ cache.entries = parsed.entries;
1210
+ }
1211
+ return cache;
1212
+ }
1213
+
1214
+ function saveRevisionFilesCache(cache) {
1215
+ if (!cache.writable || !cache.dirty) return;
1216
+ const entries = { ...cache.used };
1217
+ let count = Object.keys(entries).length;
1218
+ for (const [hash, files] of Object.entries(cache.entries)) {
1219
+ if (count >= REVISION_FILES_CACHE_MAX) break;
1220
+ if (entries[hash] || !Array.isArray(files)) continue;
1221
+ entries[hash] = files;
1222
+ count += 1;
1223
+ }
1224
+ ignoredCache.writeCacheJson(cache.root, REVISION_FILES_CACHE, { version: 1, shallow: cache.shallow, entries });
1109
1225
  }
1110
1226
 
1111
1227
  /**
@@ -1150,10 +1266,27 @@ function collectRevisionSignals(root, options = {}) {
1150
1266
  // commits count as revision signals.
1151
1267
  const humans = commits.filter((c) => !c.isAgent && c.parents.length < 2);
1152
1268
 
1153
- const filesCache = new Map();
1269
+ // Every landing's files are needed, and a human commit's only when it falls
1270
+ // inside some landing's window. Plain commits missing from the cache are
1271
+ // read in one batch; merges keep their own first-parent diff.
1272
+ const cache = loadRevisionFilesCache(root);
1273
+ const needed = [...landings, ...humans.filter((human) => landings.some((landing) => human.ms > landing.ms && human.ms <= landing.ms + REVISION_WINDOW_MS))];
1274
+ const missing = [];
1275
+ for (const commit of needed) {
1276
+ const hit = cache.entries[commit.hash];
1277
+ if (Array.isArray(hit)) cache.used[commit.hash] = hit;
1278
+ else if (commit.parents.length < 2) missing.push(commit);
1279
+ }
1280
+ for (const [hash, files] of plainCommitFiles(root, missing)) {
1281
+ cache.used[hash] = files;
1282
+ cache.dirty = true;
1283
+ }
1154
1284
  const filesOf = (commit) => {
1155
- if (!filesCache.has(commit.hash)) filesCache.set(commit.hash, commitFiles(root, commit));
1156
- return filesCache.get(commit.hash);
1285
+ if (!cache.used[commit.hash]) {
1286
+ cache.used[commit.hash] = commitFiles(root, commit);
1287
+ cache.dirty = true;
1288
+ }
1289
+ return cache.used[commit.hash];
1157
1290
  };
1158
1291
 
1159
1292
  const revisions = [];
@@ -1177,6 +1310,7 @@ function collectRevisionSignals(root, options = {}) {
1177
1310
  });
1178
1311
  }
1179
1312
  }
1313
+ saveRevisionFilesCache(cache);
1180
1314
 
1181
1315
  return {
1182
1316
  schema: REVISIONS_SCHEMA,
@@ -33,11 +33,12 @@ const {
33
33
  const fs = require('fs');
34
34
  const os = require('os');
35
35
  const path = require('path');
36
+ const { stateHome } = require('../lib/state-home');
36
37
  const readline = require('readline');
37
38
  const { spawnSync } = require('child_process');
38
39
  const { sleepSync } = require('../lib/sleep-sync');
39
40
 
40
- const CALENDAR_CACHE_PATH = path.join(os.homedir(), '.atris', 'calendar-events-cache.json');
41
+ const CALENDAR_CACHE_PATH = path.join(stateHome(), '.atris', 'calendar-events-cache.json');
41
42
  const GMAIL_CONNECT_POLL_MS = 3000;
42
43
  const GMAIL_CONNECT_TIMEOUT_MS = 3 * 60 * 1000;
43
44
  const GMAIL_SEND_USAGE = 'usage: atris gmail send <to> <subject> <body...> [--body-file <path>] [--account <id>]';
@@ -182,7 +183,7 @@ function parseGmailVerdictsArgs(args = []) {
182
183
 
183
184
  function gmailAccountStatePath() {
184
185
  return process.env.ATRIS_GMAIL_ACCOUNT_FILE
185
- || path.join(os.homedir(), '.atris', 'gmail-account.json');
186
+ || path.join(stateHome(), '.atris', 'gmail-account.json');
186
187
  }
187
188
 
188
189
  function readGmailStickyAccount() {
@@ -1794,7 +1795,7 @@ function normalizeContactLabel(value) {
1794
1795
  }
1795
1796
 
1796
1797
  function imessageLookupCachePath() {
1797
- return path.join(os.homedir(), '.atris', 'cache', 'imessage-contacts.json');
1798
+ return path.join(stateHome(), '.atris', 'cache', 'imessage-contacts.json');
1798
1799
  }
1799
1800
 
1800
1801
  function readImessageLookupCache() {
package/commands/loops.js CHANGED
@@ -13,6 +13,7 @@
13
13
  const fs = require('fs');
14
14
  const os = require('os');
15
15
  const path = require('path');
16
+ const { stateHome } = require('../lib/state-home');
16
17
  const { execSync, spawnSync } = require('child_process');
17
18
 
18
19
  const MAX_LOG_AGE_DAYS = 14;
@@ -26,7 +27,7 @@ const SCAFFOLD_FILES = [
26
27
  ];
27
28
 
28
29
  function heartbeatDir() {
29
- return process.env.ATRIS_HEARTBEAT_DIR || path.join(os.homedir(), '.atris', 'heartbeat');
30
+ return process.env.ATRIS_HEARTBEAT_DIR || path.join(stateHome(), '.atris', 'heartbeat');
30
31
  }
31
32
 
32
33
  function readJson(filePath, fallback) {
@@ -0,0 +1,163 @@
1
+ 'use strict';
2
+
3
+ // atris mail inboxes | read <inbox> | send <inbox> --to --subject --body
4
+ // Your agents' own email inboxes (the @atrismail.com ones), not your Gmail.
5
+ // For Gmail use `atris gmail`. Backend: /api/business/{id}/mail/...
6
+
7
+ const readline = require('readline');
8
+ const { apiRequestJson } = require('../utils/api');
9
+ const { NOT_LOGGED_IN } = require('../lib/developer-api');
10
+ const {
11
+ parseArgs, businessOrSay, loginToken, errorText, clip,
12
+ } = require('../lib/business-target');
13
+
14
+ // The only flags each subcommand takes (plus --help, --json, --business).
15
+ const MAIL_FLAGS = {
16
+ inboxes: {},
17
+ read: { values: ['limit'] },
18
+ send: { switches: ['yes', 'dry_run'], values: ['to', 'subject', 'body'] },
19
+ };
20
+ const ALL_MAIL_FLAGS = { switches: ['yes', 'dry_run'], values: ['limit', 'to', 'subject', 'body'] };
21
+
22
+ function mailHelp(log = console.log) {
23
+ log(`usage: atris mail inboxes [--business <slug>] [--json]
24
+ atris mail read <inbox> [--limit 10] [--json]
25
+ atris mail send <inbox> --to <address> --subject "<subject>" --body "<text>" [--yes] [--dry-run]
26
+
27
+ your agents' own email inboxes (not your Gmail; for that use atris gmail).
28
+ <inbox> is the address, the part before the @, or the inbox id.
29
+
30
+ inboxes list every agent inbox in this business
31
+ read newest messages first (--limit 1 to 200, default 10)
32
+ send send one email from an agent inbox. asks before sending unless --yes.
33
+ --dry-run prints the email and sends nothing.
34
+ unknown flags are refused, so a typo never sends anything.`);
35
+ }
36
+
37
+ function toList(value) {
38
+ if (Array.isArray(value)) return value.map(String);
39
+ if (typeof value === 'string' && value.trim().startsWith('[')) {
40
+ try { return JSON.parse(value).map(String); } catch { /* fall through */ }
41
+ }
42
+ return value ? [String(value)] : [];
43
+ }
44
+
45
+ function renderInboxes(rows = []) {
46
+ if (!rows.length) return 'No agent inboxes yet. Hiring an agent makes one: atris hire "<name>" --job "<idea>"';
47
+ return rows.map((r) => `${r.email_address}${r.display_name ? ` (${r.display_name})` : ''}`).join('\n');
48
+ }
49
+
50
+ function renderMessages(inbox, rows = []) {
51
+ if (!rows.length) return `No mail in ${inbox.email_address} yet.`;
52
+ const lines = [];
53
+ for (const m of rows) {
54
+ const when = String(m.created_at || '').slice(0, 16).replace('T', ' ');
55
+ const out = m.direction === 'outbound';
56
+ const who = out ? `to ${toList(m.to_addresses).join(', ')}` : `from ${m.from_address || '?'}`;
57
+ lines.push(`${when} ${out ? 'sent' : 'got '} ${who}`);
58
+ lines.push(` ${clip(m.subject || '(no subject)', 100)}`);
59
+ const body = clip(m.body_text || '', 160);
60
+ if (body) lines.push(` ${body}`);
61
+ }
62
+ return lines.join('\n');
63
+ }
64
+
65
+ // address, local part, or id -> the inbox row
66
+ function findInbox(rows, wanted) {
67
+ const w = String(wanted || '').trim().toLowerCase();
68
+ if (!w) return null;
69
+ return rows.find((r) => String(r.id).toLowerCase() === w)
70
+ || rows.find((r) => String(r.email_address || '').toLowerCase() === w)
71
+ || rows.find((r) => String(r.email_address || '').toLowerCase().split('@')[0] === w)
72
+ || null;
73
+ }
74
+
75
+ function askYesNo(question) {
76
+ if (!process.stdin.isTTY) return Promise.resolve(false);
77
+ return new Promise((resolve) => {
78
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
79
+ rl.question(question, (answer) => {
80
+ rl.close();
81
+ resolve(/^y(es)?$/i.test(String(answer || '').trim()));
82
+ });
83
+ });
84
+ }
85
+
86
+ async function mailCommand(args = [], deps = {}) {
87
+ const out = { log: deps.log || console.log, err: deps.err || console.error };
88
+ const { flags, pos } = parseArgs(args, ALL_MAIL_FLAGS);
89
+ const sub = pos[0];
90
+ if (flags.help || !sub || sub === 'help') { mailHelp(out.log); return sub || flags.help ? 0 : 1; }
91
+ if (!MAIL_FLAGS[sub]) {
92
+ out.err(`unknown mail command: ${sub}. try: atris mail inboxes | read | send`);
93
+ return 1;
94
+ }
95
+ // Checked against this subcommand's own flags, before any request: an
96
+ // unknown or mistyped flag on send must never turn into a real email.
97
+ const { error } = parseArgs(args, MAIL_FLAGS[sub]);
98
+ if (error) { out.err(`${error}. see: atris mail --help`); return 1; }
99
+ const words = sub === 'inboxes' ? 1 : 2;
100
+ if (pos.length > words) {
101
+ out.err(`atris mail ${sub} got an extra word: "${pos[words]}". put text in quotes, like --body "hi there"`);
102
+ return 1;
103
+ }
104
+
105
+ const business = businessOrSay(flags, deps, out.err);
106
+ if (!business) return 1;
107
+ const token = await loginToken(deps);
108
+ if (!token) { out.err(NOT_LOGGED_IN); return 1; }
109
+ const request = deps.apiRequestJson || apiRequestJson;
110
+ const base = `/business/${encodeURIComponent(business.id)}/mail`;
111
+
112
+ const listed = await request(`${base}/inboxes`, { method: 'GET', token });
113
+ if (!listed || !listed.ok) { out.err(`Could not load inboxes: ${errorText(listed)}`); return 1; }
114
+ const inboxes = Array.isArray(listed.data) ? listed.data : [];
115
+
116
+ if (sub === 'inboxes') {
117
+ out.log(flags.json ? JSON.stringify(inboxes) : renderInboxes(inboxes));
118
+ return 0;
119
+ }
120
+
121
+ if (!pos[1]) { out.err(`which inbox? atris mail ${sub} <inbox>`); return 1; }
122
+ const inbox = findInbox(inboxes, pos[1]);
123
+ if (!inbox) { out.err(`No inbox matches "${pos[1]}". See them all: atris mail inboxes`); return 1; }
124
+
125
+ if (sub === 'read') {
126
+ const limit = flags.limit == null ? 10 : Number(flags.limit);
127
+ if (!Number.isInteger(limit) || limit < 1 || limit > 200) { out.err('--limit must be a number from 1 to 200'); return 1; }
128
+ const res = await request(`${base}/inboxes/${encodeURIComponent(inbox.id)}/messages?limit=${limit}`, { method: 'GET', token });
129
+ if (!res || !res.ok) { out.err(`Could not read ${inbox.email_address}: ${errorText(res)}`); return 1; }
130
+ const rows = Array.isArray(res.data) ? res.data : [];
131
+ out.log(flags.json ? JSON.stringify(rows) : renderMessages(inbox, rows));
132
+ return 0;
133
+ }
134
+
135
+ // send
136
+ const to = typeof flags.to === 'string' ? flags.to.trim() : '';
137
+ const subject = typeof flags.subject === 'string' ? flags.subject : '';
138
+ const text = typeof flags.body === 'string' ? flags.body : '';
139
+ if (!to || !subject || !text) { out.err('send needs --to, --subject, and --body'); return 1; }
140
+ const preview = `From: ${inbox.email_address}\nTo: ${to}\nSubject: ${subject}\n\n${text}\n`;
141
+ if (flags.dry_run === true) {
142
+ out.log(`${preview}\nDry run: nothing was sent.`);
143
+ return 0;
144
+ }
145
+ // Only a bare --yes (or -y) skips the question; the parser refuses --yes=<anything>.
146
+ if (flags.yes !== true) {
147
+ out.log(preview);
148
+ const ok = await (deps.confirm || askYesNo)('Send this email? (y/N) ');
149
+ if (!ok) {
150
+ out.log(process.stdin.isTTY || deps.confirm ? 'Not sent.' : 'Not sent. Add --yes to send without asking.');
151
+ return 1;
152
+ }
153
+ }
154
+ const res = await request(`${base}/inboxes/${encodeURIComponent(inbox.id)}/messages/send`, {
155
+ method: 'POST', token, body: { to, subject, text }, retries: 0, timeoutMs: 30000,
156
+ });
157
+ if (!res || !res.ok) { out.err(`Not sent: ${errorText(res)}`); return 1; }
158
+ if (flags.json) out.log(JSON.stringify(res.data));
159
+ else out.log(`Sent from ${inbox.email_address} to ${to}.`);
160
+ return 0;
161
+ }
162
+
163
+ module.exports = { mailCommand, findInbox, renderInboxes, renderMessages };