atris 2.6.3 → 3.0.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 (54) hide show
  1. package/README.md +124 -34
  2. package/atris/CLAUDE.md +5 -1
  3. package/atris/atris.md +4 -0
  4. package/atris/features/README.md +24 -0
  5. package/atris/skills/autopilot/SKILL.md +74 -75
  6. package/atris/skills/endgame/SKILL.md +179 -0
  7. package/atris/skills/flow/SKILL.md +121 -0
  8. package/atris/skills/improve/SKILL.md +84 -0
  9. package/atris/skills/loop/SKILL.md +72 -0
  10. package/atris/skills/wiki/SKILL.md +61 -0
  11. package/atris/team/executor/MEMBER.md +10 -4
  12. package/atris/team/navigator/MEMBER.md +2 -0
  13. package/atris/team/validator/MEMBER.md +8 -5
  14. package/atris.md +33 -0
  15. package/bin/atris.js +210 -41
  16. package/commands/activate.js +28 -2
  17. package/commands/align.js +720 -0
  18. package/commands/auth.js +75 -2
  19. package/commands/autopilot.js +1213 -270
  20. package/commands/browse.js +100 -0
  21. package/commands/business.js +785 -12
  22. package/commands/clean.js +107 -2
  23. package/commands/computer.js +429 -0
  24. package/commands/context-sync.js +78 -8
  25. package/commands/experiments.js +351 -0
  26. package/commands/feedback.js +150 -0
  27. package/commands/fleet.js +395 -0
  28. package/commands/fork.js +127 -0
  29. package/commands/init.js +50 -1
  30. package/commands/learn.js +407 -0
  31. package/commands/lifecycle.js +94 -0
  32. package/commands/loop.js +114 -0
  33. package/commands/publish.js +129 -0
  34. package/commands/pull.js +369 -38
  35. package/commands/push.js +283 -246
  36. package/commands/review.js +149 -0
  37. package/commands/run.js +76 -43
  38. package/commands/serve.js +360 -0
  39. package/commands/setup.js +1 -1
  40. package/commands/soul.js +381 -0
  41. package/commands/status.js +119 -1
  42. package/commands/sync.js +147 -1
  43. package/commands/terminal.js +201 -0
  44. package/commands/wiki.js +376 -0
  45. package/commands/workflow.js +191 -74
  46. package/commands/workspace-clean.js +3 -3
  47. package/lib/endstate.js +259 -0
  48. package/lib/learnings.js +235 -0
  49. package/lib/manifest.js +1 -0
  50. package/lib/todo.js +9 -5
  51. package/lib/wiki.js +578 -0
  52. package/package.json +2 -2
  53. package/utils/api.js +40 -35
  54. package/utils/auth.js +1 -0
package/commands/push.js CHANGED
@@ -1,10 +1,11 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
+ const crypto = require('crypto');
3
4
  const { loadCredentials } = require('../utils/auth');
4
5
  const { apiRequestJson } = require('../utils/api');
5
6
  const { loadBusinesses, saveBusinesses } = require('./business');
6
- const { loadManifest, saveManifest, computeFileHash, buildManifest, computeLocalHashes, threeWayCompare, SKIP_DIRS } = require('../lib/manifest');
7
- const { sectionMerge } = require('../lib/section-merge');
7
+ const { loadManifest, saveManifest, buildManifest, computeLocalHashes } = require('../lib/manifest');
8
+ const { normalizeWikiOnlyPrefix } = require('../lib/wiki');
8
9
 
9
10
  async function pushAtris() {
10
11
  let slug = process.argv[3];
@@ -18,55 +19,45 @@ async function pushAtris() {
18
19
  slug = biz.slug || biz.name;
19
20
  } catch {}
20
21
  }
21
- // If still no slug (no .atris/business.json), need explicit name
22
- if (!slug || slug.startsWith('-')) {
23
- slug = null;
24
- }
22
+ if (!slug || slug.startsWith('-')) slug = null;
25
23
  }
26
24
 
27
25
  if (!slug || slug === '--help') {
28
- console.log('Usage: atris push [business-slug] [--from <path>] [--force]');
29
- console.log('');
30
- console.log('Push local files to a Business Computer.');
31
- console.log('If run inside a pulled folder, business is auto-detected.');
26
+ console.log('Usage: atris push [business] [--from <path>] [--only <prefix>] [--force]');
32
27
  console.log('');
33
- console.log('Options:');
34
- console.log(' --from <path> Push from a custom directory');
35
- console.log(' --force Push everything, overwrite conflicts');
28
+ console.log(' Push requires a fresh pull. If cloud has changed since your last pull,');
29
+ console.log(' the push will be blocked until you run `atris pull`. Use --force to override.');
36
30
  console.log('');
37
- console.log('Examples:');
38
- console.log(' atris push Auto-detect from current folder');
39
- console.log(' atris push pallet Push from atris/pallet/ or ./pallet/');
40
- console.log(' atris push pallet --from ./my-dir/ Push from a custom directory');
31
+ console.log(' atris push Push from current folder (auto-detect business)');
32
+ console.log(' atris push pallet Push pallet/ or atris/pallet/');
33
+ console.log(' atris push pallet --only team/nate Only push files in team/nate/');
34
+ console.log(' atris push --force Bypass freshness check (force-push, may overwrite cloud changes)');
41
35
  process.exit(0);
42
36
  }
