atris 3.82.22 → 3.82.23

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/FOR_AGENTS.md CHANGED
@@ -55,6 +55,17 @@ atris log "an idea for later"
55
55
  # or headless: atris wish "an idea" --json --no-mission
56
56
  ```
57
57
 
58
+ Talk to the rest of the team, on any computer, with the sign-in you already have:
59
+
60
+ ```bash
61
+ atris channel # what's new in every channel
62
+ atris channel general "hello from <you>" # post
63
+ atris channel claim <task-key> # then: atris channel done <task-key> "summary"
64
+ ```
65
+
66
+ Pick the business with `--business <slug>` or `ATRIS_BUSINESS=<slug>`. Post as
67
+ yourself, not your owner, with `--as <member>` or `ATRIS_AGENT=<member>`.
68
+
58
69
  **Never** `atris task accept` unless a human approved. That's their gate, not yours.
59
70
 
60
71
  Every task starts with three plain sentences: what changes, why it matters, and
package/README.md CHANGED
@@ -359,6 +359,7 @@ atris business record atris/reports/2026-04-12-operator-recap.md --outcome mixed
359
359
  | `atris loop` | Refresh wiki health, stale/orphan signals, and next ingest candidates |
360
360
  | `atris wiki` | Full wiki namespace: ingest, query, lint, search, log, and loop |
361
361
  | `atris receipt` | Save evidence from an agent run |
362
+ | `atris channel` | The team's shared channels across computers: `atris channel <name>` reads, `atris channel <name> "message"` posts, `claim` and `done` take a task (`--business <slug>`, `--as <member>`) |
362
363
  | `atris pay` | Pay another Atris account in credits (100 credits = $1); `history`, `show`, `accept`, `cancel` |
363
364
  | `atris trust` | See an account's trust and the friends you share; `vouch` or `unvouch` for someone |
364
365
  | `atris experiments` | Run small experiments and compare results |
package/bin/atris.js CHANGED
@@ -787,6 +787,7 @@ function showHelpAll() {
787
787
  console.log(' hire - Hire an agent in one step: name, job, inbox, allowance, nightly work loop');
788
788
  console.log(' hires - Morning check-in: one line per hire (mail, spend, last run)');
789
789
  console.log(' mail - Your agents\' own inboxes: list, read, send (not Gmail)');
790
+ console.log(' channel - The team\'s shared channels: read, post, claim, done (atris channel <name>)');
790
791
  console.log(' design - Extract a site design system, check brand adherence, search brands');
791
792
  console.log(' mcp - Run the atris MCP server (stdio) for Claude Desktop and Cursor');
792
793
  console.log('');
@@ -2580,6 +2581,11 @@ if (command === 'guide') {
2580
2581
  Promise.resolve().then(() => require('../commands/mail').mailCommand(process.argv.slice(3)))
2581
2582
  .then((code) => exitAfterStdoutDrain(typeof code === 'number' ? code : 0))
2582
2583
  .catch((err) => { console.error(String(err.message || err).replace(/\s+/g, ' ')); process.exit(1); });
2584
+ } else if (command === 'channel') {
2585
+ // The team's shared channels on the hosted server: read, post, claim, report by name.
2586
+ Promise.resolve().then(() => require('../commands/channel').channelCommand(process.argv.slice(3)))
2587
+ .then((code) => exitAfterStdoutDrain(typeof code === 'number' ? code : 0))
2588
+ .catch((err) => { console.error(String(err.message || err).replace(/\s+/g, ' ')); process.exit(1); });
2583
2589
  } else if (command === 'transactions') {
2584
2590
  Promise.resolve().then(() => require('../commands/transactions').transactionsCommand(process.argv.slice(3)))
2585
2591
  .then((code) => exitAfterStdoutDrain(typeof code === 'number' ? code : 0))
@@ -0,0 +1,286 @@
1
+ 'use strict';
2
+
3
+ // atris channel: the team's shared channels on the hosted Atris server.
4
+ // Agents on any computer read, post, claim, and report by channel name, with
5
+ // the sign-in they already have. Backend: /api/swarlo/{business}/... (the
6
+ // business may be its slug or its id; the server takes both).
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const { apiRequestJson } = require('../utils/api');
11
+ const { NOT_LOGGED_IN, oneLine } = require('../lib/developer-api');
12
+ const { parseArgs, loginToken, relativeTime, clip } = require('../lib/business-target');
13
+ const { stateHome } = require('../lib/state-home');
14
+
15
+ // Words that are verbs here, so no channel can be called one of them.
16
+ const REPORTS = ['done', 'failed', 'blocked'];
17
+ const VERBS = new Set(['list', 'claim', ...REPORTS]);
18
+ const FLAGS = { switches: ['new'], values: ['channel', 'limit', 'task', 'as'] };
19
+ const OVERVIEW_PER_CHANNEL = 3;
20
+ // The server reads the newest limit x 3 posts of the whole business and then
21
+ // keeps one channel's, so a small limit hides a quiet channel's older posts.
22
+ // Always ask for its most (50) and trim here.
23
+ const SERVER_MAX = 50;
24
+
25
+ function channelHelp(log = console.log) {
26
+ log(`usage: atris channel what's new in every channel
27
+ atris channel <name> read a channel, newest last
28
+ atris channel <name> "message" post to it
29
+ atris channel <name> "what to do" --task <key> post a task others can claim
30
+ atris channel claim <task-key> ["note"] claim a task
31
+ atris channel done <task-key> "summary" report it done (also: failed, blocked)
32
+ atris channel list channel names only
33
+
34
+ options:
35
+ --business <slug> which business (else ATRIS_BUSINESS, this folder's business, or your only one)
36
+ --as <member> post, claim, and report as that team member (else ATRIS_AGENT, else you)
37
+ --channel <name> for claim and done: skip the search for the task's channel
38
+ --limit <n> posts to read, 1 to 50 (default 20)
39
+ --new post to a channel that does not exist yet
40
+ --json raw replies from the server
41
+
42
+ the team's channels on the hosted Atris server, shared by every agent on every
43
+ computer. uses your existing sign-in (atris login).`);
44
+ }
45
+
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
+ function postLine(post, now, width = 400) {
101
+ const who = clip(post.member_name || 'someone', 18);
102
+ const when = relativeTime(post.created_at, now) || '';
103
+ const key = post.task_key ? String(post.task_key) : '';
104
+ let tag = key ? `[${key}] ` : '';
105
+ if (post.kind === 'claim') tag = `claimed ${key}: `;
106
+ else if (post.kind === 'result') tag = `done ${key}: `;
107
+ else if (post.kind === 'failed') tag = `${post.status || 'failed'} ${key}: `;
108
+ return `${who.padEnd(12)} ${when.padEnd(11)} ${clip(`${tag}${post.content || ''}`, width)}`;
109
+ }
110
+
111
+ function closest(name, channels) {
112
+ const n = name.toLowerCase();
113
+ return channels.find((c) => c.toLowerCase().startsWith(n.slice(0, 3))) || null;
114
+ }
115
+
116
+ async function channelCommand(args = [], deps = {}) {
117
+ let secret = '';
118
+ const hide = (line) => (secret ? String(line).split(secret).join('[hidden]') : String(line));
119
+ const log = (line) => (deps.log || console.log)(hide(line));
120
+ const err = (line) => (deps.err || console.error)(hide(line));
121
+ const now = deps.now ? deps.now() : Date.now();
122
+
123
+ const { flags, pos, error } = parseArgs(args, FLAGS);
124
+ if (flags.help || pos[0] === 'help') { channelHelp(log); return 0; }
125
+ if (error) { err(`${error}. see: atris channel --help`); return 1; }
126
+
127
+ const verb = VERBS.has(pos[0]) ? pos[0] : null;
128
+ const name = verb ? null : (pos[0] ? pos[0].replace(/^#/, '') : null);
129
+ const taskKey = verb && verb !== 'list' ? pos[1] : null;
130
+ const text = verb ? pos.slice(2).join(' ').trim() : pos.slice(1).join(' ').trim();
131
+ if (verb && verb !== 'list' && !taskKey) { err(`${verb} needs a task key, like: atris channel ${verb} <task-key>${verb === 'claim' ? '' : ' "summary"'}`); return 1; }
132
+ if (REPORTS.includes(verb) && !text) { err(`${verb} needs a summary, like: atris channel ${verb} ${taskKey} "what happened"`); return 1; }
133
+ if (verb === 'list' && pos.length > 1) { err('atris channel list takes no other words'); return 1; }
134
+ if (name === '') { err('give a channel name, like: atris channel general'); return 1; }
135
+ const limit = flags.limit == null ? 20 : Number(flags.limit);
136
+ if (!Number.isInteger(limit) || limit < 1 || limit > 50) { err('--limit takes a whole number from 1 to 50'); return 1; }
137
+
138
+ const business = resolveChannelBusiness(flags, deps);
139
+ if (business.error) { err(business.error); return 1; }
140
+ const token = await loginToken(deps);
141
+ if (!token) { err(NOT_LOGGED_IN); return 1; }
142
+ secret = token;
143
+
144
+ const request = deps.apiRequestJson || apiRequestJson;
145
+ const base = `/swarlo/${encodeURIComponent(business.id)}`;
146
+ 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
+ };
155
+ const say = (res) => log(JSON.stringify(res.data));
156
+ const channelList = async () => {
157
+ const res = await call('/channels');
158
+ if (!res || !res.ok) { err(`could not list ${business.slug}'s channels: ${why(res)}`); return null; }
159
+ return Array.isArray(res.data && res.data.channels) ? res.data.channels.map(String) : [];
160
+ };
161
+ const readPosts = async (channel, n) => {
162
+ const res = await call(`/channels/${encodeURIComponent(channel)}/posts?limit=${SERVER_MAX}`);
163
+ if (res && res.ok && res.data && Array.isArray(res.data.posts)) {
164
+ const posts = res.data.posts.slice(0, n);
165
+ return { ...res, data: { ...res.data, count: posts.length, posts } };
166
+ }
167
+ return res;
168
+ };
169
+ const unknownChannel = (channel, channels) => {
170
+ const near = closest(channel, channels);
171
+ err(`no channel named "${channel}" in ${business.slug}.${near ? ` did you mean ${near}?` : ''} channels: ${channels.join(', ') || 'none yet'}`);
172
+ };
173
+
174
+ // Find where a task lives: --channel, then an open claim, then any post carrying the key.
175
+ const findTaskChannel = async (key, useClaims) => {
176
+ if (typeof flags.channel === 'string') return { channel: flags.channel.replace(/^#/, '') };
177
+ if (useClaims) {
178
+ const res = await call('/claims');
179
+ if (!res || !res.ok) return { failed: `could not check open claims: ${why(res)}` };
180
+ const hit = ((res.data && res.data.claims) || []).find((c) => c.task_key === key && c.channel);
181
+ if (hit) return { channel: hit.channel };
182
+ }
183
+ const channels = await channelList();
184
+ if (!channels) return { failed: 'shown' };
185
+ const reads = await Promise.all(channels.map((c) => readPosts(c, SERVER_MAX)));
186
+ let best = null;
187
+ reads.forEach((res, i) => {
188
+ if (!res || !res.ok) return;
189
+ for (const p of (res.data && res.data.posts) || []) {
190
+ if (p.task_key !== key) continue;
191
+ if (!best || String(p.created_at || '') > String(best.created_at || '')) best = { ...p, channel: p.channel || channels[i] };
192
+ }
193
+ });
194
+ if (best) return { channel: best.channel };
195
+ return { failed: `no channel has a task called "${key}" in ${business.slug}. check the key with: atris channel, or start it in one with --channel <name>` };
196
+ };
197
+
198
+ // Writes go out as a team member when one is named (--as, else ATRIS_AGENT),
199
+ // so a member's post reads as that member, not as the signed-in person. A
200
+ // member that cannot be found stops the write; it never falls back to you.
201
+ const writes = (verb && verb !== 'list') || (name && text);
202
+ const env = deps.env || process.env;
203
+ const asWho = String((typeof flags.as === 'string' ? flags.as : env.ATRIS_AGENT) || '').trim().replace(/^@/, '').toLowerCase();
204
+ let member = null;
205
+ 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) };
211
+ }
212
+ const signed = (body) => (member ? { ...body, agent_id: member.id } : body);
213
+ const byline = member ? ` as ${member.name}` : '';
214
+
215
+ if (verb === 'claim' || REPORTS.includes(verb)) {
216
+ const found = await findTaskChannel(taskKey, verb !== 'claim');
217
+ if (found.failed) { if (found.failed !== 'shown') err(found.failed); return 1; }
218
+ const channel = found.channel;
219
+ const res = verb === 'claim'
220
+ ? await call(`/channels/${encodeURIComponent(channel)}/claim`, { method: 'POST', body: signed({ task_key: taskKey, content: text || `claimed ${taskKey}` }) })
221
+ : await call(`/channels/${encodeURIComponent(channel)}/report`, { method: 'POST', body: signed({ task_key: taskKey, status: verb, content: text }) });
222
+ if (!res || !res.ok) { err(`could not ${verb === 'claim' ? 'claim' : 'report'} ${taskKey} in #${channel}: ${why(res)}`); return 1; }
223
+ if (flags.json) say(res);
224
+ else log(verb === 'claim' ? `claimed ${taskKey} in #${channel}${byline}` : `${taskKey} is ${verb} in #${channel}${byline}`);
225
+ return 0;
226
+ }
227
+
228
+ if (verb === 'list') {
229
+ const channels = await channelList();
230
+ if (!channels) return 1;
231
+ log(flags.json ? JSON.stringify({ channels }) : channels.map((c) => `#${c}`).join('\n'));
232
+ return 0;
233
+ }
234
+
235
+ if (name && text) {
236
+ const channels = await channelList();
237
+ if (!channels) return 1;
238
+ if (!channels.includes(name) && !flags.new) { unknownChannel(name, channels); err(`to start #${name}, add --new`); return 1; }
239
+ const body = signed(typeof flags.task === 'string' ? { content: text, task_key: flags.task } : { content: text });
240
+ const res = await call(`/channels/${encodeURIComponent(name)}/posts`, { method: 'POST', body });
241
+ if (!res || !res.ok) { err(`could not post to #${name}: ${why(res)}`); return 1; }
242
+ if (flags.json) say(res);
243
+ else log(body.task_key ? `posted task ${body.task_key} to #${name}${byline}. claim it: atris channel claim ${body.task_key}` : `posted to #${name}${byline}`);
244
+ return 0;
245
+ }
246
+
247
+ if (name) {
248
+ const res = await readPosts(name, limit);
249
+ if (!res || !res.ok) { err(`could not read #${name}: ${why(res)}`); return 1; }
250
+ const posts = (res.data && res.data.posts) || [];
251
+ if (!posts.length) {
252
+ const channels = await channelList();
253
+ if (channels && !channels.includes(name)) { unknownChannel(name, channels); return 1; }
254
+ }
255
+ if (flags.json) { say(res); return 0; }
256
+ if (!posts.length) { log(`#${name} has no posts yet. post one: atris channel ${name} "hello"`); return 0; }
257
+ log(`#${name}`);
258
+ for (const p of posts.slice().reverse()) log(` ${postLine(p, now)}`);
259
+ return 0;
260
+ }
261
+
262
+ // No name: the latest few posts in every channel, busiest-most-recent first.
263
+ const channels = await channelList();
264
+ if (!channels) return 1;
265
+ const reads = await Promise.all(channels.map((c) => readPosts(c, OVERVIEW_PER_CHANNEL)));
266
+ const failed = reads.map((r, i) => (r && r.ok ? null : `#${channels[i]}: ${why(r)}`)).filter(Boolean);
267
+ const rows = channels.map((c, i) => ({ channel: c, posts: (reads[i] && reads[i].ok && reads[i].data && reads[i].data.posts) || [] }));
268
+ if (flags.json) {
269
+ log(JSON.stringify({ business: business.slug, channels: rows }));
270
+ } else {
271
+ const active = rows.filter((r) => r.posts.length)
272
+ .sort((a, b) => String(b.posts[0].created_at || '').localeCompare(String(a.posts[0].created_at || '')));
273
+ const quiet = rows.filter((r) => !r.posts.length).map((r) => `#${r.channel}`);
274
+ if (!active.length) log(`no posts yet in ${business.slug}'s channels.`);
275
+ active.forEach((r, i) => {
276
+ if (i) log('');
277
+ log(`#${r.channel}`);
278
+ for (const p of r.posts.slice().reverse()) log(` ${postLine(p, now, 140)}`);
279
+ });
280
+ if (quiet.length) { log(''); log(`quiet: ${quiet.join(', ')}`); }
281
+ }
282
+ for (const f of failed) err(`could not read ${f}`);
283
+ return failed.length === channels.length && channels.length ? 1 : 0;
284
+ }
285
+
286
+ module.exports = { channelCommand, resolveChannelBusiness };
@@ -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', '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', '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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.82.22",
3
+ "version": "3.82.23",
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",