atris 2.6.0 → 2.6.2

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 (46) hide show
  1. package/GETTING_STARTED.md +2 -2
  2. package/atris/GETTING_STARTED.md +2 -2
  3. package/bin/atris.js +35 -4
  4. package/commands/business.js +244 -2
  5. package/commands/context-sync.js +228 -0
  6. package/commands/pull.js +176 -50
  7. package/commands/push.js +154 -61
  8. package/commands/setup.js +178 -0
  9. package/commands/workspace-clean.js +249 -0
  10. package/lib/manifest.js +224 -0
  11. package/lib/section-merge.js +196 -0
  12. package/package.json +9 -4
  13. package/utils/api.js +9 -1
  14. package/utils/update-check.js +11 -11
  15. package/AGENT.md +0 -35
  16. package/atris/experiments/README.md +0 -118
  17. package/atris/experiments/_examples/smoke-keep-revert/README.md +0 -45
  18. package/atris/experiments/_examples/smoke-keep-revert/candidate.py +0 -8
  19. package/atris/experiments/_examples/smoke-keep-revert/loop.py +0 -129
  20. package/atris/experiments/_examples/smoke-keep-revert/measure.py +0 -47
  21. package/atris/experiments/_examples/smoke-keep-revert/program.md +0 -3
  22. package/atris/experiments/_examples/smoke-keep-revert/proposals/bad_patch.py +0 -19
  23. package/atris/experiments/_examples/smoke-keep-revert/proposals/fix_patch.py +0 -22
  24. package/atris/experiments/_examples/smoke-keep-revert/reset.py +0 -21
  25. package/atris/experiments/_examples/smoke-keep-revert/results.tsv +0 -5
  26. package/atris/experiments/_examples/smoke-keep-revert/visual.svg +0 -52
  27. package/atris/experiments/_fixtures/invalid/BadName/loop.py +0 -1
  28. package/atris/experiments/_fixtures/invalid/BadName/program.md +0 -3
  29. package/atris/experiments/_fixtures/invalid/BadName/results.tsv +0 -1
  30. package/atris/experiments/_fixtures/invalid/bloated-context/loop.py +0 -1
  31. package/atris/experiments/_fixtures/invalid/bloated-context/measure.py +0 -1
  32. package/atris/experiments/_fixtures/invalid/bloated-context/program.md +0 -6
  33. package/atris/experiments/_fixtures/invalid/bloated-context/results.tsv +0 -1
  34. package/atris/experiments/_fixtures/valid/good-experiment/loop.py +0 -1
  35. package/atris/experiments/_fixtures/valid/good-experiment/measure.py +0 -1
  36. package/atris/experiments/_fixtures/valid/good-experiment/program.md +0 -3
  37. package/atris/experiments/_fixtures/valid/good-experiment/results.tsv +0 -1
  38. package/atris/experiments/_template/pack/loop.py +0 -3
  39. package/atris/experiments/_template/pack/measure.py +0 -13
  40. package/atris/experiments/_template/pack/program.md +0 -3
  41. package/atris/experiments/_template/pack/reset.py +0 -3
  42. package/atris/experiments/_template/pack/results.tsv +0 -1
  43. package/atris/experiments/benchmark_runtime.py +0 -81
  44. package/atris/experiments/benchmark_validate.py +0 -70
  45. package/atris/experiments/validate.py +0 -92
  46. package/atris/team/navigator/journal/2026-02-23.md +0 -6
@@ -164,8 +164,8 @@ This syncs your local `atris.md` and agent templates to the latest version. Re-r
164
164
  ## Need Help?
165
165
 
166
166
  - **Full spec:** Read `atris.md` for technical details
167
- - **Issues:** https://github.com/atrislabs/atris.md/issues
168
- - **Docs:** https://github.com/atrislabs/atris.md
167
+ - **Issues:** https://github.com/atrislabs/atris/issues
168
+ - **Docs:** https://github.com/atrislabs/atris
169
169
 