43
37
 
44
38
  const force = process.argv.includes('--force');
39
+ const dryRun = process.argv.includes('--dry-run');
45
40
 
46
- // Parse --only flag: filter which files to push
41
+ // Parse --only
47
42
  let onlyRaw = null;
48
- const onlyEqArg = process.argv.find(a => a.startsWith('--only='));
49
- if (onlyEqArg) {
50
- onlyRaw = onlyEqArg.slice('--only='.length);
51
- } else {
52
- const onlyIdx = process.argv.indexOf('--only');
53
- if (onlyIdx !== -1 && process.argv[onlyIdx + 1] && !process.argv[onlyIdx + 1].startsWith('-')) {
54
- onlyRaw = process.argv[onlyIdx + 1];
55
- }
43
+ const onlyEq = process.argv.find(a => a.startsWith('--only='));
44
+ if (onlyEq) onlyRaw = onlyEq.slice(7);
45
+ else {
46
+ const oi = process.argv.indexOf('--only');
47
+ if (oi !== -1 && process.argv[oi + 1] && !process.argv[oi + 1].startsWith('-')) onlyRaw = process.argv[oi + 1];
56
48
  }
57
49
  const onlyPrefixes = onlyRaw
58
50
  ? onlyRaw.split(',').map(p => {
59
- let norm = p.replace(/^\//, '');
60
- if (norm && !norm.endsWith('/') && !norm.includes('.')) norm += '/';
61
- return '/' + norm;
62
- }).filter(Boolean)
51
+ const wikiPrefix = normalizeWikiOnlyPrefix(p);
52
+ if (wikiPrefix) return `/${wikiPrefix.replace(/^\//, '')}`;
53
+ let n = '/' + p.replace(/^\//, '');
54
+ if (!n.endsWith('/') && !n.includes('.')) n += '/';
55
+ return n;
56
+ })
63
57
  : null;
64
58
 
65
59
  const creds = loadCredentials();
66
- if (!creds || !creds.token) {
67
- console.error('Not logged in. Run: atris login');
68
- process.exit(1);
69
- }
60
+ if (!creds || !creds.token) { console.error('Not logged in. Run: atris login'); process.exit(1); }
70
61
 
71
62
  // Determine source directory
72
63
  const fromIdx = process.argv.indexOf('--from');
@@ -74,74 +65,71 @@ async function pushAtris() {
74
65
  if (fromIdx !== -1 && process.argv[fromIdx + 1]) {
75
66
  sourceDir = path.resolve(process.argv[fromIdx + 1]);
76
67
  } else if (fs.existsSync(path.join(process.cwd(), '.atris', 'business.json'))) {
77
- // Inside a pulled folder — push from here
78
68
  sourceDir = process.cwd();
79
69
  } else {
80
70
  const atrisDir = path.join(process.cwd(), 'atris', slug);
81
71
  const cwdDir = path.join(process.cwd(), slug);
82
- if (fs.existsSync(atrisDir)) {
83
- sourceDir = atrisDir;
84
- } else if (fs.existsSync(cwdDir)) {
85
- sourceDir = cwdDir;
86
- } else {
72
+ if (fs.existsSync(atrisDir)) sourceDir = atrisDir;
73
+ else if (fs.existsSync(cwdDir)) sourceDir = cwdDir;
74
+ else {
87
75
  console.error(`No local folder found for "${slug}".`);
88
- console.error(`Expected: atris/${slug}/ or ./${slug}/`);
89
- console.error('Or specify: atris push pallet --from ./path/to/folder');
76
+ console.error('Run from inside a pulled folder, or: atris push pallet --from ./path');
90
77
  process.exit(1);
91
78
  }
92
79
  }
93
80
 
94
- if (!fs.existsSync(sourceDir)) {
95
- console.error(`Source directory not found: ${sourceDir}`);
96
- process.exit(1);
97
- }
81
+ if (!fs.existsSync(sourceDir)) { console.error(`Source not found: ${sourceDir}`); process.exit(1); }
98
82
 
99
- // Resolve business ID
83
+ // Resolve business — always refresh from API
100
84
  let businessId, workspaceId, businessName, resolvedSlug;
101
85
  const businesses = loadBusinesses();
102
-
103
- if (businesses[slug]) {
104
- businessId = businesses[slug].business_id;
105
- workspaceId = businesses[slug].workspace_id;
106
- businessName = businesses[slug].name || slug;
107
- resolvedSlug = businesses[slug].slug || slug;
108
- } else {
109
- const listResult = await apiRequestJson('/businesses/', { method: 'GET', token: creds.token });
110
- if (!listResult.ok) {
111
- console.error(`Failed to fetch businesses: ${listResult.errorMessage || listResult.status}`);
112
- process.exit(1);
113
- }
114
- const match = (listResult.data || []).find(
115
- b => b.slug === slug || b.name.toLowerCase() === slug.toLowerCase()
116
- );
117
- if (!match) {
118
- console.error(`Business "${slug}" not found.`);
119
- process.exit(1);
120
- }
86
+ const listResult = await apiRequestJson('/business/', { method: 'GET', token: creds.token });
87
+ if (listResult.ok) {
88
+ const match = (listResult.data || []).find(b => b.slug === slug || b.name.toLowerCase() === slug.toLowerCase());
89
+ if (!match) { console.error(`Business "${slug}" not found.`); process.exit(1); }
121
90
  businessId = match.id;
122
91
  workspaceId = match.workspace_id;
123
92
  businessName = match.name;
124
93
  resolvedSlug = match.slug;
125
-
126
- businesses[slug] = {
127
- business_id: businessId,
128
- workspace_id: workspaceId,
129
- name: businessName,
130
- slug: match.slug,
131
- added_at: new Date().toISOString(),
132
- };
94
+ businesses[slug] = { business_id: businessId, workspace_id: workspaceId, name: businessName, slug: match.slug, added_at: new Date().toISOString() };
133
95
  saveBusinesses(businesses);
96
+ } else if (businesses[slug]) {
97
+ businessId = businesses[slug].business_id;
98
+ workspaceId = businesses[slug].workspace_id;
99
+ businessName = businesses[slug].name || slug;
100
+ resolvedSlug = businesses[slug].slug || slug;
101
+ } else {
102
+ console.error(`Failed to reach API and no cached business for "${slug}".`);
103
+ process.exit(1);
134
104
  }
135
105
 
136
- if (!workspaceId) {
137
- console.error(`Business "${slug}" has no workspace.`);
138
- process.exit(1);
106
+ if (!workspaceId) { console.error(`Business "${slug}" has no workspace.`); process.exit(1); }
107
+
108
+ // Auto-wake the EC2 computer if --auto-wake is set, otherwise check status and warn.
109
+ // Without this, push silently routes to agent_files cache when computer is asleep
110
+ // (the silent fallback footgun from tonight's debugging).
111
+ const autoWake = process.argv.includes('--auto-wake');
112
+ if (autoWake) {
113
+ const statusResult = await apiRequestJson(`/business/${businessId}/ai-computer/status`, { method: 'GET', token: creds.token });
114
+ const computerStatus = statusResult.ok && statusResult.data ? statusResult.data.status : 'unknown';
115
+ if (computerStatus !== 'running' || !(statusResult.data && statusResult.data.endpoint)) {
116
+ process.stdout.write(' Waking EC2 computer... ');
117
+ await apiRequestJson(`/business/${businessId}/ai-computer/wake`, { method: 'POST', token: creds.token });
118
+ const wakeStart = Date.now();
119
+ while (Date.now() - wakeStart < 90000) {
120
+ await new Promise((r) => setTimeout(r, 3000));
121
+ const s = await apiRequestJson(`/business/${businessId}/ai-computer/status`, { method: 'GET', token: creds.token });
122
+ if (s.ok && s.data && s.data.status === 'running' && s.data.endpoint) {
123
+ const elapsed = Math.floor((Date.now() - wakeStart) / 1000);
124
+ console.log(`awake (${elapsed}s)`);
125
+ break;
126
+ }
127
+ }
128
+ }
139
129
  }
140
130
 
141
- // Load manifest (last sync state)
131
+ // Load manifest and compute local hashes
142
132
  const manifest = loadManifest(resolvedSlug || slug);
143
-
144
- // Compute local file hashes
145
133
  const localFiles = computeLocalHashes(sourceDir);
146
134
 
147
135
  if (Object.keys(localFiles).length === 0) {
@@ -149,226 +137,275 @@ async function pushAtris() {
149
137
  return;
150
138
  }
151
139
 
152
- // Get remote snapshot for three-way compare
153
140
  console.log('');
154
141
  console.log(`Pushing to ${businessName}...`);
155
142
 
156
- // Loading indicator
157
- const startTime = Date.now();
158
- const spinner = ['|', '/', '-', '\\'];
159
- let spinIdx = 0;
160
- const loading = setInterval(() => {
161
- const elapsed = Math.floor((Date.now() - startTime) / 1000);
162
- process.stdout.write(`\r Comparing with remote... ${spinner[spinIdx++ % 4]} ${elapsed}s`);
163
- }, 250);
164
-
165
- const snapshotResult = await apiRequestJson(
166
- `/businesses/${businessId}/workspaces/${workspaceId}/snapshot?include_content=true`,
167
- { method: 'GET', token: creds.token, timeoutMs: 300000 }
168
- );
169
-
170
- clearInterval(loading);
171
- const totalSec = Math.floor((Date.now() - startTime) / 1000);
172
- process.stdout.write(`\r Compared in ${totalSec}s.${' '.repeat(20)}\n`);
173
-
174
- let remoteFiles = {};
175
- const remoteContent = {}; // for section merge
176
- if (snapshotResult.ok && snapshotResult.data && snapshotResult.data.files) {
177
- for (const file of snapshotResult.data.files) {
178
- if (file.path && !file.binary && file.content != null) {
179
- const rawBytes = Buffer.from(file.content, 'utf-8');
180
- const hash = require('crypto').createHash('sha256').update(rawBytes).digest('hex');
181
- remoteFiles[file.path] = { hash, size: rawBytes.length };
182
- remoteContent[file.path] = file.content;
143
+ // ───────────────────────────────────────────────────────────────────
144
+ // FRESHNESS CHECK — pull-before-push enforcement.
145
+ // ───────────────────────────────────────────────────────────────────
146
+ // Compare cloud's current state to our local manifest. If cloud has any
147
+ // file the manifest doesn't know about, OR a file with a different hash
148
+ // than what we last pulled, the user is out of date and MUST pull first.
149
+ // This prevents stale local state from clobbering fresh cloud changes —
150
+ // the "lagging version push" footgun. Use --force to bypass (e.g., for
151
+ // genuine local-canonical pushes like align --hard).
152
+ if (!force) {
153
+ process.stdout.write(' Checking cloud freshness... ');
154
+ const snapshotResult = await apiRequestJson(
155
+ `/business/${businessId}/workspaces/${workspaceId}/snapshot?include_content=false`,
156
+ { method: 'GET', token: creds.token, timeoutMs: 60000 }
157
+ );
158
+ if (snapshotResult.ok && snapshotResult.data && Array.isArray(snapshotResult.data.files)) {
159
+ const cloudHashes = {};
160
+ for (const f of snapshotResult.data.files) {
161
+ if (f.path && f.hash) cloudHashes[f.path] = f.hash;
162
+ }
163
+ const manifestFiles = (manifest && manifest.files) || {};
164
+ const driftFiles = [];
165
+ // Direction 1: cloud has files the manifest doesn't know about, OR
166
+ // cloud's hash differs from what we last pulled (someone changed it).
167
+ for (const [p, hash] of Object.entries(cloudHashes)) {
168
+ // Apply --only filter to drift detection too: if user is scoping the
169
+ // push to a subtree, only block on drift inside that subtree.
170
+ if (onlyPrefixes && !onlyPrefixes.some((pref) => p.startsWith(pref))) continue;
171
+ const manifestEntry = manifestFiles[p];
172
+ if (!manifestEntry || manifestEntry.hash !== hash) {
173
+ driftFiles.push(p);
174
+ }
175
+ }
176
+ // Direction 2: manifest has files the cloud no longer has (someone
177
+ // deleted them). Without this check, we'd silently re-push deleted
178
+ // files on the next push, undoing the deletion.
179
+ //
180
+ // CAVEAT: the warm runner's snapshot endpoint deliberately hides certain
181
+ // basenames (CLAUDE.md, .* dotfiles, node_modules, __pycache__, .git) —
182
+ // see ecs_warm_runner.py _snapshot_dir. They CAN exist on cloud but
183
+ // never appear in cloudHashes. Skip them in the missing-side check or
184
+ // every CLAUDE.md push will be flagged as drift forever.
185
+ const SERVER_HIDDEN_BASENAMES = new Set(['CLAUDE.md']);
186
+ const cloudPathSet = new Set(Object.keys(cloudHashes));
187
+ for (const p of Object.keys(manifestFiles)) {
188
+ if (onlyPrefixes && !onlyPrefixes.some((pref) => p.startsWith(pref))) continue;
189
+ const idx = p.lastIndexOf('/');
190
+ const base = idx === -1 ? p : p.slice(idx + 1);
191
+ if (SERVER_HIDDEN_BASENAMES.has(base)) continue;
192
+ if (!cloudPathSet.has(p)) {
193
+ driftFiles.push(p);
194
+ }
195
+ }
196
+ if (driftFiles.length > 0) {
197
+ console.log(`drift detected (${driftFiles.length} file${driftFiles.length === 1 ? '' : 's'})`);
198
+ console.log('');
199
+ console.log(` ✗ Cloud has changed since your last pull. Refusing to push stale state.`);
200
+ console.log('');
201
+ console.log(' Files that differ on cloud:');
202
+ driftFiles.slice(0, 8).forEach((p) => console.log(` ~ ${p.replace(/^\//, '')}`));
203
+ if (driftFiles.length > 8) console.log(` ... +${driftFiles.length - 8} more`);
204
+ console.log('');
205
+ console.log(' Run `atris pull` first, then push your changes.');
206
+ console.log(' To override (force-push, may clobber cloud edits): atris push --force');
207
+ process.exit(1);
183
208
  }
209
+ console.log('fresh');
210
+ } else {
211
+ // Snapshot fetch failed — fail closed. The whole point of the freshness
212
+ // check is to prevent accidental stale pushes; if we can't verify cloud
213
+ // state, we don't push. Use --force to bypass when you know what you're
214
+ // doing (e.g., the workspace is genuinely unhealthy and you have a clean
215
+ // local copy you need to recover from).
216
+ console.log(`failed (status ${snapshotResult.status || 'unknown'})`);
217
+ console.log('');
218
+ console.log(' ✗ Could not verify cloud freshness. Refusing to push.');
219
+ console.log(' The workspace may be unreachable or the snapshot endpoint is broken.');
220
+ console.log(' To bypass and force-push anyway: atris push --force');
221
+ process.exit(1);
184
222
  }
185
223
  }
186
224
 
187
- // Three-way compare
188
- const diff = threeWayCompare(localFiles, remoteFiles, manifest);
189
-
190
- // Check if user is a member (not owner) — if so, filter to allowed paths
191
- // Members can only push to /team/{name}/ and /journal/
192
- let skippedPermission = [];
193
- const role = snapshotResult.data?._role; // not available from snapshot, so we try the push and handle 403
194
-
195
- // Determine what to push
225
+ // Compare local hashes to manifest — NO server call needed
226
+ // Files where local hash differs from manifest = changed locally
227
+ const baseFiles = (manifest && manifest.files) ? manifest.files : {};
196
228
  const filesToPush = [];
197
-
198
- // Apply --only filter
199
- const matchesOnly = (filePath) => {
200
- if (!onlyPrefixes) return true;
201
- return onlyPrefixes.some(prefix => filePath.startsWith(prefix));
202
- };
203
-
204
- // Files we changed that remote didn't
205
- for (const p of [...diff.toPush, ...diff.newLocal]) {
206
- if (!matchesOnly(p)) continue;
207
- const localPath = path.join(sourceDir, p.replace(/^\//, ''));
208
- try {
209
- const content = fs.readFileSync(localPath, 'utf8');
210
- filesToPush.push({ path: p, content });
211
- } catch {
212
- // skip
229
+ const deletedPaths = [];
230
+
231
+ for (const [filePath, fileInfo] of Object.entries(localFiles)) {
232
+ if (onlyPrefixes && !onlyPrefixes.some(p => filePath.startsWith(p))) continue;
233
+ const baseHash = baseFiles[filePath] ? baseFiles[filePath].hash : null;
234
+ if (!baseHash || fileInfo.hash !== baseHash) {
235
+ // Changed or new — push it
236
+ const localPath = path.join(sourceDir, filePath.replace(/^\//, ''));
237
+ try {
238
+ const content = fs.readFileSync(localPath, 'utf8');
239
+ filesToPush.push({ path: filePath, content });
240
+ } catch {}
213
241
  }
214
242
  }
215
243
 
216
- // Handle conflicts: try section-level merge first, then force, then flag
217
- const conflictPaths = [];
218
- const mergedPaths = [];
219
- for (const p of diff.conflicts) {
220
- const localPath = path.join(sourceDir, p.replace(/^\//, ''));
221
- let localContent;
222
- try { localContent = fs.readFileSync(localPath, 'utf8'); } catch { continue; }
223
-
224
- if (force) {
225
- filesToPush.push({ path: p, content: localContent });
226
- continue;
244
+ for (const filePath of Object.keys(baseFiles)) {
245
+ if (onlyPrefixes && !onlyPrefixes.some(p => filePath.startsWith(p))) continue;
246
+ if (!localFiles[filePath]) {
247
+ deletedPaths.push(filePath);
227
248
  }
249
+ }
228
250
 
229
- // Try section-level merge (only for .md files)
230
- if (p.endsWith('.md') && remoteContent[p] && manifest && manifest.files && manifest.files[p]) {
231
- // Get base content: we need what the file looked like at last sync.
232
- // We don't store content in manifest, so use remote as best-effort base
233
- // when manifest hash matches neither side (true conflict).
234
- // For now, attempt merge with remote content and see if sections differ.
235
- const remote = remoteContent[p];
236
- // Simple heuristic: if one side only added content (appended sections), merge works
237
- const result = sectionMerge(remote, localContent, remote);
238
- // A better merge needs the base version. For now, try local-as-changed vs remote-as-base:
239
- const mergeResult = sectionMerge(remote, localContent, remote);
240
- if (mergeResult.merged && mergeResult.conflicts.length === 0 && mergeResult.merged !== remote) {
241
- filesToPush.push({ path: p, content: mergeResult.merged });
242
- mergedPaths.push(p);
243
- continue;
244
- }
245
- }
251
+ const filteredLocalCount = Object.keys(localFiles).filter(filePath => {
252
+ if (!onlyPrefixes) return true;
253
+ return onlyPrefixes.some(prefix => filePath.startsWith(prefix));
254
+ }).length;
255
+ const unchangedCount = Math.max(0, filteredLocalCount - filesToPush.length);
246
256
 
247
- conflictPaths.push(p);
257
+ if (filesToPush.length === 0 && deletedPaths.length === 0) {
258
+ console.log('\n Already up to date.\n');
259
+ return;
248
260
  }
249
261
 
250
- console.log('');
251
-
252
- if (filesToPush.length === 0 && conflictPaths.length === 0) {
253
- console.log(' Already up to date.');
262
+ // Dry run — show what would be pushed without pushing
263
+ if (dryRun) {
254
264
  console.log('');
265
+ for (const f of filesToPush) {
266
+ const isNew = !baseFiles[f.path];
267
+ console.log(` ${isNew ? '+' : '\u2191'} ${f.path.replace(/^\//, '')} ${isNew ? 'new file' : 'updated'} (dry run)`);
268
+ }
269
+ for (const filePath of deletedPaths) {
270
+ console.log(` x ${filePath.replace(/^\//, '')} deleted (dry run)`);
271
+ }
272
+ const parts = [];
273
+ if (filesToPush.length > 0) parts.push(`${filesToPush.length} would be pushed`);
274
+ if (deletedPaths.length > 0) parts.push(`${deletedPaths.length} would be deleted`);
275
+ if (unchangedCount > 0) parts.push(`${unchangedCount} unchanged`);
276
+ console.log(`\n ${parts.join(', ')}. (--dry-run, nothing sent)\n`);
255
277
  return;
256
278
  }
257
279
 
258
- // Push the files
259
280
  let pushed = 0;
281
+ let deleted = 0;
282
+ let skipped = [];
283
+ let result = { ok: true };
284
+
260
285
  if (filesToPush.length > 0) {
261
- const result = await apiRequestJson(
262
- `/businesses/${businessId}/workspaces/${workspaceId}/sync`,
263
- {
264
- method: 'POST',
265
- token: creds.token,
266
- body: { files: filesToPush },
267
- headers: { 'X-Atris-Actor-Source': 'cli' },
268
- }
286
+ // Push files to server
287
+ result = await apiRequestJson(
288
+ `/business/${businessId}/workspaces/${workspaceId}/sync`,
289
+ { method: 'POST', token: creds.token, body: { files: filesToPush }, headers: { 'X-Atris-Actor-Source': 'cli' } }
269
290
  );
270
291
 
271
292
  if (!result.ok) {
272
- const msg = result.errorMessage || result.error || `HTTP ${result.status}`;
273
- if (result.status === 409) {
274
- console.error(` Computer is sleeping. Wake it first, then push.`);
275
- } else if (result.status === 403) {
276
- // Member scoping — retry with only team/ and journal/ files
277
- const memberFiles = filesToPush.filter(f => f.path.startsWith('/team/') || f.path.startsWith('/journal/'));
278
- const blockedFiles = filesToPush.filter(f => !f.path.startsWith('/team/') && !f.path.startsWith('/journal/'));
279
- if (memberFiles.length > 0 && blockedFiles.length > 0) {
280
- console.log(` You're a member — retrying with your team files only...`);
281
- if (blockedFiles.length > 0) {
282
- console.log(` Skipped (no permission): ${blockedFiles.map(f => f.path.replace(/^\//, '')).join(', ')}`);
283
- }
293
+ if (result.status === 403) {
294
+ // Permission denied retry with only team/ and journal/ files
295
+ const allowed = filesToPush.filter(f => f.path.startsWith('/team/') || f.path.startsWith('/journal/'));
296
+ skipped = filesToPush.filter(f => !f.path.startsWith('/team/') && !f.path.startsWith('/journal/'));
297
+
298
+ if (allowed.length > 0) {
284
299
  const retry = await apiRequestJson(
285
- `/businesses/${businessId}/workspaces/${workspaceId}/sync`,
286
- { method: 'POST', token: creds.token, body: { files: memberFiles }, headers: { 'X-Atris-Actor-Source': 'cli' } }
300
+ `/business/${businessId}/workspaces/${workspaceId}/sync`,
301
+ { method: 'POST', token: creds.token, body: { files: allowed }, headers: { 'X-Atris-Actor-Source': 'cli' } }
287
302
  );
288
303
  if (retry.ok) {
289
- for (const f of memberFiles) {
290
- console.log(` \u2191 ${f.path.replace(/^\//, '')} pushed`);
291
- pushed++;
292
- }
304
+ pushed = allowed.length;
293
305
  } else {
294
- console.error(` Push failed after retry: ${retry.errorMessage || retry.error || retry.status}`);
306
+ console.error(`\n Push failed: ${retry.errorMessage || retry.error || retry.status}`);
295
307
  process.exit(1);
296
308
  }
297
309
  } else {
298
- console.error(` Access denied: you can only push to your own team/ folder.`);
299
- if (blockedFiles.length > 0) {
300
- console.error(` Blocked: ${blockedFiles.map(f => f.path.replace(/^\//, '')).join(', ')}`);
301
- }
310
+ console.error('\n Access denied: you can only push to your team/ folder.');
302
311
  process.exit(1);
303
312
  }
313
+ } else if (result.status === 409) {
314
+ console.error('\n Computer is sleeping. Wake it first.');
315
+ process.exit(1);
304
316
  } else {
305
- console.error(` Push failed: ${msg}`);
317
+ console.error(`\n Push failed: ${result.errorMessage || result.error || result.status}`);
318
+ process.exit(1);
306
319
  }
307
- process.exit(1);
320
+ } else {
321
+ pushed = filesToPush.length;
308
322
  }
323
+ }
309
324
 
310
- // Display results
311
- for (const p of diff.toPush) {
312
- console.log(` \u2191 ${p.replace(/^\//, '')} pushing your changes`);
313
- pushed++;
325
+ // Delete loop — throttled, 429-aware, tracks per-file success/failure.
326
+ // Earlier bug: bulk deletes hit rate limit (60/min default) at request 60,
327
+ // then process.exit'd, leaving partial state and a manifest that thought
328
+ // everything was deleted. New behavior:
329
+ // - 600ms throttle between deletes (≈100/min, safe under default rate limit)
330
+ // - 429 → wait 20s, retry once
331
+ // - 404 → counted as success (file already gone)
332
+ // - other failures → collected, reported at end, do NOT exit
333
+ // - manifest update only counts confirmed-deleted paths
334
+ const deletedConfirmed = [];
335
+ const deleteFailed = [];
336
+ for (let i = 0; i < deletedPaths.length; i++) {
337
+ const filePath = deletedPaths[i];
338
+ if (i > 0) {
339
+ // sleep 600ms between deletes
340
+ await new Promise((r) => setTimeout(r, 600));
314
341
  }
315
- for (const p of diff.newLocal) {
316
- console.log(` + ${p.replace(/^\//, '')} new file`);
317
- pushed++;
342
+ let deleteResult = await apiRequestJson(
343
+ `/business/${businessId}/workspaces/${workspaceId}/file?path=${encodeURIComponent(filePath)}`,
344
+ { method: 'DELETE', token: creds.token }
345
+ );
346
+ if (deleteResult.status === 429) {
347
+ // Rate limit — wait 20s, retry once
348
+ await new Promise((r) => setTimeout(r, 20000));
349
+ deleteResult = await apiRequestJson(
350
+ `/business/${businessId}/workspaces/${workspaceId}/file?path=${encodeURIComponent(filePath)}`,
351
+ { method: 'DELETE', token: creds.token }
352
+ );
318
353
  }
319
- if (force) {
320
- for (const p of diff.conflicts) {
321
- console.log(` ! ${p.replace(/^\//, '')} overwritten (--force)`);
322
- pushed++;
323
- }
354
+ if (deleteResult.ok || deleteResult.status === 404) {
355
+ deletedConfirmed.push(filePath);
356
+ deleted++;
357
+ } else {
358
+ deleteFailed.push({ path: filePath, status: deleteResult.status, error: deleteResult.error });
324
359
  }
325
- for (const p of mergedPaths) {
326
- console.log(` \u2194 ${p.replace(/^\//, '')} auto-merged (different sections)`);
327
- pushed++;
360
+ // Show progress for large batches
361
+ if (deletedPaths.length > 20 && (i + 1) % 20 === 0) {
362
+ console.log(` [delete ${i + 1}/${deletedPaths.length}] ${deletedConfirmed.length} ok, ${deleteFailed.length} failed`);
328
363
  }
329
364
  }
330
-
331
- // Show conflicts
332
- for (const p of conflictPaths) {
333
- console.log(` \u26A0 ${p.replace(/^\//, '')} CONFLICT \u2014 skipped (use --force to override)`);
365
+ if (deleteFailed.length > 0) {
366
+ console.log('');
367
+ console.log(` ${deleteFailed.length} delete(s) failed (NOT marked as deleted in manifest):`);
368
+ deleteFailed.slice(0, 10).forEach((f) => console.log(` ${f.status} ${f.path.replace(/^\//, '')}`));
369
+ if (deleteFailed.length > 10) console.log(` ... +${deleteFailed.length - 10} more`);
334
370
  }
335
371
 
336
- // Show unchanged
337
- if (diff.unchanged.length > 0) {
338
- // Don't list them all, just count
372
+ // Display results
373
+ console.log('');
374
+ for (const f of filesToPush) {
375
+ if (skipped.includes(f)) continue;
376
+ const isNew = !baseFiles[f.path];
377
+ console.log(` ${isNew ? '+' : '\u2191'} ${f.path.replace(/^\//, '')} ${isNew ? 'new file' : 'updated'}`);
378
+ }
379
+ for (const f of skipped) {
380
+ console.log(` - ${f.path.replace(/^\//, '')} skipped (no permission)`);
381
+ }
382
+ // Only print confirmed deletes (not failed ones — they were reported above)
383
+ for (const filePath of deletedConfirmed) {
384
+ console.log(` x ${filePath.replace(/^\//, '')} deleted`);
339
385
  }
340
386
 
341
387
  // Summary
342
388
  console.log('');
343
389
  const parts = [];
344
390
  if (pushed > 0) parts.push(`${pushed} pushed`);
345
- if (diff.unchanged.length > 0) parts.push(`${diff.unchanged.length} unchanged`);
346
- if (conflictPaths.length > 0) parts.push(`${conflictPaths.length} conflict${conflictPaths.length > 1 ? 's' : ''}`);
347
- if (parts.length > 0) console.log(` ${parts.join(', ')}.`);
348
-
349
- // Get commit hash after push
350
- let commitHash = null;
351
- try {
352
- const headResult = await apiRequestJson(
353
- `/businesses/${businessId}/workspaces/${workspaceId}/git/head`,
354
- { method: 'GET', token: creds.token }
355
- );
356
- if (headResult.ok && headResult.data && headResult.data.commit) {
357
- commitHash = headResult.data.commit;
391
+ if (deleted > 0) parts.push(`${deleted} deleted`);
392
+ if (deleteFailed.length > 0) parts.push(`${deleteFailed.length} delete failed`);
393
+ if (unchangedCount > 0) parts.push(`${unchangedCount} unchanged`);
394
+ if (skipped.length > 0) parts.push(`${skipped.length} skipped`);
395
+ console.log(` ${parts.join(', ')}.`);
396
+
397
+ // Update manifest — mark pushed files with their new hash, drop ONLY confirmed deletes.
398
+ // Failed deletes stay in the manifest so the next push will retry them.
399
+ const updatedFiles = { ...baseFiles };
400
+ for (const f of filesToPush) {
401
+ if (!skipped.includes(f)) {
402
+ updatedFiles[f.path] = localFiles[f.path];
358
403
  }
359
- } catch {
360
- // Git might not be initialized yet
361
404
  }
362
-
363
- // Update manifest with new state (merge local + remote)
364
- const mergedFiles = { ...remoteFiles };
365
- for (const p of Object.keys(localFiles)) {
366
- if (filesToPush.some(f => f.path === p)) {
367
- mergedFiles[p] = localFiles[p]; // we pushed this, so our hash is now the truth
368
- }
405
+ for (const filePath of deletedConfirmed) {
406
+ delete updatedFiles[filePath];
369
407
  }
370
- const newManifest = buildManifest(mergedFiles, commitHash);
371
- saveManifest(resolvedSlug || slug, newManifest);
408
+ saveManifest(resolvedSlug || slug, buildManifest(updatedFiles, null));
372
409
  }
373
410
 
374
411
  module.exports = { pushAtris };