atris 2.6.0 → 2.6.1

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 (40) hide show
  1. package/GETTING_STARTED.md +2 -2
  2. package/atris/GETTING_STARTED.md +2 -2
  3. package/bin/atris.js +16 -3
  4. package/commands/context-sync.js +226 -0
  5. package/commands/pull.js +118 -40
  6. package/commands/push.js +150 -61
  7. package/lib/manifest.js +222 -0
  8. package/package.json +9 -4
  9. package/AGENT.md +0 -35
  10. package/atris/experiments/README.md +0 -118
  11. package/atris/experiments/_examples/smoke-keep-revert/README.md +0 -45
  12. package/atris/experiments/_examples/smoke-keep-revert/candidate.py +0 -8
  13. package/atris/experiments/_examples/smoke-keep-revert/loop.py +0 -129
  14. package/atris/experiments/_examples/smoke-keep-revert/measure.py +0 -47
  15. package/atris/experiments/_examples/smoke-keep-revert/program.md +0 -3
  16. package/atris/experiments/_examples/smoke-keep-revert/proposals/bad_patch.py +0 -19
  17. package/atris/experiments/_examples/smoke-keep-revert/proposals/fix_patch.py +0 -22
  18. package/atris/experiments/_examples/smoke-keep-revert/reset.py +0 -21
  19. package/atris/experiments/_examples/smoke-keep-revert/results.tsv +0 -5
  20. package/atris/experiments/_examples/smoke-keep-revert/visual.svg +0 -52
  21. package/atris/experiments/_fixtures/invalid/BadName/loop.py +0 -1
  22. package/atris/experiments/_fixtures/invalid/BadName/program.md +0 -3
  23. package/atris/experiments/_fixtures/invalid/BadName/results.tsv +0 -1
  24. package/atris/experiments/_fixtures/invalid/bloated-context/loop.py +0 -1
  25. package/atris/experiments/_fixtures/invalid/bloated-context/measure.py +0 -1
  26. package/atris/experiments/_fixtures/invalid/bloated-context/program.md +0 -6
  27. package/atris/experiments/_fixtures/invalid/bloated-context/results.tsv +0 -1
  28. package/atris/experiments/_fixtures/valid/good-experiment/loop.py +0 -1
  29. package/atris/experiments/_fixtures/valid/good-experiment/measure.py +0 -1
  30. package/atris/experiments/_fixtures/valid/good-experiment/program.md +0 -3
  31. package/atris/experiments/_fixtures/valid/good-experiment/results.tsv +0 -1
  32. package/atris/experiments/_template/pack/loop.py +0 -3
  33. package/atris/experiments/_template/pack/measure.py +0 -13
  34. package/atris/experiments/_template/pack/program.md +0 -3
  35. package/atris/experiments/_template/pack/reset.py +0 -3
  36. package/atris/experiments/_template/pack/results.tsv +0 -1
  37. package/atris/experiments/benchmark_runtime.py +0 -81
  38. package/atris/experiments/benchmark_validate.py +0 -70
  39. package/atris/experiments/validate.py +0 -92
  40. 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
@@ -698,6 +698,11 @@ if (command === 'init') {
698
698
  console.error(`✗ Log sync failed: ${error.message || error}`);
699
699
  process.exit(1);
700
700
  });
701
+ } else if (subcommand && subcommand !== '--help' && !subcommand.startsWith('-')) {
702
+ // Business log: atris log <business-slug>
703
+ require('../commands/context-sync').businessLog(subcommand)
704
+ .then(() => process.exit(0))
705
+ .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
701
706
  } else {
702
707
  logCmd();
703
708
  }
@@ -876,9 +881,17 @@ if (command === 'init') {
876
881
  process.exit(1);
877
882
  });
878
883
  } 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);