170
170
  ---
171
171
 
@@ -164,8 +164,8 @@ This syncs your local `atris.md` and agent templates to the latest version. Re-r
164
164
  ## Need Help?
165
165
 
166
166
  - **Full spec:** Read `atris.md` for technical details
167
- - **Issues:** https://github.com/atrislabs/atris.md/issues
168
- - **Docs:** https://github.com/atrislabs/atris.md
167
+ - **Issues:** https://github.com/atrislabs/atris/issues
168
+ - **Docs:** https://github.com/atrislabs/atris
169
169
 
170
170
  ---
171
171
 
package/bin/atris.js CHANGED
@@ -211,6 +211,7 @@ function showHelp() {
211
211
  console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
212
212
  console.log('');
213
213
  console.log('Setup:');
214
+ console.log(' setup - Guided first-time setup (login, pick business, pull)');
214
215
  console.log(' init - Initialize Atris in current project');
215
216
  console.log(' update - Update local files to latest version');
216
217
  console.log(' upgrade - Install latest Atris from npm');
@@ -246,6 +247,14 @@ function showHelp() {
246
247
  console.log('');
247
248
  console.log('Sync:');
248
249
  console.log(' pull - Pull journals + member data from cloud');
250
+ console.log(' clean-workspace <slug> - Analyze & remove junk files from a workspace (alias: cw)');
251
+ console.log('');
252
+ console.log('Business:');
253
+ console.log(' business add <slug> - Connect a business');
254
+ console.log(' business list - Show connected businesses');
255
+ console.log(' business remove <slug> - Disconnect a business');
256
+ console.log(' business health <slug> - Health report (members, workspace, issues)');
257
+ console.log(' business audit - One-line health summary of all businesses');
249
258
  console.log('');
250
259
  console.log('Cloud & agents:');
251
260
  console.log(' console - Start/attach always-on coding console (tmux daemon)');
@@ -387,7 +396,7 @@ const { planAtris: planCmd, doAtris: doCmd, reviewAtris: reviewCmd } = require('
387
396
  const knownCommands = ['init', 'log', 'status', 'analytics', 'visualize', 'brainstorm', 'autopilot', 'run', 'plan', 'do', 'review',
388
397
  'activate', 'agent', 'chat', 'console', 'login', 'logout', 'whoami', 'switch', 'use', 'accounts', '_resolve', '_profile-email', 'shell-init', 'update', 'upgrade', 'version', 'help', 'next', 'atris',
389
398
  'clean', 'verify', 'search', 'skill', 'member', 'plugin', 'experiments', 'pull', 'push', 'business', 'sync',
390
- 'gmail', 'calendar', 'twitter', 'slack', 'integrations'];
399
+ 'gmail', 'calendar', 'twitter', 'slack', 'integrations', 'setup', 'clean-workspace', 'cw'];
391
400
 
392
401
  // Check if command is an atris.md spec file - triggers welcome visualization
393
402
  function isSpecFile(cmd) {
@@ -698,6 +707,11 @@ if (command === 'init') {
698
707
  console.error(`✗ Log sync failed: ${error.message || error}`);
699
708
  process.exit(1);
700
709
  });
710
+ } else if (subcommand && subcommand !== '--help' && !subcommand.startsWith('-')) {
711
+ // Business log: atris log <business-slug>
712
+ require('../commands/context-sync').businessLog(subcommand)
713
+ .then(() => process.exit(0))
714
+ .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
701
715
  } else {
702
716
  logCmd();
703
717
  }
@@ -876,9 +890,17 @@ if (command === 'init') {
876
890
  process.exit(1);
877
891
  });
