atris 3.2.0 → 3.11.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 (55) hide show
  1. package/GETTING_STARTED.md +65 -131
  2. package/README.md +18 -2
  3. package/atris/GETTING_STARTED.md +65 -131
  4. package/atris/PERSONA.md +5 -1
  5. package/atris/atris.md +122 -153
  6. package/atris/skills/aeo/SKILL.md +117 -0
  7. package/atris/skills/atris/SKILL.md +49 -25
  8. package/atris/skills/create-member/SKILL.md +29 -9
  9. package/atris/skills/endgame/SKILL.md +9 -0
  10. package/atris/skills/research-search/SKILL.md +167 -0
  11. package/atris/skills/research-search/arxiv_search.py +157 -0
  12. package/atris/skills/research-search/program.md +48 -0
  13. package/atris/skills/research-search/results.tsv +6 -0
  14. package/atris/skills/research-search/scholar_search.py +154 -0
  15. package/atris/skills/tidy/SKILL.md +36 -21
  16. package/atris/team/_template/MEMBER.md +2 -0
  17. package/atris/team/validator/MEMBER.md +35 -1
  18. package/atris.md +118 -178
  19. package/bin/atris.js +46 -12
  20. package/cli/__pycache__/atris_code.cpython-314.pyc +0 -0
  21. package/cli/__pycache__/runtime_guard.cpython-312.pyc +0 -0
  22. package/cli/__pycache__/runtime_guard.cpython-314.pyc +0 -0
  23. package/cli/atris_code.py +889 -0
  24. package/cli/runtime_guard.py +693 -0
  25. package/commands/align.js +16 -0
  26. package/commands/app.js +316 -0
  27. package/commands/autopilot.js +863 -23
  28. package/commands/brainstorm.js +7 -5
  29. package/commands/business.js +677 -2
  30. package/commands/clean.js +19 -3
  31. package/commands/computer.js +2022 -43
  32. package/commands/context-sync.js +5 -0
  33. package/commands/integrations.js +14 -9
  34. package/commands/lifecycle.js +12 -0
  35. package/commands/plugin.js +24 -0
  36. package/commands/pull.js +86 -11
  37. package/commands/push.js +153 -9
  38. package/commands/serve.js +1 -0
  39. package/commands/sync.js +272 -76
  40. package/commands/verify.js +50 -1
  41. package/commands/wiki.js +27 -2
  42. package/commands/workflow.js +24 -9
  43. package/lib/file-ops.js +13 -1
  44. package/lib/journal.js +23 -0
  45. package/lib/manifest.js +3 -0
  46. package/lib/scorecard.js +42 -4
  47. package/lib/sync-telemetry.js +59 -0
  48. package/lib/todo.js +6 -0
  49. package/lib/wiki.js +150 -6
  50. package/lib/workspace-safety.js +87 -0
  51. package/package.json +2 -1
  52. package/utils/api.js +19 -0
  53. package/utils/auth.js +25 -1
  54. package/utils/config.js +24 -0
  55. package/utils/update-check.js +16 -0
@@ -281,6 +281,11 @@ async function businessLog(slug) {
281
281
  }
282
282
 
283
283
 