884
+ const subcommand = process.argv[3];
885
+ if (subcommand && !subcommand.startsWith('-')) {
886
+ // Business status: atris status <business-slug>
887
+ require('../commands/context-sync').businessStatus(subcommand)
888
+ .then(() => process.exit(0))
889
+ .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
890
+ } else {
891
+ const isQuick = process.argv.includes('--quick') || process.argv.includes('-q');
892
+ const isJson = process.argv.includes('--json');
893
+ statusCmd(isQuick, isJson);
894
+ }
882
895
  } else if (command === 'analytics') {
883
896
  require('../commands/analytics').analyticsAtris();
884
897
  } else if (command === 'clean') {
@@ -0,0 +1,226 @@
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 (hashes only)
77
+ const result = await apiRequestJson(
78
+ `/businesses/${biz.businessId}/workspaces/${biz.workspaceId}/snapshot?include_content=false`,
79
+ { method: 'GET', token: biz.token }
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
+ for (const file of (result.data.files || [])) {
93
+ if (file.path && !file.binary) {
94
+ remoteFiles[file.path] = { hash: file.hash, size: file.size || 0 };
95
+ }
96
+ }
97
+
98
+ const localFiles = localDir ? computeLocalHashes(localDir) : {};
99
+ const diff = threeWayCompare(localFiles, remoteFiles, manifest);
100
+
101
+ console.log('');
102
+
103
+ // You changed
104
+ const youChanged = [...diff.toPush, ...diff.newLocal];
105
+ if (youChanged.length > 0) {
106
+ console.log(' You changed:');
107
+ for (const p of youChanged) {
108
+ const label = diff.newLocal.includes(p) ? '(new)' : '';
109
+ console.log(` ${p.replace(/^\//, '')} ${label}`);
110
+ }
111
+ }
112
+
113
+ // Computer changed
114
+ const computerChanged = [...diff.toPull, ...diff.newRemote];
115
+ if (computerChanged.length > 0) {
116
+ console.log(' Computer changed:');
117
+ for (const p of computerChanged) {
118
+ const label = diff.newRemote.includes(p) ? '(new)' : '';
119
+ console.log(` ${p.replace(/^\//, '')} ${label}`);
120
+ }
121
+ }
122
+
123
+ // Conflicts
124
+ if (diff.conflicts.length > 0) {
125
+ console.log(' Conflicts:');
126
+ for (const p of diff.conflicts) {
127
+ console.log(` ${p.replace(/^\//, '')}`);
128
+ }
129
+ } else if (youChanged.length > 0 || computerChanged.length > 0) {
130
+ console.log(' Conflicts: (none)');
131
+ }
132
+
133
+ // Remote deletions
134
+ if (diff.deletedRemote.length > 0) {
135
+ console.log(' Deleted on computer:');
136
+ for (const p of diff.deletedRemote) {
137
+ console.log(` ${p.replace(/^\//, '')}`);
138
+ }
139
+ }
140
+
141
+ if (youChanged.length === 0 && computerChanged.length === 0 && diff.conflicts.length === 0) {
142
+ if (!localDir) {
143
+ console.log(' No local copy found. Run: atris pull ' + slug);
144
+ } else {
145
+ console.log(' Everything up to date.');
146
+ }
147
+ }
148
+
149
+ console.log('');
150
+ }
151
+
152
+
153
+ /**
154
+ * atris log <business-slug>
155
+ * Shows human-readable commit history.
156
+ */
157
+ async function businessLog(slug) {
158
+ const biz = await resolveBusiness(slug);
159
+
160
+ if (!biz.workspaceId) {
161
+ console.error(`Business "${slug}" has no workspace.`);
162
+ process.exit(1);
163
+ }
164
+
165
+ const limit = 20;
166
+ const pathFilter = process.argv.includes('--path') ? process.argv[process.argv.indexOf('--path') + 1] : null;
167
+
168
+ const params = `limit=${limit}` + (pathFilter ? `&path=${encodeURIComponent(pathFilter)}` : '');
169
+ const result = await apiRequestJson(
170
+ `/businesses/${biz.businessId}/workspaces/${biz.workspaceId}/git/log?${params}`,
171
+ { method: 'GET', token: biz.token }
172
+ );
173
+
174
+ if (!result.ok) {
175
+ if (result.status === 409) {
176
+ console.log('\n Computer is sleeping. Wake it first.\n');
177
+ } else {
178
+ console.error(`\n Failed to get history: ${result.errorMessage || result.status}\n`);
179
+ }
180
+ process.exit(1);
181
+ }
182
+
183
+ const commits = result.data.commits || [];
184
+ if (commits.length === 0) {
185
+ console.log(`\n ${biz.name} \u2014 no history yet.\n`);
186
+ return;
187
+ }
188
+
189
+ console.log(`\n ${biz.name} \u2014 history\n`);
190
+
191
+ for (const commit of commits) {
192
+ const date = _timeSince(commit.date) || commit.date;
193
+ const msg = commit.message || '';
194
+
195
+ // Parse actor from message format "actor/name: message"
196
+ const colonIdx = msg.indexOf(': ');
197
+ let actor = '';
198
+ let description = msg;
199
+ if (colonIdx > 0 && colonIdx < 40) {
200
+ actor = msg.substring(0, colonIdx);
201
+ description = msg.substring(colonIdx + 2);
202
+ }
203
+
204
+ const actorDisplay = actor ? ` ${actor}` : '';
205
+ console.log(` ${date.padEnd(12)} ${actorDisplay}`);
206
+ console.log(` ${description}`);
207
+ console.log('');
208
+ }
209
+ }
210
+
211
+
212
+ function _timeSince(isoString) {
213
+ if (!isoString) return null;
214
+ const diff = Date.now() - new Date(isoString).getTime();
215
+ if (diff < 0) return 'just now';
216
+ const mins = Math.floor(diff / 60000);
217
+ if (mins < 1) return 'just now';
218
+ if (mins < 60) return `${mins}m ago`;
219
+ const hours = Math.floor(mins / 60);
220
+ if (hours < 24) return `${hours}h ago`;
221
+ const days = Math.floor(hours / 24);
222
+ return `${days}d ago`;
223
+ }
224
+
225
+
226
+ module.exports = { businessStatus, businessLog };
package/commands/pull.js CHANGED
@@ -7,6 +7,7 @@ const { loadConfig } = require('../utils/config');
7
7
  const { getLogPath } = require('../lib/file-ops');
8
8
  const { parseJournalSections, mergeSections, reconstructJournal } = require('../lib/journal');
9
9
  const { loadBusinesses } = require('./business');
10
+ const { loadManifest, saveManifest, computeFileHash, buildManifest, computeLocalHashes, threeWayCompare } = require('../lib/manifest');
10
11
 
11
12
  async function pullAtris() {
12
13
  const arg = process.argv[3];
@@ -77,13 +78,14 @@ async function pullBusiness(slug) {
77
78
  process.exit(1);
78
79
  }
79
80
 
81
+ const force = process.argv.includes('--force');
82
+
80
83
  // Determine output directory
81
84
  const intoIdx = process.argv.indexOf('--into');
82
85
  let outputDir;
83
86
  if (intoIdx !== -1 && process.argv[intoIdx + 1]) {
84
87
  outputDir = path.resolve(process.argv[intoIdx + 1]);
85
88
  } else {
86
- // Default: atris/{slug}/ in current directory, or just {slug}/ if no atris/ folder
87
89
  const atrisDir = path.join(process.cwd(), 'atris');
88
90
  if (fs.existsSync(atrisDir)) {
89
91
  outputDir = path.join(atrisDir, slug);
@@ -93,15 +95,15 @@ async function pullBusiness(slug) {
93
95
  }
94
96
 
95
97
  // Resolve business ID — check local config first, then API
96
- let businessId, workspaceId, businessName;
98
+ let businessId, workspaceId, businessName, resolvedSlug;
97
99
  const businesses = loadBusinesses();
98
100
 
99
101
  if (businesses[slug]) {
100
102
  businessId = businesses[slug].business_id;
101
103
  workspaceId = businesses[slug].workspace_id;
102
104
  businessName = businesses[slug].name || slug;
105
+ resolvedSlug = businesses[slug].slug || slug;
103
106
  } else {
104
- // Try to find by slug via API
105
107
  const listResult = await apiRequestJson('/businesses/', { method: 'GET', token: creds.token });
106
108
  if (!listResult.ok) {
107
109
  console.error(`Failed to fetch businesses: ${listResult.errorMessage || listResult.status}`);
@@ -117,8 +119,8 @@ async function pullBusiness(slug) {
117
119
  businessId = match.id;
118
120
  workspaceId = match.workspace_id;
119
121
  businessName = match.name;
122
+ resolvedSlug = match.slug;
120
123
 
121
- // Auto-save for next time
122
124
  businesses[slug] = {
123
125
  business_id: businessId,
124
126
  workspace_id: workspaceId,
@@ -135,25 +137,29 @@ async function pullBusiness(slug) {
135
137
  process.exit(1);
136
138
  }
137
139
 
140
+ // Load manifest (last sync state)
141
+ const manifest = loadManifest(resolvedSlug || slug);
142
+ const timeSince = manifest ? _timeSince(manifest.last_sync) : null;
143
+
138
144
  console.log('');
139
- console.log(`Pulling ${businessName}...`);
145
+ console.log(`Pulling ${businessName}...` + (timeSince ? ` (last synced ${timeSince})` : ''));
140
146
 
141
- // Snapshot one API call gets everything
147
+ // Get remote snapshot (large workspaces can take 60s+)
142
148
  const result = await apiRequestJson(
143
149
  `/businesses/${businessId}/workspaces/${workspaceId}/snapshot?include_content=true`,
144
- { method: 'GET', token: creds.token }
150
+ { method: 'GET', token: creds.token, timeoutMs: 120000 }
145
151
  );
146
152
 
147
153
  if (!result.ok) {
148
154
  const msg = result.errorMessage || `HTTP ${result.status}`;
149
155
  if (result.status === 409) {
150
- console.error(`\nComputer is sleeping. Wake it first, then pull again.`);
156
+ console.error(`\n Computer is sleeping. Wake it first, then pull again.`);
151
157
  } else if (result.status === 403) {
152
- console.error(`\nAccess denied. You're not a member of "${slug}".`);
158
+ console.error(`\n Access denied. You're not a member of "${slug}".`);
153
159
  } else if (result.status === 404) {
154
- console.error(`\nBusiness "${slug}" not found.`);
160
+ console.error(`\n Business "${slug}" not found.`);
155
161
  } else {
156
- console.error(`\nPull failed: ${msg}`);
162
+ console.error(`\n Pull failed: ${msg}`);
157
163
  }
158
164
  process.exit(1);
159
165
  }
@@ -164,45 +170,117 @@ async function pullBusiness(slug) {
164
170
  return;
165
171
  }
166
172
 
167
- // Write files to local directory
168
- let written = 0;
169
- let skipped = 0;
170
-
173
+ // Build remote file map {path: {hash, size, content}}
174
+ const remoteFiles = {};
175
+ const remoteContent = {};
171
176
  for (const file of files) {
172
- if (!file.path || file.content === null || file.content === undefined) {
173
- skipped++;
174
- continue;
175
- }
176
- if (file.binary) {
177
- skipped++;
178
- continue;
179
- }
177
+ if (!file.path || file.binary || file.content === null || file.content === undefined) continue;
178
+ remoteFiles[file.path] = { hash: file.hash || computeFileHash(file.content), size: file.size || 0 };
179
+ remoteContent[file.path] = file.content;
180
+ }
181
+
182
+ // Compute local file hashes
183
+ const localFiles = fs.existsSync(outputDir) ? computeLocalHashes(outputDir) : {};
180
184
 
181
- const localPath = path.join(outputDir, file.path.replace(/^\//, ''));
182
- const localDir = path.dirname(localPath);
185
+ // Three-way compare
186
+ const baseFiles = (manifest && manifest.files) ? manifest.files : {};
187
+ const diff = threeWayCompare(localFiles, remoteFiles, manifest);
188
+
189
+ // Apply changes
190
+ let pulled = 0;
191
+ let conflictCount = 0;
192
+ let unchangedCount = diff.unchanged.length;
193
+
194
+ console.log('');
195
+
196
+ // Pull files that changed remotely (and we didn't change locally)
197
+ for (const p of [...diff.toPull, ...diff.newRemote]) {
198
+ const content = remoteContent[p];
199
+ if (!content && content !== '') continue;
200
+ const localPath = path.join(outputDir, p.replace(/^\//, ''));
201
+ fs.mkdirSync(path.dirname(localPath), { recursive: true });
202
+ fs.writeFileSync(localPath, content);
203
+ const label = diff.newRemote.includes(p) ? 'new on computer' : 'updated on computer';
204
+ const icon = diff.newRemote.includes(p) ? '+' : '\u2193';
205
+ console.log(` ${icon} ${p.replace(/^\//, '')} ${label}`);
206
+ pulled++;
207
+ }
183
208
 
184
- // Check if unchanged
185
- if (fs.existsSync(localPath)) {
186
- const existing = fs.readFileSync(localPath, 'utf8');
187
- if (existing === file.content) {
188
- skipped++;
189
- continue;
209
+ // Handle conflicts
210
+ for (const p of diff.conflicts) {
211
+ if (force) {
212
+ // Force mode: pull remote version, overwrite local
213
+ const content = remoteContent[p];
214
+ if (!content && content !== '') continue;
215
+ const localPath = path.join(outputDir, p.replace(/^\//, ''));
216
+ fs.mkdirSync(path.dirname(localPath), { recursive: true });
217
+ fs.writeFileSync(localPath, content);
218
+ console.log(` ! ${p.replace(/^\//, '')} overwritten (--force)`);
219
+ pulled++;
220
+ } else {
221
+ // Save remote version alongside local
222
+ const content = remoteContent[p];
223
+ if (content || content === '') {
224
+ const localPath = path.join(outputDir, p.replace(/^\//, '') + '.remote');
225
+ fs.mkdirSync(path.dirname(localPath), { recursive: true });
226
+ fs.writeFileSync(localPath, content);
190
227
  }
228
+ console.log(` \u26A0 ${p.replace(/^\//, '')} CONFLICT \u2014 both you and the computer changed this`);
229
+ console.log(` \u2192 Remote version saved as ${p.replace(/^\//, '')}.remote`);
230
+ conflictCount++;
191
231
  }
232
+ }
192
233
 
193
- fs.mkdirSync(localDir, { recursive: true });
194
- fs.writeFileSync(localPath, file.content);
195
- written++;
234
+ // Warn about remote deletions
235
+ for (const p of diff.deletedRemote) {
236
+ console.log(` - ${p.replace(/^\//, '')} deleted on computer`);
196
237
  }
197
238
 
198
- console.log('');
199
- if (written > 0) {
200
- console.log(` ${written} file${written > 1 ? 's' : ''} pulled to ${outputDir}`);
239
+ // Show unchanged
240
+ if (unchangedCount > 0 && pulled === 0 && conflictCount === 0 && diff.deletedRemote.length === 0) {
241
+ console.log(' Already up to date.');
201
242
  }
202
- if (skipped > 0) {
203
- console.log(` ${skipped} unchanged`);
243
+
244
+ // Summary
245
+ console.log('');
246
+ const parts = [];
247
+ if (pulled > 0) parts.push(`${pulled} pulled`);
248
+ if (diff.newRemote.length > 0 && !parts.some(p => p.includes('pulled'))) parts.push(`${diff.newRemote.length} new`);
249
+ if (unchangedCount > 0) parts.push(`${unchangedCount} unchanged`);
250
+ if (conflictCount > 0) parts.push(`${conflictCount} conflict${conflictCount > 1 ? 's' : ''}`);
251
+ if (diff.deletedRemote.length > 0) parts.push(`${diff.deletedRemote.length} deleted remotely`);
252
+ if (parts.length > 0) console.log(` ${parts.join(', ')}.`);
253
+
254
+ // Get current commit hash from remote (for manifest)
255
+ let commitHash = null;
256
+ try {
257
+ const headResult = await apiRequestJson(
258
+ `/businesses/${businessId}/workspaces/${workspaceId}/git/head`,
259
+ { method: 'GET', token: creds.token }
260
+ );
261
+ if (headResult.ok && headResult.data && headResult.data.commit) {
262
+ commitHash = headResult.data.commit;
263
+ }
264
+ } catch {
265
+ // Git might not be initialized yet — that's fine
204
266
  }
205
- console.log(`\n Total: ${files.length} files (${result.data.total_size} bytes)`);
267
+
268
+ // Save manifest
269
+ const newManifest = buildManifest(remoteFiles, commitHash);
270
+ saveManifest(resolvedSlug || slug, newManifest);
271
+ }
272
+
273
+
274
+ function _timeSince(isoString) {
275
+ if (!isoString) return null;
276
+ const diff = Date.now() - new Date(isoString).getTime();
277
+ const mins = Math.floor(diff / 60000);
278
+ if (mins < 1) return 'just now';
279
+ if (mins < 60) return `${mins}m ago`;
280
+ const hours = Math.floor(mins / 60);
281
+ if (hours < 24) return `${hours}h ago`;
282
+ const days = Math.floor(hours / 24);
283
+ return `${days}d ago`;
206
284
  }
207
285
 
208
286