878
892
  } else if (command === 'status') {
879
- const isQuick = process.argv.includes('--quick') || process.argv.includes('-q');
880
- const isJson = process.argv.includes('--json');
881
- statusCmd(isQuick, isJson);
893
+ const subcommand = process.argv[3];
894
+ if (subcommand && !subcommand.startsWith('-')) {
895
+ // Business status: atris status <business-slug>
896
+ require('../commands/context-sync').businessStatus(subcommand)
897
+ .then(() => process.exit(0))
898
+ .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
899
+ } else {
900
+ const isQuick = process.argv.includes('--quick') || process.argv.includes('-q');
901
+ const isJson = process.argv.includes('--json');
902
+ statusCmd(isQuick, isJson);
903
+ }
882
904
  } else if (command === 'analytics') {
883
905
  require('../commands/analytics').analyticsAtris();
884
906
  } else if (command === 'clean') {
@@ -945,6 +967,11 @@ if (command === 'init') {
945
967
  require('../commands/business').businessCommand(subcommand, ...args)
946
968
  .then(() => process.exit(0))
947
969
  .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
970
+ } else if (command === 'clean-workspace' || command === 'cw') {
971
+ const { cleanWorkspace } = require('../commands/workspace-clean');
972
+ cleanWorkspace()
973
+ .then(() => process.exit(0))
974
+ .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
948
975
  } else if (command === 'plugin') {
949
976
  const subcommand = process.argv[3] || 'build';
950
977
  const args = process.argv.slice(4);
@@ -953,6 +980,10 @@ if (command === 'init') {
953
980
  const subcommand = process.argv[3];
954
981
  const args = process.argv.slice(4);
955
982
  require('../commands/experiments').experimentsCommand(subcommand, ...args);
983
+ } else if (command === 'setup') {
984
+ require('../commands/setup').setupAtris()
985
+ .then(() => process.exit(0))
986
+ .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
956
987
  } else {
957
988
  console.log(`Unknown command: ${command}`);
958
989
  console.log('Run "atris help" to see available commands');
@@ -111,6 +111,242 @@ async function removeBusiness(slug) {
111
111
  console.log(`\nRemoved "${name}"`);
112
112
  }
113
113
 
114
+ // ---------------------------------------------------------------------------
115
+ // Resolve a slug to a business ID using local cache or API lookup
116
+ // ---------------------------------------------------------------------------
117
+ async function resolveSlug(slug, creds) {
118
+ // Check local cache first
119
+ const businesses = loadBusinesses();
120
+ if (businesses[slug]) {
121
+ return businesses[slug];
122
+ }
123
+
124
+ // Try by-slug endpoint
125
+ const result = await apiRequestJson(`/businesses/by-slug/${slug}/`, {
126
+ method: 'GET',
127
+ token: creds.token,
128
+ });
129
+ if (result.ok && result.data) {
130
+ return { business_id: result.data.id, workspace_id: result.data.workspace_id, name: result.data.name, slug: result.data.slug };
131
+ }
132
+
133
+ // Fallback: list all and match
134
+ const listResult = await apiRequestJson('/businesses/', { method: 'GET', token: creds.token });
135
+ if (listResult.ok && Array.isArray(listResult.data)) {
136
+ const match = listResult.data.find(b => b.slug === slug || b.name.toLowerCase() === slug.toLowerCase());
137
+ if (match) {
138
+ return { business_id: match.id, workspace_id: match.workspace_id, name: match.name, slug: match.slug };
139
+ }
140
+ }
141
+
142
+ return null;
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Helper: format relative time
147
+ // ---------------------------------------------------------------------------
148
+ function relativeTime(dateStr) {
149
+ if (!dateStr) return 'unknown';
150
+ const diff = Date.now() - new Date(dateStr).getTime();
151
+ const days = Math.floor(diff / (1000 * 60 * 60 * 24));
152
+ if (days <= 0) return 'today';
153
+ if (days === 1) return '1d ago';
154
+ return `${days}d ago`;
155
+ }
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // Helper: activity bar
159
+ // ---------------------------------------------------------------------------
160
+ function activityBar(daysSinceActive, width = 10) {
161
+ const filled = Math.max(0, Math.min(width, width - Math.floor(daysSinceActive / 3)));
162
+ return '\u2501'.repeat(filled) + '\u2591'.repeat(width - filled);
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // atris business health <slug>
167
+ // ---------------------------------------------------------------------------
168
+ async function businessHealth(slug) {
169
+ if (!slug) {
170
+ console.error('Usage: atris business health <slug>');
171
+ process.exit(1);
172
+ }
173
+
174
+ const creds = loadCredentials();
175
+ if (!creds || !creds.token) {
176
+ console.error('Not logged in. Run: atris login');
177
+ process.exit(1);
178
+ }
179
+
180
+ const biz = await resolveSlug(slug, creds);
181
+ if (!biz) {
182
+ console.error(`Business "${slug}" not found.`);
183
+ process.exit(1);
184
+ }
185
+
186
+ const bizId = biz.business_id;
187
+ const wsId = biz.workspace_id;
188
+
189
+ // Fetch dashboard and workspace snapshot in parallel
190
+ const fetchOpts = { method: 'GET', token: creds.token, timeoutMs: 120000 };
191
+ const [dashResult, wsResult] = await Promise.all([
192
+ apiRequestJson(`/businesses/${bizId}/dashboard/`, fetchOpts),
193
+ wsId
194
+ ? apiRequestJson(`/businesses/${bizId}/workspaces/${wsId}/snapshot?include_content=false`, fetchOpts)
195
+ : Promise.resolve({ ok: false }),
196
+ ]);
197
+
198
+ const dashboard = dashResult.ok ? dashResult.data : null;
199
+ const workspace = wsResult.ok ? wsResult.data : null;
200
+
201
+ const name = dashboard?.business?.name || biz.name || slug;
202
+
203
+ console.log('');
204
+ console.log(`Business Health: ${name}`);
205
+ console.log('\u2501'.repeat(26 + name.length));
206
+ console.log('');
207
+
208
+ // Workspace stats
209
+ const files = workspace?.files || [];
210
+ const totalSize = files.reduce((sum, f) => sum + (f.size || 0), 0);
211
+ const fileSizeStr = totalSize > 1024 ? `${Math.round(totalSize / 1024)}KB` : `${totalSize}B`;
212
+ console.log(` Workspace: ${files.length} files, ${fileSizeStr}`);
213
+
214
+ // Members
215
+ const members = dashboard?.roster?.members || dashboard?.members || dashboard?.business?.members || [];
216
+ const humanMembers = members.filter(m => !m.is_agent && m.role !== 'agent');
217
+ const agentMembers = members.filter(m => m.is_agent || m.role === 'agent');
218
+ const memberCountStr = members.length > 0
219
+ ? `${members.length} (${humanMembers.length} human, ${agentMembers.length} agent)`
220
+ : `${members.length}`;
221
+ console.log(` Members: ${memberCountStr}`);
222
+
223
+ // Apps
224
+ const apps = dashboard?.business?.apps || dashboard?.apps || [];
225
+ console.log(` Apps: ${Array.isArray(apps) ? apps.length : 0}`);
226
+
227
+ // Status
228
+ const status = dashboard?.business?.status || dashboard?.status || 'unknown';
229
+ console.log(` Status: ${status}`);
230
+
231
+ // Member activity
232
+ if (members.length > 0) {
233
+ console.log('');
234
+ console.log(' Member Activity:');
235
+ for (const m of members) {
236
+ const memberName = m.display_name || m.name || m.email || 'Unknown';
237
+ const role = m.role || 'member';
238
+ const lastActive = m.atris?.last_active || m.last_active || m.last_login || m.joined_at || m.created_at;
239
+ const daysSince = lastActive ? Math.floor((Date.now() - new Date(lastActive).getTime()) / (1000 * 60 * 60 * 24)) : 999;
240
+ const bar = activityBar(daysSince);
241
+ const label = daysSince <= 1 ? 'active' : `last active ${relativeTime(lastActive)}`;
242
+ console.log(` ${memberName.padEnd(18)} ${role.padEnd(8)} ${bar} ${label}`);
243
+ }
244
+ }
245
+
246
+ // Workspace breakdown by directory
247
+ if (files.length > 0) {
248
+ console.log('');
249
+ console.log(' Workspace Breakdown:');
250
+ const dirSizes = {};
251
+ for (const f of files) {
252
+ const filePath = f.path || f.name || '';
253
+ const dir = filePath.includes('/') ? filePath.split('/')[0] + '/' : '/';
254
+ dirSizes[dir] = (dirSizes[dir] || 0) + (f.size || 0);
255
+ }
256
+ const maxDirSize = Math.max(...Object.values(dirSizes), 1);
257
+ const sortedDirs = Object.entries(dirSizes).sort((a, b) => b[1] - a[1]);
258
+ for (const [dir, size] of sortedDirs) {
259
+ const sizeStr = size > 1024 ? `${Math.round(size / 1024)}KB` : `${size}B`;
260
+ const barLen = Math.max(1, Math.round((size / maxDirSize) * 10));
261
+ console.log(` ${dir.padEnd(12)} ${sizeStr.padStart(5)} ${'█'.repeat(barLen)}`);
262
+ }
263
+ }
264
+
265
+ // Issues
266
+ console.log('');
267
+ console.log(' Issues:');
268
+ let hasIssues = false;
269
+ const humanMembers2 = members.filter(m => m.role !== 'agent');
270
+ for (const m of humanMembers2) {
271
+ const lastActive = m.atris?.last_active || m.last_active || m.last_login || m.joined_at || m.created_at;
272
+ const daysSince = lastActive ? Math.floor((Date.now() - new Date(lastActive).getTime()) / (1000 * 60 * 60 * 24)) : 999;
273
+ if (daysSince >= 30) {
274
+ const memberName = m.display_name || m.name || m.email || 'Unknown';
275
+ console.log(` \u26A0 ${memberName} inactive for ${daysSince}+ days`);
276
+ hasIssues = true;
277
+ }
278
+ }
279
+
280
+ // Check for workspace bloat (arbitrary threshold: >500KB or >100 files)
281
+ if (totalSize > 500 * 1024) {
282
+ console.log(` \u26A0 Workspace large (${fileSizeStr})`);
283
+ hasIssues = true;
284
+ }
285
+ if (files.length > 100) {
286
+ console.log(` \u26A0 Workspace has ${files.length} files (consider cleanup)`);
287
+ hasIssues = true;
288
+ }
289
+ if (!hasIssues) {
290
+ console.log(' \u2713 Workspace clean (no bloat detected)');
291
+ }
292
+
293
+ console.log('');
294
+ }
295
+
296
+ // ---------------------------------------------------------------------------
297
+ // atris business audit
298
+ // ---------------------------------------------------------------------------
299
+ async function businessAudit() {
300
+ const creds = loadCredentials();
301
+ if (!creds || !creds.token) {
302
+ console.error('Not logged in. Run: atris login');
303
+ process.exit(1);
304
+ }
305
+
306
+ const listResult = await apiRequestJson('/businesses/', { method: 'GET', token: creds.token });
307
+ if (!listResult.ok || !Array.isArray(listResult.data)) {
308
+ console.error(`Failed to fetch businesses: ${listResult.error || 'unknown error'}`);
309
+ process.exit(1);
310
+ }
311
+
312
+ const businesses = listResult.data;
313
+
314
+ console.log('');
315
+ console.log('Business Audit');
316
+ console.log('\u2501'.repeat(14));
317
+ console.log('');
318
+
319
+ for (const biz of businesses) {
320
+ const name = biz.name || biz.slug || 'Unknown';
321
+ const memberCount = typeof biz.member_count === 'number' ? biz.member_count : (Array.isArray(biz.members) ? biz.members.length : 0);
322
+ const appCount = typeof biz.app_count === 'number' ? biz.app_count : (Array.isArray(biz.apps) ? biz.apps.length : 0);
323
+
324
+ // Determine activity status
325
+ const status = biz.status || 'unknown';
326
+ const isActive = status === 'active' || (memberCount > 1 && appCount > 0);
327
+ const hasContent = memberCount > 1 || appCount > 0;
328
+
329
+ let icon, activityLabel;
330
+ if (isActive) {
331
+ icon = '\u2713';
332
+ activityLabel = appCount > 0 ? 'active' : 'idle';
333
+ } else if (hasContent) {
334
+ icon = '\u26A0';
335
+ activityLabel = 'inactive';
336
+ } else {
337
+ icon = '\u25CB';
338
+ activityLabel = 'inactive';
339
+ }
340
+
341
+ const memberStr = memberCount === 1 ? '1 member' : `${memberCount} members`;
342
+ const appStr = appCount === 1 ? '1 app' : `${appCount} apps`;
343
+
344
+ console.log(` ${icon} ${name.padEnd(16)} ${memberStr.padEnd(12)} ${appStr.padEnd(8)} ${activityLabel}`);
345
+ }
346
+
347
+ console.log('');
348
+ }
349
+
114
350
  async function businessCommand(subcommand, ...args) {
115
351
  switch (subcommand) {
116
352
  case 'add':
@@ -124,9 +360,15 @@ async function businessCommand(subcommand, ...args) {
124
360
  case 'rm':
125
361
  await removeBusiness(args[0]);
126
362
  break;
363
+ case 'health':
364
+ await businessHealth(args[0]);
365
+ break;
366
+ case 'audit':
367
+ await businessAudit();
368
+ break;
127
369
  default:
128
- console.log('Usage: atris business <add|list|remove> [slug]');
370
+ console.log('Usage: atris business <add|list|remove|health|audit> [slug]');
129
371
  }
130
372
  }
131
373
 
132
- module.exports = { businessCommand, loadBusinesses, saveBusinesses, getBusinessConfigPath };
374
+ module.exports = { businessCommand, businessHealth, businessAudit, loadBusinesses, saveBusinesses, getBusinessConfigPath };
@@ -0,0 +1,228 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { loadCredentials } = require('../utils/auth');
4
+ const { apiRequestJson } = require('../utils/api');
5
+ const { loadBusinesses } = require('./business');
6
+ const { loadManifest, computeLocalHashes, threeWayCompare } = require('../lib/manifest');
7
+
8
+ /**
9
+ * Resolve a business slug to its IDs. Shared helper.
10
+ */
11
+ async function resolveBusiness(slug) {
12
+ const creds = loadCredentials();
13
+ if (!creds || !creds.token) {
14
+ console.error('Not logged in. Run: atris login');
15
+ process.exit(1);
16
+ }
17
+
18
+ const businesses = loadBusinesses();
19
+ if (businesses[slug]) {
20
+ return {
21
+ businessId: businesses[slug].business_id,
22
+ workspaceId: businesses[slug].workspace_id,
23
+ name: businesses[slug].name || slug,
24
+ slug: businesses[slug].slug || slug,
25
+ token: creds.token,
26
+ };
27
+ }
28
+
29
+ const listResult = await apiRequestJson('/businesses/', { method: 'GET', token: creds.token });
30
+ if (!listResult.ok) {
31
+ console.error(`Failed to fetch businesses: ${listResult.errorMessage || listResult.status}`);
32
+ process.exit(1);
33
+ }
34
+ const match = (listResult.data || []).find(
35
+ b => b.slug === slug || b.name.toLowerCase() === slug.toLowerCase()
36
+ );
37
+ if (!match) {
38
+ console.error(`Business "${slug}" not found.`);
39
+ process.exit(1);
40
+ }
41
+ return {
42
+ businessId: match.id,
43
+ workspaceId: match.workspace_id,
44
+ name: match.name,
45
+ slug: match.slug,
46
+ token: creds.token,
47
+ };
48
+ }
49
+
50
+
51
+ /**
52
+ * atris status <business-slug>
53
+ * Shows what's different locally vs remote without transferring.
54
+ */
55
+ async function businessStatus(slug) {
56
+ const biz = await resolveBusiness(slug);
57
+
58
+ if (!biz.workspaceId) {
59
+ console.error(`Business "${slug}" has no workspace.`);
60
+ process.exit(1);
61
+ }
62
+
63
+ const manifest = loadManifest(biz.slug);
64
+ const timeSince = manifest ? _timeSince(manifest.last_sync) : null;
65
+
66
+ console.log('');
67
+ console.log(`${biz.name}` + (timeSince ? ` \u2014 last synced ${timeSince}` : ' \u2014 never synced'));
68
+
69
+ // Determine local directory
70
+ const atrisDir = path.join(process.cwd(), 'atris', slug);
71
+ const cwdDir = path.join(process.cwd(), slug);
72
+ let localDir = null;
73
+ if (fs.existsSync(atrisDir)) localDir = atrisDir;
74
+ else if (fs.existsSync(cwdDir)) localDir = cwdDir;
75
+
76
+ // Get remote snapshot with content (needed for reliable hash computation)
77
+ const result = await apiRequestJson(
78
+ `/businesses/${biz.businessId}/workspaces/${biz.workspaceId}/snapshot?include_content=true`,
79
+ { method: 'GET', token: biz.token, timeoutMs: 120000 }
80
+ );
81
+
82
+ if (!result.ok) {
83
+ if (result.status === 409) {
84
+ console.log(' Computer is sleeping. Wake it first.');
85
+ } else {
86
+ console.error(` Failed to get remote state: ${result.errorMessage || result.status}`);
87
+ }
88
+ process.exit(1);
89
+ }
90
+
91
+ const remoteFiles = {};
92
+ const crypto = require('crypto');
93
+ for (const file of (result.data.files || [])) {
94
+ if (file.path && !file.binary && file.content != null) {
95
+ const rawBytes = Buffer.from(file.content, 'utf-8');
96
+ remoteFiles[file.path] = { hash: crypto.createHash('sha256').update(rawBytes).digest('hex'), size: rawBytes.length };
97
+ }
98
+ }
99
+
100
+ const localFiles = localDir ? computeLocalHashes(localDir) : {};
101
+ const diff = threeWayCompare(localFiles, remoteFiles, manifest);
102
+
103
+ console.log('');
104
+
105
+ // You changed
106
+ const youChanged = [...diff.toPush, ...diff.newLocal];
107
+ if (youChanged.length > 0) {
108
+ console.log(' You changed:');
109
+ for (const p of youChanged) {
110
+ const label = diff.newLocal.includes(p) ? '(new)' : '';
111
+ console.log(` ${p.replace(/^\//, '')} ${label}`);
112
+ }
113
+ }
114
+
115
+ // Computer changed
116
+ const computerChanged = [...diff.toPull, ...diff.newRemote];
117
+ if (computerChanged.length > 0) {
118
+ console.log(' Computer changed:');
119
+ for (const p of computerChanged) {
120
+ const label = diff.newRemote.includes(p) ? '(new)' : '';
121
+ console.log(` ${p.replace(/^\//, '')} ${label}`);
122
+ }
123
+ }
124
+
125
+ // Conflicts
126
+ if (diff.conflicts.length > 0) {
127
+ console.log(' Conflicts:');
128
+ for (const p of diff.conflicts) {
129
+ console.log(` ${p.replace(/^\//, '')}`);
130
+ }
131
+ } else if (youChanged.length > 0 || computerChanged.length > 0) {
132
+ console.log(' Conflicts: (none)');
133
+ }
134
+
135
+ // Remote deletions
136
+ if (diff.deletedRemote.length > 0) {
137
+ console.log(' Deleted on computer:');
138
+ for (const p of diff.deletedRemote) {
139
+ console.log(` ${p.replace(/^\//, '')}`);
140
+ }
141
+ }
142
+
143
+ if (youChanged.length === 0 && computerChanged.length === 0 && diff.conflicts.length === 0) {
144
+ if (!localDir) {
145
+ console.log(' No local copy found. Run: atris pull ' + slug);
146
+ } else {
147
+ console.log(' Everything up to date.');
148
+ }
149
+ }
150
+
151
+ console.log('');
152
+ }
153
+
154
+
155
+ /**
156
+ * atris log <business-slug>
157
+ * Shows human-readable commit history.
158
+ */
159
+ async function businessLog(slug) {
160
+ const biz = await resolveBusiness(slug);
161
+
162
+ if (!biz.workspaceId) {
163
+ console.error(`Business "${slug}" has no workspace.`);
164
+ process.exit(1);
165
+ }
166
+
167
+ const limit = 20;
168
+ const pathFilter = process.argv.includes('--path') ? process.argv[process.argv.indexOf('--path') + 1] : null;
169
+
170
+ const params = `limit=${limit}` + (pathFilter ? `&path=${encodeURIComponent(pathFilter)}` : '');
171
+ const result = await apiRequestJson(
172
+ `/businesses/${biz.businessId}/workspaces/${biz.workspaceId}/git/log?${params}`,
173
+ { method: 'GET', token: biz.token }
174
+ );
175
+
176
+ if (!result.ok) {
177
+ if (result.status === 409) {
178
+ console.log('\n Computer is sleeping. Wake it first.\n');
179
+ } else {
180
+ console.error(`\n Failed to get history: ${result.errorMessage || result.status}\n`);
181
+ }
182
+ process.exit(1);
183
+ }
184
+
185
+ const commits = result.data.commits || [];
186
+ if (commits.length === 0) {
187
+ console.log(`\n ${biz.name} \u2014 no history yet.\n`);
188
+ return;
189
+ }
190
+
191
+ console.log(`\n ${biz.name} \u2014 history\n`);
192
+
193
+ for (const commit of commits) {
194
+ const date = _timeSince(commit.date) || commit.date;
195
+ const msg = commit.message || '';
196
+
197
+ // Parse actor from message format "actor/name: message"
198
+ const colonIdx = msg.indexOf(': ');
199
+ let actor = '';
200
+ let description = msg;
201
+ if (colonIdx > 0 && colonIdx < 40) {
202
+ actor = msg.substring(0, colonIdx);
203
+ description = msg.substring(colonIdx + 2);
204
+ }
205
+
206
+ const actorDisplay = actor ? ` ${actor}` : '';
207
+ console.log(` ${date.padEnd(12)} ${actorDisplay}`);
208
+ console.log(` ${description}`);
209
+ console.log('');
210
+ }
211
+ }
212
+
213
+
214
+ function _timeSince(isoString) {
215
+ if (!isoString) return null;
216
+ const diff = Date.now() - new Date(isoString).getTime();
217
+ if (diff < 0) return 'just now';
218
+ const mins = Math.floor(diff / 60000);
219
+ if (mins < 1) return 'just now';
220
+ if (mins < 60) return `${mins}m ago`;
221
+ const hours = Math.floor(mins / 60);
222
+ if (hours < 24) return `${hours}h ago`;
223
+ const days = Math.floor(hours / 24);
224
+ return `${days}d ago`;
225
+ }
226
+
227
+
228
+ module.exports = { businessStatus, businessLog };