284
+ /**
285
+ * Convert ISO timestamp to human-readable relative time (e.g., "5m ago").
286
+ * @param {string} isoString - ISO 8601 timestamp
287
+ * @returns {string|null} Relative time string, or null if no input
288
+ */
284
289
  function _timeSince(isoString) {
285
290
  if (!isoString) return null;
286
291
  const diff = Date.now() - new Date(isoString).getTime();
@@ -10,20 +10,25 @@
10
10
  * atris slack channels - List Slack channels
11
11
  */
12
12
 
13
- const { loadCredentials } = require('../utils/auth');
13
+ const { loadCredentials, ensureValidCredentials } = require('../utils/auth');
14
14
  const { apiRequestJson } = require('../utils/api');
15
15
 
16
- function getAuth() {
17
- const creds = loadCredentials();
16
+ async function getAuth() {
17
+ const ensured = await ensureValidCredentials(apiRequestJson);
18
+ const creds = ensured.error ? null : ensured.credentials;
18
19
  if (!creds || !creds.token) {
19
- console.error('Not logged in. Run: atris login');
20
+ if (ensured.error && ensured.error !== 'not_logged_in') {
21
+ console.error(`Authentication failed: ${ensured.detail || ensured.error}. Run: atris login`);
22
+ } else {
23
+ console.error('Not logged in. Run: atris login');
24
+ }
20
25
  process.exit(1);
21
26
  }
22
27
  return { token: creds.token, email: creds.email || 'unknown' };
23
28
  }
24
29
 
25
30
  async function getAuthToken() {
26
- return getAuth().token;
31
+ return (await getAuth()).token;
27
32
  }
28
33
 
29
34
  // ============================================================================
@@ -31,7 +36,7 @@ async function getAuthToken() {
31
36
  // ============================================================================
32
37
 
33
38
  async function gmailInbox(options = {}) {
34
- const { token, email } = getAuth();
39
+ const { token, email } = await getAuth();
35
40
  const limit = options.limit || 10;
36
41
 
37
42
  console.log('📬 Fetching inbox...\n');
@@ -129,7 +134,7 @@ async function gmailCommand(subcommand, ...args) {
129
134
  // ============================================================================
130
135
 
131
136
  async function calendarToday() {
132
- const { token, email } = getAuth();
137
+ const { token, email } = await getAuth();
133
138
 
134
139
  console.log('📅 Today\'s events:\n');
135
140
 
@@ -224,7 +229,7 @@ async function twitterPost(text) {
224
229
 
225
230
  if (!result.ok) {
226
231
  if (result.status === 400 || result.status === 401) {
227
- const { email } = getAuth();
232
+ const { email } = await getAuth();
228
233
  console.error(`Twitter not connected for ${email}.`);
229
234
  console.error('Connect at: https://atris.ai/dashboard/settings');
230
235
  console.error(`Make sure you're signed in as ${email} on the web.`);
@@ -268,7 +273,7 @@ async function slackChannels() {
268
273
 
269
274
  if (!result.ok) {
270
275
  if (result.status === 400 || result.status === 401) {
271
- const { email } = getAuth();
276
+ const { email } = await getAuth();
272
277
  console.error(`Slack not connected for ${email}.`);
273
278
  console.error('Connect at: https://atris.ai/dashboard/settings');
274
279
  console.error(`Make sure you're signed in as ${email} on the web.`);
@@ -3,6 +3,10 @@ const path = require('path');
3
3
  const { loadCredentials } = require('../utils/auth');
4
4
  const { apiRequestJson } = require('../utils/api');
5
5
 
6
+ /**
7
+ * Resolve business slug from CLI arg or local .atris/business.json.
8
+ * @returns {string|null} The resolved slug, or null if not found.
9
+ */
6
10
  function resolveSlug() {
7
11
  let slug = process.argv[3];
8
12
  if (!slug || slug.startsWith('-')) {
@@ -18,6 +22,10 @@ function resolveSlug() {
18
22
  return slug;
19
23
  }
20
24
 
25
+ /**
26
+ * Pause a workspace to save compute (storage only mode).
27
+ * @returns {Promise<void>}
28
+ */
21
29
  async function sleepAtris() {
22
30
  const slug = resolveSlug();
23
31
 
@@ -45,6 +53,10 @@ async function sleepAtris() {
45
53
  console.log('Compute paused. Storage only — pennies/day.');
46
54
  }
47
55
 
56
+ /**
57
+ * Wake a sleeping workspace so agents resume automatically.
58
+ * @returns {Promise<void>}
59
+ */
48
60
  async function wakeAtris() {
49
61
  const slug = resolveSlug();
50
62
 
@@ -6,6 +6,11 @@ const { findAllSkills, parseFrontmatter } = require('./skill');
6
6
 
7
7
  // --- Recursive Copy Helper ---
8
8
 
9
+ /**
10
+ * Recursively copy a directory, skipping .DS_Store and .git.
11
+ * @param {string} src - Source directory path
12
+ * @param {string} dest - Destination directory path
13
+ */
9
14
  function copyRecursive(src, dest) {
10
15
  fs.mkdirSync(dest, { recursive: true });
11
16
  const entries = fs.readdirSync(src);
@@ -23,6 +28,12 @@ function copyRecursive(src, dest) {
23
28
 
24
29
  // --- Generate plugin.json manifest ---
25
30
 
31
+ /**
32
+ * Generate plugin.json manifest from discovered skills.
33
+ * @param {Array} skills - Array of skill objects
34
+ * @param {string} projectDir - Project root directory
35
+ * @returns {Object} Plugin manifest object
36
+ */
26
37
  function generateManifest(skills, projectDir) {
27
38
  let pkg = {};
28
39
  const pkgPath = path.join(projectDir, 'package.json');
@@ -44,6 +55,10 @@ function generateManifest(skills, projectDir) {
44
55
 
45
56
  // --- Generate /atris-setup command ---
46
57
 
58
+ /**
59
+ * Generate the /atris-setup skill markdown for workspace bootstrapping.
60
+ * @returns {string} Skill markdown content
61
+ */
47
62
  function generateSetupCommand() {
48
63
  return `---
49
64
  description: Set up Atris authentication and connect integrations (Gmail, Calendar, Slack, Notion, Drive)
@@ -113,6 +128,11 @@ Tell the user which integrations are connected and which still need setup. They
113
128
 
114
129
  // --- Generate README.md ---
115
130
 
131
+ /**
132
+ * Generate README.md for the plugin package.
133
+ * @param {Array} skills - Array of discovered skills
134
+ * @returns {string} README markdown content
135
+ */
116
136
  function generateREADME(skills) {
117
137
  const skillList = skills.map(s => {
118
138
  const fm = s.frontmatter || {};
@@ -149,6 +169,10 @@ ${skillList}
149
169
 
150
170
  // --- BUILD subcommand ---
151
171
 
172
+ /**
173
+ * Build the plugin package from atris/skills into dist/.
174
+ * @param {...string} args - CLI arguments
175
+ */
152
176
  function buildPlugin(...args) {
153
177
  const projectDir = process.cwd();
154
178
  const atrisDir = path.join(projectDir, 'atris');
package/commands/pull.js CHANGED
@@ -9,6 +9,8 @@ const { parseJournalSections, mergeSections, reconstructJournal } = require('../
9
9
  const { loadBusinesses } = require('./business');
10
10
  const { loadManifest, saveManifest, computeFileHash, buildManifest, computeLocalHashes, threeWayCompare } = require('../lib/manifest');
11
11
  const { normalizeWikiOnlyPrefix } = require('../lib/wiki');
12
+ const { emitSyncEvent, startTimer } = require('../lib/sync-telemetry');
13
+ const { resolveSafeOutputDir } = require('../lib/workspace-safety');
12
14
 
13
15
  function pruneEmptyParentDirs(filePath, stopDir) {
14
16
  let current = path.dirname(filePath);
@@ -114,6 +116,7 @@ async function pullAtris() {
114
116
 
115
117
 
116
118
  async function pullBusiness(slug) {
119
+ const elapsedMs = startTimer();
117
120
  const creds = loadCredentials();
118
121
  if (!creds || !creds.token) {
119
122
  console.error('Not logged in. Run: atris login');
@@ -161,22 +164,44 @@ async function pullBusiness(slug) {
161
164
  }
162
165
  const timeoutMs = timeoutSec * 1000;
163
166
 
164
- // Determine output directory
167
+ // Determine output directory.
168
+ //
169
+ // We only reuse the current working directory when we can prove it's the
170
+ // correct workspace for THIS business — i.e. it has a `.atris/business.json`
171
+ // whose slug matches `slug`. Any other signal (a stray `atris/` folder, a
172
+ // business.json for a different business, etc.) is NOT enough: pulling
173
+ // atris-labs-1 on top of a pallet workspace would mix two businesses into
174
+ // one directory and write pallet's manifest over atris-labs-1's (or vice
175
+ // versa), causing the next sync to do strange things.
176
+ //
177
+ // Fallback: create a fresh ./{slug}/ subdir. Always safe — even if cwd is
178
+ // $HOME or /tmp, we land in a dedicated subfolder.
165
179
  const intoIdx = process.argv.indexOf('--into');
166
180
  let outputDir;
167
181
  if (intoIdx !== -1 && process.argv[intoIdx + 1]) {
168
182
  outputDir = path.resolve(process.argv[intoIdx + 1]);
169
- } else if (fs.existsSync(path.join(process.cwd(), '.atris', 'business.json'))) {
170
- // Inside a pulled workspace — pull into current dir (no nesting)
171
- outputDir = process.cwd();
172
- } else if (fs.existsSync(path.join(process.cwd(), 'atris')) && fs.statSync(path.join(process.cwd(), 'atris')).isDirectory()) {
173
- // Inside an atris init'd workspace — merge business into current dir
174
- outputDir = process.cwd();
175
183
  } else {
176
- // Default: ./{slug}/ in current directory
177
- outputDir = path.join(process.cwd(), slug);
184
+ const bizFile = path.join(process.cwd(), '.atris', 'business.json');
185
+ let cwdMatchesSlug = false;
186
+ if (fs.existsSync(bizFile)) {
187
+ try {
188
+ const biz = JSON.parse(fs.readFileSync(bizFile, 'utf8'));
189
+ const cwdSlug = biz.slug || biz.name;
190
+ cwdMatchesSlug = cwdSlug === slug;
191
+ } catch {
192
+ // Corrupt business.json — ignore, treat as no match.
193
+ }
194
+ }
195
+ outputDir = cwdMatchesSlug ? process.cwd() : path.join(process.cwd(), slug);
178
196
  }
179
197
 
198
+ // Auto-relocate if the resolved outputDir is dangerous ($HOME, /, /Users,
199
+ // system dirs, reserved home folders). Non-technical users shouldn't have
200
+ // to know about mkdir/cd — atris picks a safe subdir and tells them.
201
+ // Paired with the manifest-scoped sweep below, this makes it impossible
202
+ // for a stray cwd to cause atris to delete user files.
203
+ ({ dir: outputDir } = resolveSafeOutputDir(outputDir, { slug, op: 'pull into' }));
204
+
180
205
  // Resolve business ID — always refresh from API to avoid stale workspace_id
181
206
  let businessId, workspaceId, businessName, resolvedSlug;
182
207
  const businesses = loadBusinesses();
@@ -223,6 +248,11 @@ async function pullBusiness(slug) {
223
248
  process.exit(1);
224
249
  }
225
250
 
251
+ // Telemetry helper — captures wall-clock time including wake.
252
+ let _coldWake = false;
253
+ const emit = (outcome, extras = {}) =>
254
+ emitSyncEvent(creds.token, businessId, workspaceId, 'pull', outcome, elapsedMs(), extras);
255
+
226
256
  // Auto-wake the EC2 computer if --auto-wake is set.
227
257
  // Without this, pull silently serves stale data from agent_files cache when
228
258
  // the computer is asleep — the bug that confused us all night.
@@ -232,6 +262,7 @@ async function pullBusiness(slug) {
232
262
  const computerStatus = statusResult.ok && statusResult.data ? statusResult.data.status : 'unknown';
233
263
  if (computerStatus !== 'running' || !(statusResult.data && statusResult.data.endpoint)) {
234
264
  process.stdout.write(' Waking EC2 computer... ');
265
+ _coldWake = true;
235
266
  await apiRequestJson(`/business/${businessId}/ai-computer/wake`, { method: 'POST', token: creds.token });
236
267
  const wakeStart = Date.now();
237
268
  while (Date.now() - wakeStart < 90000) {
@@ -355,20 +386,30 @@ async function pullBusiness(slug) {
355
386
 
356
387
  if (!result.ok) {
357
388
  const msg = result.errorMessage || result.error || `HTTP ${result.status}`;
389
+ let outcome = 'status_unknown';
358
390
  if (result.status === 0 || (typeof msg === 'string' && msg.toLowerCase().includes('timeout'))) {
359
391
  console.error(`\n Workspace timed out (large workspaces can take 60s+). Try: atris pull ${slug} --timeout=600`);
392
+ outcome = 'hang';
360
393
  } else if (result.status === 502) {
361
394
  console.error(`\n Computer didn't respond in time. It may be waking up or the workspace is large.`);
362
395
  console.error(` Try again in 30s, or use: atris pull ${slug} --only=team/,context/`);
396
+ outcome = 'cold_wake';
363
397
  } else if (result.status === 409) {
364
398
  console.error(`\n Computer is sleeping. Wake it first, then pull again.`);
399
+ outcome = 'cold_wake';
365
400
  } else if (result.status === 403) {
366
401
  console.error(`\n Access denied. You're not a member of "${slug}".`);
402
+ outcome = 'access_denied';
367
403
  } else if (result.status === 404) {
368
404
  console.error(`\n Business "${slug}" not found.`);
405
+ outcome = 'status_unknown';
406
+ } else if (result.status === 429) {
407
+ console.error(`\n Pull failed: ${msg}`);
408
+ outcome = 'rate_limited';
369
409
  } else {
370
410
  console.error(`\n Pull failed: ${msg}`);
371
411
  }
412
+ await emit(outcome, { error_detail: `${result.status}: ${msg}`.slice(0, 500) });
372
413
  process.exit(1);
373
414
  }
374
415
 
@@ -379,7 +420,10 @@ async function pullBusiness(slug) {
379
420
  // mirror sweep so a genuinely-emptied cloud can clear local files. The
380
421
  // sweep itself has a safety guard that refuses to wipe local content
381
422
  // when remote reports empty (the snapshot-glitch case), so this is safe.
382
- if (!force) return;
423
+ if (!force) {
424
+ await emit(_coldWake ? 'cold_wake' : 'success', { files_unchanged: 0 });
425
+ return;
426
+ }
383
427
  } else {
384
428
  console.log(` Processing ${files.length} files...`);
385
429
  }
@@ -543,14 +587,27 @@ async function pullBusiness(slug) {
543
587
  }
544
588
  if (force) {
545
589
  const remotePathSet = new Set(Object.keys(remoteFiles));
590
+
591
+ // SAFETY: only sweep files atris previously wrote (recorded in the
592
+ // manifest). Local-only files that were never on cloud are the user's
593
+ // own — atris didn't put them there, atris doesn't delete them. This
594
+ // makes it impossible for a stray cwd (e.g. $HOME) to cause atris to
595
+ // wipe ~/Library, ~/Downloads, etc.
596
+ //
597
+ // If there's no prior manifest (first pull), there's nothing to sweep —
598
+ // threeWayCompare already handled newRemote/conflicts/newLocal, and we
599
+ // have no basis for claiming ownership of any local-only file.
600
+ const managedPaths = manifest && manifest.files ? Object.keys(manifest.files) : [];
601
+ const sweepCandidates = managedPaths.filter(isInScope).filter((p) => localFiles[p]);
546
602
  const inScopeLocal = Object.keys(localFiles).filter(isInScope);
603
+
547
604
  if (remotePathSet.size === 0 && inScopeLocal.length > 0) {
548
605
  console.log('');
549
606
  console.log(' ⚠ Cloud reported zero files but local has in-scope content. Refusing to sweep.');
550
607
  console.log(' This usually means the snapshot endpoint glitched. Try again,');
551
608
  console.log(' or run `atris align --hard` if you really want to nuke local.');
552
609
  } else {
553
- for (const p of inScopeLocal) {
610
+ for (const p of sweepCandidates) {
554
611
  if (remotePathSet.has(p)) continue;
555
612
  if (SERVER_HIDDEN_BASENAMES.has(basename(p))) continue;
556
613
  const localPath = path.join(outputDir, p.replace(/^\//, ''));
@@ -702,6 +759,24 @@ async function pullBusiness(slug) {
702
759
  }
703
760
  }
704
761
 
762
+ // Telemetry — only count files where content actually came over the wire
763
+ // (smart-pull skips unchanged files and just sends a hash). Counting all
764
+ // remoteFiles here would dwarf the real signal once smart-pull steady-state
765
+ // is normal (< 1% of files transferred per pull).
766
+ let bytesTransferred = 0;
767
+ let filesTransferred = 0;
768
+ for (const filePath of Object.keys(remoteContent)) {
769
+ const f = remoteFiles[filePath];
770
+ if (f && typeof f.size === 'number') bytesTransferred += f.size;
771
+ filesTransferred++;
772
+ }
773
+ const totalRemote = Object.keys(remoteFiles).length;
774
+ await emit(_coldWake ? 'cold_wake' : 'success', {
775
+ files_pushed: filesTransferred,
776
+ files_unchanged: Math.max(0, totalRemote - filesTransferred),
777
+ bytes_transferred: bytesTransferred,
778
+ bytes_changed: bytesTransferred,
779
+ });
705
780
  }
706
781
 
707
782
 
package/commands/push.js CHANGED
@@ -6,9 +6,13 @@ const { apiRequestJson } = require('../utils/api');
6
6
  const { loadBusinesses, saveBusinesses } = require('./business');
7
7
  const { loadManifest, saveManifest, buildManifest, computeLocalHashes } = require('../lib/manifest');
8
8
  const { normalizeWikiOnlyPrefix } = require('../lib/wiki');
9
+ const { emitSyncEvent, startTimer } = require('../lib/sync-telemetry');
10
+ const { assertSafeWorkspaceRoot } = require('../lib/workspace-safety');
9
11
 
10
12
  async function pushAtris() {
13
+ const elapsedMs = startTimer();
11
14
  let slug = process.argv[3];
15
+ let _coldWake = false;
12
16
 
13
17
  // Auto-detect business from .atris/business.json in current dir
14
18
  if (!slug || slug.startsWith('-')) {
@@ -80,6 +84,9 @@ async function pushAtris() {
80
84
 
81
85
  if (!fs.existsSync(sourceDir)) { console.error(`Source not found: ${sourceDir}`); process.exit(1); }
82
86
 
87
+ // Refuse to walk/upload dangerous paths ($HOME, /, /Users, system dirs).
88
+ assertSafeWorkspaceRoot(sourceDir, { slug, op: 'push from' });
89
+
83
90
  // Resolve business — always refresh from API
84
91
  let businessId, workspaceId, businessName, resolvedSlug;
85
92
  const businesses = loadBusinesses();
@@ -105,6 +112,11 @@ async function pushAtris() {
105
112
 
106
113
  if (!workspaceId) { console.error(`Business "${slug}" has no workspace.`); process.exit(1); }
107
114
 
115
+ // Telemetry helper — emits one event with the elapsed wall-clock time.
116
+ // Awaited (not fire-and-forget) because process.exit kills in-flight requests.
117
+ const emit = (outcome, extras = {}) =>
118
+ emitSyncEvent(creds.token, businessId, workspaceId, 'push', outcome, elapsedMs(), extras);
119
+
108
120
  // Auto-wake the EC2 computer if --auto-wake is set, otherwise check status and warn.
109
121
  // Without this, push silently routes to agent_files cache when computer is asleep
110
122
  // (the silent fallback footgun from tonight's debugging).
@@ -114,6 +126,7 @@ async function pushAtris() {
114
126
  const computerStatus = statusResult.ok && statusResult.data ? statusResult.data.status : 'unknown';
115
127
  if (computerStatus !== 'running' || !(statusResult.data && statusResult.data.endpoint)) {
116
128
  process.stdout.write(' Waking EC2 computer... ');
129
+ _coldWake = true;
117
130
  await apiRequestJson(`/business/${businessId}/ai-computer/wake`, { method: 'POST', token: creds.token });
118
131
  const wakeStart = Date.now();
119
132
  while (Date.now() - wakeStart < 90000) {
@@ -204,6 +217,7 @@ async function pushAtris() {
204
217
  console.log('');
205
218
  console.log(' Run `atris pull` first, then push your changes.');
206
219
  console.log(' To override (force-push, may clobber cloud edits): atris push --force');
220
+ await emit('drift', { error_detail: `${driftFiles.length} file(s) drifted` });
207
221
  process.exit(1);
208
222
  }
209
223
  console.log('fresh');
@@ -218,6 +232,7 @@ async function pushAtris() {
218
232
  console.log(' ✗ Could not verify cloud freshness. Refusing to push.');
219
233
  console.log(' The workspace may be unreachable or the snapshot endpoint is broken.');
220
234
  console.log(' To bypass and force-push anyway: atris push --force');
235
+ await emit('status_unknown', { error_detail: `snapshot status ${snapshotResult.status || 'unknown'}` });
221
236
  process.exit(1);
222
237
  }
223
238
  }
@@ -256,6 +271,7 @@ async function pushAtris() {
256
271
 
257
272
  if (filesToPush.length === 0 && deletedPaths.length === 0) {
258
273
  console.log('\n Already up to date.\n');
274
+ await emit('success', { files_unchanged: filteredLocalCount });
259
275
  return;
260
276
  }
261
277
 
@@ -280,13 +296,73 @@ async function pushAtris() {
280
296
  let pushed = 0;
281
297
  let deleted = 0;
282
298
  let skipped = [];
299
+ // Files we sent that the server did not confirm as written/unchanged.
300
+ // The /sync endpoint can silently drop files (role-based filters on the
301
+ // business workspace route, path-rejection inside the warm runner, etc.)
302
+ // and still return HTTP 200. If we don't cross-check per-file results,
303
+ // the CLI prints "Pushed" for files that never actually landed — and
304
+ // the manifest records a hash that makes the next push skip them too,
305
+ // losing them permanently. `failedToLand` collects those casualties so
306
+ // we can warn the user AND keep them out of the manifest update.
307
+ let failedToLand = [];
308
+ const landedPaths = new Set();
283
309
  let result = { ok: true };
284
310
 
311
+ // Server-canonical path format for the /sync endpoint: NO leading slash.
312
+ // The warm runner's _safe_path rejects `/atris/...` with "Absolute path
313
+ // outside workspace" (it only accepts paths under `/workspace/...`). All
314
+ // our internal bookkeeping uses a leading slash (manifest keys, localFiles
315
+ // keys, snapshot response paths), so we strip only at the wire.
316
+ const toWirePath = (p) => (p || '').replace(/^\/+/, '');
317
+ const fromWirePath = (p) => {
318
+ const s = String(p || '');
319
+ return s.startsWith('/') ? s : `/${s}`;
320
+ };
321
+ const wireFiles = (files) => files.map((f) => ({ path: toWirePath(f.path), content: f.content }));
322
+
323
+ // Inspect per-file results from a /sync response. Treat "written" and
324
+ // "unchanged" as success; everything else (including missing-from-results,
325
+ // which is how silent server-side drops look) is a failure.
326
+ const recordSyncResults = (sentFiles, response) => {
327
+ const resultsArr = response && response.data && Array.isArray(response.data.results)
328
+ ? response.data.results
329
+ : null;
330
+ const seen = new Set();
331
+ if (resultsArr) {
332
+ for (const r of resultsArr) {
333
+ if (!r || !r.path) continue;
334
+ const status = String(r.status || '').toLowerCase();
335
+ const canonical = fromWirePath(r.path);
336
+ if (status === 'written' || status === 'unchanged') {
337
+ landedPaths.add(canonical);
338
+ seen.add(canonical);
339
+ } else {
340
+ failedToLand.push({ path: canonical, status: status || 'error', error: r.error || '' });
341
+ seen.add(canonical);
342
+ }
343
+ }
344
+ } else {
345
+ // No results array in response — old server. Best-effort: assume
346
+ // everything sent landed. This preserves existing behavior when
347
+ // talking to a server that doesn't return per-file status.
348
+ for (const f of sentFiles) {
349
+ landedPaths.add(f.path);
350
+ seen.add(f.path);
351
+ }
352
+ }
353
+ // Any file we sent that the server didn't mention = silently dropped.
354
+ for (const f of sentFiles) {
355
+ if (!seen.has(f.path)) {
356
+ failedToLand.push({ path: f.path, status: 'dropped', error: 'server did not confirm write' });
357
+ }
358
+ }
359
+ };
360
+
285
361
  if (filesToPush.length > 0) {
286
- // Push files to server
362
+ // Push files to server (strip leading slash — server requires workspace-relative paths)
287
363
  result = await apiRequestJson(
288
364
  `/business/${businessId}/workspaces/${workspaceId}/sync`,
289
- { method: 'POST', token: creds.token, body: { files: filesToPush }, headers: { 'X-Atris-Actor-Source': 'cli' } }
365
+ { method: 'POST', token: creds.token, body: { files: wireFiles(filesToPush) }, headers: { 'X-Atris-Actor-Source': 'cli' } }
290
366
  );
291
367
 
292
368
  if (!result.ok) {
@@ -298,27 +374,33 @@ async function pushAtris() {
298
374
  if (allowed.length > 0) {
299
375
  const retry = await apiRequestJson(
300
376
  `/business/${businessId}/workspaces/${workspaceId}/sync`,
301
- { method: 'POST', token: creds.token, body: { files: allowed }, headers: { 'X-Atris-Actor-Source': 'cli' } }
377
+ { method: 'POST', token: creds.token, body: { files: wireFiles(allowed) }, headers: { 'X-Atris-Actor-Source': 'cli' } }
302
378
  );
303
379
  if (retry.ok) {
304
- pushed = allowed.length;
380
+ recordSyncResults(allowed, retry);
381
+ pushed = landedPaths.size;
305
382
  } else {
306
383
  console.error(`\n Push failed: ${retry.errorMessage || retry.error || retry.status}`);
384
+ await emit('access_denied', { error_detail: `403 retry failed: ${retry.status}` });
307
385
  process.exit(1);
308
386
  }
309
387
  } else {
310
388
  console.error('\n Access denied: you can only push to your team/ folder.');
389
+ await emit('access_denied');
311
390
  process.exit(1);
312
391
  }
313
392
  } else if (result.status === 409) {
314
393
  console.error('\n Computer is sleeping. Wake it first.');
394
+ await emit('cold_wake', { error_detail: 'computer sleeping (409)' });
315
395
  process.exit(1);
316
396
  } else {
317
397
  console.error(`\n Push failed: ${result.errorMessage || result.error || result.status}`);
398
+ await emit('status_unknown', { error_detail: `sync status ${result.status}` });
318
399
  process.exit(1);
319
400
  }
320
401
  } else {
321
- pushed = filesToPush.length;
402
+ recordSyncResults(filesToPush, result);
403
+ pushed = landedPaths.size;
322
404
  }
323
405
  }
324
406
 
@@ -333,6 +415,7 @@ async function pushAtris() {
333
415
  // - manifest update only counts confirmed-deleted paths
334
416
  const deletedConfirmed = [];
335
417
  const deleteFailed = [];
418
+ let _rateLimitedDeletes = 0;
336
419
  for (let i = 0; i < deletedPaths.length; i++) {
337
420
  const filePath = deletedPaths[i];
338
421
  if (i > 0) {
@@ -345,6 +428,7 @@ async function pushAtris() {
345
428
  );
346
429
  if (deleteResult.status === 429) {
347
430
  // Rate limit — wait 20s, retry once
431
+ _rateLimitedDeletes++;
348
432
  await new Promise((r) => setTimeout(r, 20000));
349
433
  deleteResult = await apiRequestJson(
350
434
  `/business/${businessId}/workspaces/${workspaceId}/file?path=${encodeURIComponent(filePath)}`,
@@ -369,10 +453,15 @@ async function pushAtris() {
369
453
  if (deleteFailed.length > 10) console.log(` ... +${deleteFailed.length - 10} more`);
370
454
  }
371
455
 
372
- // Display results
456
+ // Display results — only for files the server confirmed as landed.
457
+ // A non-technical user seeing "+ atris/ideas/foo.md new file" naturally
458
+ // assumes foo.md is on cloud. So we only print that line when the server
459
+ // actually confirmed it via per-file status. Anything else goes into the
460
+ // loud failure block below.
373
461
  console.log('');
374
462
  for (const f of filesToPush) {
375
463
  if (skipped.includes(f)) continue;
464
+ if (!landedPaths.has(f.path)) continue;
376
465
  const isNew = !baseFiles[f.path];
377
466
  console.log(` ${isNew ? '+' : '\u2191'} ${f.path.replace(/^\//, '')} ${isNew ? 'new file' : 'updated'}`);
378
467
  }
@@ -384,6 +473,26 @@ async function pushAtris() {
384
473
  console.log(` x ${filePath.replace(/^\//, '')} deleted`);
385
474
  }
386
475
 
476
+ // Loud failure block — files the server silently dropped or rejected.
477
+ // These did NOT land on cloud even though the HTTP call returned 200.
478
+ if (failedToLand.length > 0) {
479
+ console.log('');
480
+ console.log(` ⚠ ${failedToLand.length} file(s) did NOT land on cloud (server returned 200 but`);
481
+ console.log(` dropped or rejected these files):`);
482
+ const shown = failedToLand.slice(0, 15);
483
+ for (const f of shown) {
484
+ const detail = f.error ? ` — ${f.error}` : ` (${f.status})`;
485
+ console.log(` ✗ ${f.path.replace(/^\//, '')}${detail}`);
486
+ }
487
+ if (failedToLand.length > shown.length) {
488
+ console.log(` ... +${failedToLand.length - shown.length} more`);
489
+ }
490
+ console.log('');
491
+ console.log(' Common causes: path is outside the workspace (e.g. absolute /Users/... path),');
492
+ console.log(' your role lacks write permission for that folder, or warm runner returned an error.');
493
+ console.log(' These files will appear as drift on your next push so you can retry.');
494
+ }
495
+
387
496
  // Summary
388
497
  console.log('');
389
498
  const parts = [];
@@ -396,16 +505,51 @@ async function pushAtris() {
396
505
 
397
506
  // Update manifest — mark pushed files with their new hash, drop ONLY confirmed deletes.
398
507
  // Failed deletes stay in the manifest so the next push will retry them.
508
+ //
509
+ // CRITICAL: only record manifest entries for files the server confirmed as
510
+ // landed (landedPaths). If we recorded the local hash for a file that the
511
+ // server silently dropped, the next push would compare local==manifest and
512
+ // skip it — the file would never land. Keeping it OUT of the manifest means
513
+ // the next push sees it as new/changed and retries automatically.
399
514
  const updatedFiles = { ...baseFiles };
400
515
  for (const f of filesToPush) {
401
- if (!skipped.includes(f)) {
402
- updatedFiles[f.path] = localFiles[f.path];
403
- }
516
+ if (skipped.includes(f)) continue;
517
+ if (!landedPaths.has(f.path)) continue;
518
+ updatedFiles[f.path] = localFiles[f.path];
404
519
  }
405
520
  for (const filePath of deletedConfirmed) {
406
521
  delete updatedFiles[filePath];
407
522
  }
408
523
  saveManifest(resolvedSlug || slug, buildManifest(updatedFiles, null));
524
+
525
+ // Telemetry — outcome reflects actual run quality, not just exit-code-zero.
526
+ // Partial delete failures or rate-limit retries mean the run was NOT a clean win;
527
+ // labeling them success would poison the RL signal.
528
+ const bytesChanged = filesToPush.reduce((acc, f) => acc + (f.content ? Buffer.byteLength(f.content, 'utf8') : 0), 0);
529
+ let finalOutcome;
530
+ let finalDetail;
531
+ if (failedToLand.length > 0) {
532
+ finalOutcome = 'status_unknown';
533
+ finalDetail = `${failedToLand.length} file(s) silently dropped by server (statuses: ${[...new Set(failedToLand.map(f => f.status))].join(',')})`;
534
+ } else if (deleteFailed.length > 0) {
535
+ finalOutcome = 'status_unknown';
536
+ finalDetail = `${deleteFailed.length} delete(s) failed (statuses: ${[...new Set(deleteFailed.map(f => f.status))].join(',')})`;
537
+ } else if (_rateLimitedDeletes > 0) {
538
+ finalOutcome = 'rate_limited';
539
+ finalDetail = `${_rateLimitedDeletes} delete(s) hit 429 (recovered)`;
540
+ } else if (_coldWake) {
541
+ finalOutcome = 'cold_wake';
542
+ } else {
543
+ finalOutcome = 'success';
544
+ }
545
+ await emit(finalOutcome, {
546
+ files_pushed: pushed,
547
+ files_deleted: deleted,
548
+ files_unchanged: unchangedCount,
549
+ bytes_changed: bytesChanged,
550
+ bytes_transferred: bytesChanged,
551
+ error_detail: finalDetail,
552
+ });
409
553
  }
410
554
 
411
555
  module.exports = { pushAtris };
package/commands/serve.js CHANGED
@@ -354,6 +354,7 @@ async function serveAtris(options = {}) {
354
354
 
355
355
  module.exports = {
356
356
  serveAtris,
357
+ streamSession,
357
358
  // exported for testing
358
359
  safePath,
359
360
  applyOp,