atris 3.82.23 → 3.82.24

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.
package/bin/atris.js CHANGED
@@ -153,7 +153,8 @@ const dryRunRequested = updateArgs.includes('--dry-run');
153
153
  const skipUpdateCheck = Boolean(process.env.ATRIS_SKIP_UPDATE_CHECK || process.env.NO_UPDATE_NOTIFIER || helpRequested || jsonRequested);
154
154
  // Local-only commands make no registry call and start no background install.
155
155
  // sentinel prints its own one-line notice at the end of the run instead.
156
- if (!skipUpdateCheck && (!updateCommand || (updateCommand && !['version', 'update', 'mcp', 'recap', '99', 'sentinel', 'login'].includes(updateCommand)))) {
156
+ // listen is polled by scripts every minute and its stdout is read by them.
157
+ if (!skipUpdateCheck && (!updateCommand || (updateCommand && !['version', 'update', 'mcp', 'recap', '99', 'sentinel', 'login', 'listen'].includes(updateCommand)))) {
157
158
  updateCheckPromise = checkForUpdates()
158
159
  .then((updateInfo) => {
159
160
  if (updateInfo) {
@@ -788,6 +789,7 @@ function showHelpAll() {
788
789
  console.log(' hires - Morning check-in: one line per hire (mail, spend, last run)');
789
790
  console.log(' mail - Your agents\' own inboxes: list, read, send (not Gmail)');
790
791
  console.log(' channel - The team\'s shared channels: read, post, claim, done (atris channel <name>)');
792
+ console.log(' listen - A team member\'s inbox: what was sent to it, then mark it handled (atris listen --as <member>)');
791
793
  console.log(' design - Extract a site design system, check brand adherence, search brands');
792
794
  console.log(' mcp - Run the atris MCP server (stdio) for Claude Desktop and Cursor');
793
795
  console.log('');
@@ -2586,6 +2588,11 @@ if (command === 'guide') {
2586
2588
  Promise.resolve().then(() => require('../commands/channel').channelCommand(process.argv.slice(3)))
2587
2589
  .then((code) => exitAfterStdoutDrain(typeof code === 'number' ? code : 0))
2588
2590
  .catch((err) => { console.error(String(err.message || err).replace(/\s+/g, ' ')); process.exit(1); });
2591
+ } else if (command === 'listen') {
2592
+ // A team member's inbox on the team board. Exits 0 new, 1 nothing new, 2 error.
2593
+ Promise.resolve().then(() => require('../commands/listen').listenCommand(process.argv.slice(3)))
2594
+ .then((code) => exitAfterStdoutDrain(typeof code === 'number' ? code : 0))
2595
+ .catch((err) => { console.error(String(err.message || err).replace(/\s+/g, ' ')); process.exit(2); });
2589
2596
  } else if (command === 'transactions') {
2590
2597
  Promise.resolve().then(() => require('../commands/transactions').transactionsCommand(process.argv.slice(3)))
2591
2598
  .then((code) => exitAfterStdoutDrain(typeof code === 'number' ? code : 0))
@@ -5,12 +5,10 @@
5
5
  // the sign-in they already have. Backend: /api/swarlo/{business}/... (the
6
6
  // business may be its slug or its id; the server takes both).
7
7
 
8
- const fs = require('fs');
9
- const path = require('path');
10
8
  const { apiRequestJson } = require('../utils/api');
11
- const { NOT_LOGGED_IN, oneLine } = require('../lib/developer-api');
9
+ const { NOT_LOGGED_IN } = require('../lib/developer-api');
12
10
  const { parseArgs, loginToken, relativeTime, clip } = require('../lib/business-target');
13
- const { stateHome } = require('../lib/state-home');
11
+ const { resolveChannelBusiness, boardWhy, memberName, lookupMember } = require('../lib/team-board');
14
12
 
15
13
  // Words that are verbs here, so no channel can be called one of them.
16
14
  const REPORTS = ['done', 'failed', 'blocked'];
@@ -43,60 +41,6 @@ the team's channels on the hosted Atris server, shared by every agent on every
43
41
  computer. uses your existing sign-in (atris login).`);
44
42
  }
45
43
 
46
- function readJson(file) {
47
- try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
48
- }
49
-
50
- function registeredBusinesses(homeDir) {
51
- const raw = readJson(path.join(homeDir, '.atris', 'businesses.json'));
52
- if (!raw || typeof raw !== 'object') return [];
53
- return Object.entries(raw)
54
- .filter(([, b]) => b && typeof b === 'object' && b.business_id)
55
- .map(([key, b]) => ({ id: String(b.business_id), slug: String(b.slug || key), name: String(b.name || b.slug || key) }));
56
- }
57
-
58
- function boundBusiness(startDir) {
59
- let dir = path.resolve(startDir);
60
- for (;;) {
61
- const parsed = readJson(path.join(dir, '.atris', 'business.json'));
62
- if (parsed && parsed.business_id) {
63
- const slug = String(parsed.slug || parsed.name || parsed.business_id);
64
- return { id: String(parsed.business_id), slug, name: String(parsed.name || slug) };
65
- }
66
- const up = path.dirname(dir);
67
- if (up === dir) return null;
68
- dir = up;
69
- }
70
- }
71
-
72
- const PICK = 'add --business <slug>, set ATRIS_BUSINESS=<slug>, or run inside a business folder';
73
-
74
- // Which business, in order: --business, ATRIS_BUSINESS, the nearest
75
- // .atris/business.json, then the only one in ~/.atris/businesses.json.
76
- // A slug is looked up locally; one this computer does not know goes to the
77
- // server as is. Returns { id, slug, name } or { error }.
78
- function resolveChannelBusiness(flags = {}, deps = {}) {
79
- const env = deps.env || process.env;
80
- const known = registeredBusinesses(deps.homeDir || stateHome());
81
- const lookup = (value) => known.find((b) => b.slug === value || b.id === value)
82
- || { id: value, slug: value, name: value };
83
-
84
- if (Object.prototype.hasOwnProperty.call(flags, 'business')) {
85
- const given = typeof flags.business === 'string' ? flags.business.trim() : '';
86
- if (!given) return { error: '--business needs a business slug' };
87
- return lookup(given);
88
- }
89
- const fromEnv = String(env.ATRIS_BUSINESS || '').trim();
90
- if (fromEnv) return lookup(fromEnv);
91
- const bound = boundBusiness(deps.cwd || process.cwd());
92
- if (bound) return bound;
93
- if (known.length === 1) return known[0];
94
- if (!known.length) return { error: `no business picked: ${PICK}.` };
95
- const names = known.map((b) => b.slug);
96
- const shown = names.length > 8 ? `${names.slice(0, 8).join(', ')}, and ${names.length - 8} more` : names.join(', ');
97
- return { error: `more than one business on this computer (${shown}): ${PICK}.` };
98
- }
99
-
100
44
  function postLine(post, now, width = 400) {
101
45
  const who = clip(post.member_name || 'someone', 18);
102
46
  const when = relativeTime(post.created_at, now) || '';
@@ -144,14 +88,7 @@ async function channelCommand(args = [], deps = {}) {
144
88
  const request = deps.apiRequestJson || apiRequestJson;
145
89
  const base = `/swarlo/${encodeURIComponent(business.id)}`;
146
90
  const call = (route, options = {}) => request(`${base}${route}`, { method: 'GET', ...options, token });
147
- const why = (res) => {
148
- if (!res) return 'no answer from the server';
149
- const detail = res.data && (res.data.detail || res.data.error);
150
- if (res.status === 401) return 'your sign-in was refused. run: atris login';
151
- if (res.status === 403) return `you are not on ${business.slug}'s team`;
152
- if (res.status === 404 && detail === 'Business not found') return `no business called "${business.slug}"`;
153
- return oneLine(detail || res.error || `request failed (${res.status})`);
154
- };
91
+ const why = (res) => boardWhy(res, business);
155
92
  const say = (res) => log(JSON.stringify(res.data));
156
93
  const channelList = async () => {
157
94
  const res = await call('/channels');
@@ -200,14 +137,14 @@ async function channelCommand(args = [], deps = {}) {
200
137
  // member that cannot be found stops the write; it never falls back to you.
201
138
  const writes = (verb && verb !== 'list') || (name && text);
202
139
  const env = deps.env || process.env;
203
- const asWho = String((typeof flags.as === 'string' ? flags.as : env.ATRIS_AGENT) || '').trim().replace(/^@/, '').toLowerCase();
140
+ const asWho = memberName(flags, env);
204
141
  let member = null;
205
142
  if (writes && asWho) {
206
- const res = await request(`/business/${encodeURIComponent(business.id)}/team/${encodeURIComponent(asWho)}`, { method: 'GET', token });
207
- if (res && res.status === 404) { err(`no member called "${asWho}" in ${business.slug}, so nothing was sent. check the name, or leave off --as to post as yourself`); return 1; }
208
- if (!res || !res.ok) { err(`could not look up member "${asWho}", so nothing was sent: ${why(res)}`); return 1; }
209
- if (!res.data || !res.data.agent_id) { err(`member "${asWho}" in ${business.slug} has no agent id, so nothing was sent`); return 1; }
210
- member = { id: String(res.data.agent_id), name: String(res.data.name || asWho) };
143
+ const found = await lookupMember(request, business, asWho, token);
144
+ if (found.missing) { err(`no member called "${asWho}" in ${business.slug}, so nothing was sent. check the name, or leave off --as to post as yourself`); return 1; }
145
+ if (found.failed !== undefined) { err(`could not look up member "${asWho}", so nothing was sent: ${why(found.failed)}`); return 1; }
146
+ if (found.noId) { err(`member "${asWho}" in ${business.slug} has no agent id, so nothing was sent`); return 1; }
147
+ member = found.member;
211
148
  }
212
149
  const signed = (body) => (member ? { ...body, agent_id: member.id } : body);
213
150
  const byline = member ? ` as ${member.name}` : '';
@@ -0,0 +1,196 @@
1
+ 'use strict';
2
+
3
+ // atris listen: a team member's inbox on the team board. Shows what was sent
4
+ // to that member (mentions, replies, tasks) that it has not marked handled.
5
+ // Backend: GET /api/swarlo/{business}/inbox and POST .../inbox/ack.
6
+ //
7
+ // Exit codes are for scripts: 0 something new, 1 nothing new, 2 error.
8
+ // if atris listen --as walsh --json > new.json; then ...; fi
9
+
10
+ const { apiRequestJson } = require('../utils/api');
11
+ const { NOT_LOGGED_IN, oneLine } = require('../lib/developer-api');
12
+ const { parseArgs, relativeTime, clip } = require('../lib/business-target');
13
+ const { resolveChannelBusiness, boardWhy, memberName, lookupMember } = require('../lib/team-board');
14
+
15
+ const FLAGS = { switches: ['watch'], values: ['as', 'limit', 'ack'] };
16
+ const NEW = 0;
17
+ const NOTHING = 1;
18
+ const FAILED = 2;
19
+ const WATCH_MS = 30000;
20
+ const SERVER_MAX = 50;
21
+ // Scripts run this every minute: each request gives up after 10 seconds and
22
+ // is never retried, so one run cannot outlast the next.
23
+ const QUICK = { timeoutMs: 10000, retries: 0 };
24
+ const WHOLE = /^[1-9]\d*$/;
25
+
26
+ // The sign-in token, or { error }. A token from ATRIS_TOKEN or one that is
27
+ // not near expiry is used as is: the inbox request's own 401 is the check,
28
+ // which saves a round trip per run. Anything else (expired, unknown expiry,
29
+ // an agent client) goes through the full check. When that check fails for a
30
+ // reason other than "never signed in", say the real reason.
31
+ async function listenToken(deps, request) {
32
+ if (deps.token) return { token: deps.token };
33
+ if (!deps.ensureCredentials) {
34
+ const auth = require('../utils/auth');
35
+ const { isAgentClientCredential } = require('../utils/agent-client');
36
+ const creds = auth.readActiveCredentials();
37
+ const fresh = creds && creds.token && !isAgentClientCredential(creds)
38
+ && (creds.source === 'env' || (auth.getTokenExpiryEpochSeconds(creds.token) && !auth.shouldRefreshToken(creds.token)));
39
+ if (fresh) return { token: creds.token };
40
+ }
41
+ const ensure = deps.ensureCredentials
42
+ || (() => require('../utils/auth').ensureValidCredentials((p, o = {}) => request(p, { ...o, ...QUICK })));
43
+ let auth;
44
+ try { auth = await ensure(); } catch (e) { return { error: `could not check your sign-in: ${oneLine((e && e.message) || e)}` }; }
45
+ if (auth && !auth.error && auth.credentials && auth.credentials.token) return { token: auth.credentials.token };
46
+ if (!auth || auth.error === 'not_logged_in') return { error: NOT_LOGGED_IN };
47
+ return { error: `could not check your sign-in: ${oneLine(auth.detail || auth.error)}. if this keeps happening, run: atris login` };
48
+ }
49
+
50
+ function listenHelp(log = console.log) {
51
+ log(`usage: atris listen --as <member> what was sent to that member, oldest first
52
+ atris listen --as <member> --ack <number> mark everything up to that number handled
53
+ atris listen --as <member> --watch keep checking every 30 seconds (Ctrl-C to stop)
54
+
55
+ options:
56
+ --as <member> whose inbox (else ATRIS_AGENT)
57
+ --business <slug> which business (else ATRIS_BUSINESS, this folder's business, or your only one)
58
+ --limit <n> how many to show, 1 to 50 (default 20)
59
+ --json the server's reply as is
60
+
61
+ exits 0 when something is new, 1 when nothing is, 2 on an error, so a script
62
+ can run: if atris listen --as walsh --json > new.json; then ...; fi
63
+ --watch never marks anything handled. uses your existing sign-in (atris login).`);
64
+ }
65
+
66
+ // Oldest first, whatever order the server sent.
67
+ function bySeq(items) {
68
+ return items.slice().sort((a, b) => Number(a.seq) - Number(b.seq));
69
+ }
70
+
71
+ function itemLines(items, now) {
72
+ const rows = items.map((it) => ({
73
+ channel: `#${clip(it.channel || '?', 20)}`,
74
+ who: clip(it.author_name || it.author_id || 'someone', 18),
75
+ when: relativeTime(it.created_at, now) || '',
76
+ text: `[${it.reason || 'note'}] ${clip(it.content || '', 200)}`,
77
+ }));
78
+ const w = (key) => Math.max(...rows.map((r) => r[key].length));
79
+ const wc = w('channel'); const ww = w('who'); const wt = w('when');
80
+ return rows.map((r) => `${r.channel.padEnd(wc)} ${r.who.padEnd(ww)} ${r.when.padEnd(wt)} ${r.text}`);
81
+ }
82
+
83
+ async function listenCommand(args = [], deps = {}) {
84
+ let secret = '';
85
+ const hide = (line) => (secret ? String(line).split(secret).join('[hidden]') : String(line));
86
+ const log = (line) => (deps.log || console.log)(hide(line));
87
+ const err = (line) => (deps.err || console.error)(hide(line));
88
+ const clock = () => (deps.now ? deps.now() : Date.now());
89
+ const env = deps.env || process.env;
90
+
91
+ const { flags, pos, error } = parseArgs(args, FLAGS);
92
+ if (flags.help || pos[0] === 'help') { listenHelp(log); return 0; }
93
+ if (error) { err(`${error}. see: atris listen --help`); return FAILED; }
94
+ if (pos.length) { err(`atris listen takes no other words. to name the member: atris listen --as ${pos[0].replace(/^@/, '')}`); return FAILED; }
95
+
96
+ const who = memberName(flags, env);
97
+ if (!who) { err('say who is listening: atris listen --as <member>'); return FAILED; }
98
+ const limit = flags.limit == null ? 20 : (WHOLE.test(flags.limit) ? Number(flags.limit) : NaN);
99
+ if (!(limit >= 1 && limit <= SERVER_MAX)) { err('--limit takes a whole number from 1 to 50'); return FAILED; }
100
+ const ack = flags.ack == null ? null : (WHOLE.test(flags.ack) ? Number(flags.ack) : NaN);
101
+ if (ack !== null && !Number.isSafeInteger(ack)) { err(`--ack takes the number shown on the last line, like: atris listen --as ${who} --ack 42`); return FAILED; }
102
+ if (ack !== null && flags.watch) { err('--ack and --watch do not go together: --watch never marks anything handled'); return FAILED; }
103
+
104
+ const business = resolveChannelBusiness(flags, deps);
105
+ if (business.error) { err(business.error); return FAILED; }
106
+ const raw = deps.apiRequestJson || apiRequestJson;
107
+ const request = (p, o = {}) => raw(p, { ...o, ...QUICK });
108
+ const signIn = await listenToken(deps, raw);
109
+ if (signIn.error) { err(signIn.error); return FAILED; }
110
+ const token = signIn.token;
111
+ secret = token;
112
+
113
+ const why = (res) => boardWhy(res, business);
114
+ const found = await lookupMember(request, business, who, token);
115
+ if (found.missing) { err(`no member called "${who}" in ${business.slug}, so nothing was checked. check the spelling of the name`); return FAILED; }
116
+ if (found.failed !== undefined) { err(`could not look up member "${who}": ${why(found.failed)}`); return FAILED; }
117
+ if (found.noId) { err(`member "${who}" in ${business.slug} has no agent id, so it has no inbox`); return FAILED; }
118
+ const member = found.member;
119
+
120
+ const base = `/swarlo/${encodeURIComponent(business.id)}/inbox`;
121
+ const inboxWhy = (res) => (res && res.status === 404 && res.data && res.data.detail === 'Not Found'
122
+ ? 'this server has no inboxes yet; the update may still be rolling out'
123
+ : why(res));
124
+ // The same command again, so the hint is copyable as is.
125
+ const again = `atris listen --as ${who}${Object.prototype.hasOwnProperty.call(flags, 'business') ? ` --business ${business.slug}` : ''}`;
126
+
127
+ if (ack !== null) {
128
+ const res = await request(`${base}/ack`, { method: 'POST', token, body: { agent_id: member.id, seq: ack } });
129
+ if (!res || !res.ok) { err(`could not mark ${member.name}'s inbox handled: ${inboxWhy(res)}`); return FAILED; }
130
+ if (flags.json) { log(JSON.stringify(res.data)); return 0; }
131
+ const upTo = Number(res.data && res.data.read_up_to);
132
+ if (Number.isFinite(upTo) && upTo > ack) log(`${member.name}'s inbox was already handled up to ${upTo}; nothing changed.`);
133
+ else log(`marked handled for ${member.name}, up to ${Number.isFinite(upTo) ? upTo : ack}.`);
134
+ return 0;
135
+ }
136
+
137
+ const fetchInbox = async () => {
138
+ // --watch asks for the most the server gives, since it never marks
139
+ // anything handled and would otherwise keep seeing the same oldest few.
140
+ const n = flags.watch ? SERVER_MAX : limit;
141
+ const res = await request(`${base}?agent_id=${encodeURIComponent(member.id)}&limit=${n}`, { method: 'GET', token });
142
+ if (!res || !res.ok) return { failed: inboxWhy(res), res };
143
+ if (!res.data || !Array.isArray(res.data.items)) return { failed: 'the server answered without an items list' };
144
+ return { data: res.data, items: bySeq(res.data.items) };
145
+ };
146
+
147
+ if (!flags.watch) {
148
+ const got = await fetchInbox();
149
+ if (got.failed) { err(`could not read ${member.name}'s inbox: ${got.failed}`); return FAILED; }
150
+ if (flags.json) { log(JSON.stringify(got.data)); return got.items.length ? NEW : NOTHING; }
151
+ if (!got.items.length) { log(`nothing new for ${member.name}.`); return NOTHING; }
152
+ for (const line of itemLines(got.items, clock())) log(line);
153
+ if (got.items.length >= limit) log(`showing the oldest ${got.items.length}; more are waiting after these are handled.`);
154
+ log(`mark handled: ${again} --ack ${got.items[got.items.length - 1].seq}`);
155
+ return NEW;
156
+ }
157
+
158
+ // --watch: for a person at a terminal. Prints only items it has not shown
159
+ // yet and never marks anything handled. Errors that retrying cannot fix
160
+ // (sign-in, access, no such business) stop it; the rest wait for the next round.
161
+ const sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
162
+ const keepGoing = deps.keepGoing || (() => true);
163
+ // stderr, so --watch --json leaves nothing but items on stdout.
164
+ if (!deps.sleep) process.once('SIGINT', () => { (deps.err || console.error)('stopped listening.'); process.exit(0); });
165
+ if (!flags.json) log(`listening for ${member.name} in ${business.slug}. checks every 30 seconds; Ctrl-C to stop.`);
166
+ let shown = -Infinity;
167
+ let first = true;
168
+ let full = false;
169
+ for (;;) {
170
+ const got = await fetchInbox();
171
+ if (got.failed) {
172
+ err(`could not read ${member.name}'s inbox: ${got.failed}`);
173
+ if (got.res && [401, 403, 404].includes(got.res.status)) return FAILED;
174
+ } else {
175
+ const fresh = got.items.filter((it) => Number(it.seq) > shown);
176
+ if (fresh.length) {
177
+ if (flags.json) for (const it of fresh) log(JSON.stringify(it));
178
+ else for (const line of itemLines(fresh, clock())) log(line);
179
+ shown = Math.max(shown, ...fresh.map((it) => Number(it.seq)));
180
+ if (!flags.json) log(`mark handled: ${again} --ack ${shown}`);
181
+ } else if (first && !flags.json) {
182
+ log(`nothing new for ${member.name} yet.`);
183
+ }
184
+ // The server gives the oldest 50 unhandled; newer ones stay hidden
185
+ // until some are marked handled. Say so once, not every round.
186
+ const nowFull = got.items.length >= SERVER_MAX;
187
+ if (nowFull && !full) (flags.json ? err : log)(`${SERVER_MAX}+ waiting for ${member.name}; newer ones show after these are marked handled: ${again} --ack ${shown}`);
188
+ full = nowFull;
189
+ }
190
+ first = false;
191
+ if (!keepGoing()) return 0;
192
+ await sleep(deps.intervalMs || WATCH_MS);
193
+ }
194
+ }
195
+
196
+ module.exports = { listenCommand };
@@ -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', 'campaign', 'agents', 'probe', 'worktree', 'land', 'caretaker', 'autoland', 'drive', 'aeo', 'slop', 'voice', 'strings', 'write', 'security-review', 'secure', 'sentinel', 'deck', 'site', 'theme', 'card', 'reel', 'improve', 'study', 'rainmaker', 'xp', 'play', 'gm', 'game', 'x', 'recap', 'report', 'signup', 'clarity', 'interview', 'meet', 'moves', 'unknowns', 'avail', 'scoreboard', 'growth', 'scorecard', 'rating', 'sync-checkout', 'zen',
7
- 'ci', 'github', 'vercel', 'supabase', 'linear', 'stripe', 'memory', 'balance', 'film', 'usage', 'spend', 'api-key', 'agent-client', 'topup', 'buy', 'transactions', 'hire', 'hires', 'mail', 'channel', 'design', 'mcp', 'gmail', 'google', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
7
+ 'ci', 'github', 'vercel', 'supabase', 'linear', 'stripe', 'memory', 'balance', 'film', 'usage', 'spend', 'api-key', 'agent-client', 'topup', 'buy', 'transactions', 'hire', 'hires', 'mail', 'channel', 'listen', 'design', 'mcp', 'gmail', 'google', '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', 'link', 'playbook', 'feed', 'brief', 'social', 'host', 'people', 'follow', 'unfollow', 'friends', 'msg', 'inbox', 'invite', 'join', 'rsi', 'pay', 'trust'];
9
9
 
10
10
  // Damerau-Levenshtein edit distance between two short strings. Counts an
@@ -174,11 +174,15 @@ function jobScope(state, key) {
174
174
  return state.machine && state.machine.path ? 'machine' : 'project';
175
175
  }
176
176
 
177
+ // A member belongs to one project, so a member no file names yet goes in that
178
+ // project's own atris/ROSTER.md when it has one; only a project without one
179
+ // falls back to the all-projects roster.
177
180
  function teamScope(state, member) {
178
181
  for (const scope of reg.ROSTER_LAYERS) {
179
182
  const team = state[scope] && Array.isArray(state[scope].team) ? state[scope].team : [];
180
183
  if (team.some((line) => line.member === member)) return scope;
181
184
  }
185
+ if (state.project && state.project.format === 'markdown') return 'project';
182
186
  return state.machine && state.machine.path ? 'machine' : 'project';
183
187
  }
184
188
 
@@ -0,0 +1,97 @@
1
+ 'use strict';
2
+
3
+ // Shared plumbing for the team board on the hosted server (`atris channel`,
4
+ // `atris listen`): which business, the plain reason a request failed, and the
5
+ // member name -> agent id lookup. HTTP client only; routes live in the backend.
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const { oneLine } = require('./developer-api');
10
+ const { stateHome } = require('./state-home');
11
+
12
+ function readJson(file) {
13
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
14
+ }
15
+
16
+ function registeredBusinesses(homeDir) {
17
+ const raw = readJson(path.join(homeDir, '.atris', 'businesses.json'));
18
+ if (!raw || typeof raw !== 'object') return [];
19
+ return Object.entries(raw)
20
+ .filter(([, b]) => b && typeof b === 'object' && b.business_id)
21
+ .map(([key, b]) => ({ id: String(b.business_id), slug: String(b.slug || key), name: String(b.name || b.slug || key) }));
22
+ }
23
+
24
+ function boundBusiness(startDir) {
25
+ let dir = path.resolve(startDir);
26
+ for (;;) {
27
+ const parsed = readJson(path.join(dir, '.atris', 'business.json'));
28
+ if (parsed && parsed.business_id) {
29
+ const slug = String(parsed.slug || parsed.name || parsed.business_id);
30
+ return { id: String(parsed.business_id), slug, name: String(parsed.name || slug) };
31
+ }
32
+ const up = path.dirname(dir);
33
+ if (up === dir) return null;
34
+ dir = up;
35
+ }
36
+ }
37
+
38
+ const PICK = 'add --business <slug>, set ATRIS_BUSINESS=<slug>, or run inside a business folder';
39
+
40
+ // Which business, in order: --business, ATRIS_BUSINESS, the nearest
41
+ // .atris/business.json, then the only one in ~/.atris/businesses.json.
42
+ // A slug is looked up locally; one this computer does not know goes to the
43
+ // server as is. Returns { id, slug, name } or { error }.
44
+ function resolveChannelBusiness(flags = {}, deps = {}) {
45
+ const env = deps.env || process.env;
46
+ const known = registeredBusinesses(deps.homeDir || stateHome());
47
+ const lookup = (value) => known.find((b) => b.slug === value || b.id === value)
48
+ || { id: value, slug: value, name: value };
49
+
50
+ if (Object.prototype.hasOwnProperty.call(flags, 'business')) {
51
+ const given = typeof flags.business === 'string' ? flags.business.trim() : '';
52
+ if (!given) return { error: '--business needs a business slug' };
53
+ return lookup(given);
54
+ }
55
+ const fromEnv = String(env.ATRIS_BUSINESS || '').trim();
56
+ if (fromEnv) return lookup(fromEnv);
57
+ const bound = boundBusiness(deps.cwd || process.cwd());
58
+ if (bound) return bound;
59
+ if (known.length === 1) return known[0];
60
+ if (!known.length) return { error: `no business picked: ${PICK}.` };
61
+ const names = known.map((b) => b.slug);
62
+ const shown = names.length > 8 ? `${names.slice(0, 8).join(', ')}, and ${names.length - 8} more` : names.join(', ');
63
+ return { error: `more than one business on this computer (${shown}): ${PICK}.` };
64
+ }
65
+
66
+ // One plain line on why a board request failed.
67
+ function boardWhy(res, business) {
68
+ if (!res) return 'no answer from the server';
69
+ const detail = res.data && (res.data.detail || res.data.error);
70
+ if (res.status === 401) return 'your sign-in was refused. run: atris login';
71
+ if (res.status === 403) return `you are not on ${business.slug}'s team`;
72
+ if (res.status === 404 && detail === 'Business not found') return `no business called "${business.slug}"`;
73
+ return oneLine(detail || res.error || `request failed (${res.status})`);
74
+ }
75
+
76
+ // The member named by --as, else ATRIS_AGENT: lowercased, no leading @.
77
+ function memberName(flags = {}, env = process.env) {
78
+ return String((typeof flags.as === 'string' ? flags.as : env.ATRIS_AGENT) || '').trim().replace(/^@/, '').toLowerCase();
79
+ }
80
+
81
+ // The server's words for "no such member" ("Agent not found: walsh"). Any
82
+ // other 404, like "Business not found", is a different problem.
83
+ const MEMBER_MISSING = /\b(agent|member) not found\b/i;
84
+
85
+ // Member name -> { member: { id, name } }, or { missing: true }, { failed: res },
86
+ // or { noId: true }. The caller words the error for its own command.
87
+ // `options` adds request settings, like a shorter timeout.
88
+ async function lookupMember(request, business, name, token, options = {}) {
89
+ const res = await request(`/business/${encodeURIComponent(business.id)}/team/${encodeURIComponent(name)}`, { method: 'GET', ...options, token });
90
+ const detail = res && res.data && (res.data.detail || res.data.error);
91
+ if (res && res.status === 404 && MEMBER_MISSING.test(String(detail || ''))) return { missing: true };
92
+ if (!res || !res.ok) return { failed: res };
93
+ if (!res.data || !res.data.agent_id) return { noId: true };
94
+ return { member: { id: String(res.data.agent_id), name: String(res.data.name || name) } };
95
+ }
96
+
97
+ module.exports = { resolveChannelBusiness, boardWhy, memberName, lookupMember };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.82.23",
3
+ "version": "3.82.24",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof. project memory your coding agents share across sessions.",
5
5
  "mcpName": "io.github.atrislabs/atris",
6
6
  "main": "bin/atris.js",