atris 2.5.5 → 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.
- package/GETTING_STARTED.md +2 -2
- package/atris/GETTING_STARTED.md +2 -2
- package/atris/policies/atris-design.md +200 -15
- package/atris/skills/design/SKILL.md +32 -7
- package/bin/atris.js +34 -10
- package/commands/auth.js +317 -67
- package/commands/context-sync.js +226 -0
- package/commands/pull.js +118 -40
- package/commands/push.js +150 -61
- package/lib/manifest.js +222 -0
- package/package.json +9 -4
- package/utils/auth.js +127 -0
- package/AGENT.md +0 -35
- package/atris/experiments/README.md +0 -118
- package/atris/experiments/_examples/smoke-keep-revert/README.md +0 -45
- package/atris/experiments/_examples/smoke-keep-revert/candidate.py +0 -8
- package/atris/experiments/_examples/smoke-keep-revert/loop.py +0 -129
- package/atris/experiments/_examples/smoke-keep-revert/measure.py +0 -47
- package/atris/experiments/_examples/smoke-keep-revert/program.md +0 -3
- package/atris/experiments/_examples/smoke-keep-revert/proposals/bad_patch.py +0 -19
- package/atris/experiments/_examples/smoke-keep-revert/proposals/fix_patch.py +0 -22
- package/atris/experiments/_examples/smoke-keep-revert/reset.py +0 -21
- package/atris/experiments/_examples/smoke-keep-revert/results.tsv +0 -5
- package/atris/experiments/_examples/smoke-keep-revert/visual.svg +0 -52
- package/atris/experiments/_fixtures/invalid/BadName/loop.py +0 -1
- package/atris/experiments/_fixtures/invalid/BadName/program.md +0 -3
- package/atris/experiments/_fixtures/invalid/BadName/results.tsv +0 -1
- package/atris/experiments/_fixtures/invalid/bloated-context/loop.py +0 -1
- package/atris/experiments/_fixtures/invalid/bloated-context/measure.py +0 -1
- package/atris/experiments/_fixtures/invalid/bloated-context/program.md +0 -6
- package/atris/experiments/_fixtures/invalid/bloated-context/results.tsv +0 -1
- package/atris/experiments/_fixtures/valid/good-experiment/loop.py +0 -1
- package/atris/experiments/_fixtures/valid/good-experiment/measure.py +0 -1
- package/atris/experiments/_fixtures/valid/good-experiment/program.md +0 -3
- package/atris/experiments/_fixtures/valid/good-experiment/results.tsv +0 -1
- package/atris/experiments/_template/pack/loop.py +0 -3
- package/atris/experiments/_template/pack/measure.py +0 -13
- package/atris/experiments/_template/pack/program.md +0 -3
- package/atris/experiments/_template/pack/reset.py +0 -3
- package/atris/experiments/_template/pack/results.tsv +0 -1
- package/atris/experiments/benchmark_runtime.py +0 -81
- package/atris/experiments/benchmark_validate.py +0 -70
- package/atris/experiments/validate.py +0 -92
- package/atris/team/navigator/journal/2026-02-23.md +0 -6
package/commands/push.js
CHANGED
|
@@ -3,21 +3,29 @@ const path = require('path');
|
|
|
3
3
|
const { loadCredentials } = require('../utils/auth');
|
|
4
4
|
const { apiRequestJson } = require('../utils/api');
|
|
5
5
|
const { loadBusinesses, saveBusinesses } = require('./business');
|
|
6
|
+
const { loadManifest, saveManifest, computeFileHash, buildManifest, computeLocalHashes, threeWayCompare, SKIP_DIRS } = require('../lib/manifest');
|
|
6
7
|
|
|
7
8
|
async function pushAtris() {
|
|
8
9
|
const slug = process.argv[3];
|
|
9
10
|
|
|
10
11
|
if (!slug || slug === '--help') {
|
|
11
|
-
console.log('Usage: atris push <business-slug> [--from <path>]');
|
|
12
|
+
console.log('Usage: atris push <business-slug> [--from <path>] [--force]');
|
|
12
13
|
console.log('');
|
|
13
14
|
console.log('Push local files to a Business Computer.');
|
|
14
15
|
console.log('');
|
|
16
|
+
console.log('Options:');
|
|
17
|
+
console.log(' --from <path> Push from a custom directory');
|
|
18
|
+
console.log(' --force Push everything, overwrite conflicts');
|
|
19
|
+
console.log('');
|
|
15
20
|
console.log('Examples:');
|
|
16
21
|
console.log(' atris push pallet Push from atris/pallet/ or ./pallet/');
|
|
17
22
|
console.log(' atris push pallet --from ./my-dir/ Push from a custom directory');
|
|
23
|
+
console.log(' atris push pallet --force Override conflicts');
|
|
18
24
|
process.exit(0);
|
|
19
25
|
}
|
|
20
26
|
|
|
27
|
+
const force = process.argv.includes('--force');
|
|
28
|
+
|
|
21
29
|
const creds = loadCredentials();
|
|
22
30
|
if (!creds || !creds.token) {
|
|
23
31
|
console.error('Not logged in. Run: atris login');
|
|
@@ -50,15 +58,15 @@ async function pushAtris() {
|
|
|
50
58
|
}
|
|
51
59
|
|
|
52
60
|
// Resolve business ID
|
|
53
|
-
let businessId, workspaceId, businessName;
|
|
61
|
+
let businessId, workspaceId, businessName, resolvedSlug;
|
|
54
62
|
const businesses = loadBusinesses();
|
|
55
63
|
|
|
56
64
|
if (businesses[slug]) {
|
|
57
65
|
businessId = businesses[slug].business_id;
|
|
58
66
|
workspaceId = businesses[slug].workspace_id;
|
|
59
67
|
businessName = businesses[slug].name || slug;
|
|
68
|
+
resolvedSlug = businesses[slug].slug || slug;
|
|
60
69
|
} else {
|
|
61
|
-
// Try to find by slug via API
|
|
62
70
|
const listResult = await apiRequestJson('/businesses/', { method: 'GET', token: creds.token });
|
|
63
71
|
if (!listResult.ok) {
|
|
64
72
|
console.error(`Failed to fetch businesses: ${listResult.errorMessage || listResult.status}`);
|
|
@@ -74,8 +82,8 @@ async function pushAtris() {
|
|
|
74
82
|
businessId = match.id;
|
|
75
83
|
workspaceId = match.workspace_id;
|
|
76
84
|
businessName = match.name;
|
|
85
|
+
resolvedSlug = match.slug;
|
|
77
86
|
|
|
78
|
-
// Auto-save
|
|
79
87
|
businesses[slug] = {
|
|
80
88
|
business_id: businessId,
|
|
81
89
|
workspace_id: workspaceId,
|
|
@@ -91,80 +99,161 @@ async function pushAtris() {
|
|
|
91
99
|
process.exit(1);
|
|
92
100
|
}
|
|
93
101
|
|
|
94
|
-
//
|
|
95
|
-
const
|
|
96
|
-
const SKIP_DIRS = new Set(['node_modules', '__pycache__', '.git', 'venv', '.venv', 'lost+found', '.cache']);
|
|
97
|
-
|
|
98
|
-
function walkDir(dir) {
|
|
99
|
-
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
100
|
-
for (const entry of entries) {
|
|
101
|
-
if (entry.name.startsWith('.')) continue;
|
|
102
|
-
const fullPath = path.join(dir, entry.name);
|
|
103
|
-
|
|
104
|
-
if (entry.isDirectory()) {
|
|
105
|
-
if (SKIP_DIRS.has(entry.name)) continue;
|
|
106
|
-
walkDir(fullPath);
|
|
107
|
-
} else if (entry.isFile()) {
|
|
108
|
-
const relPath = '/' + path.relative(sourceDir, fullPath);
|
|
109
|
-
try {
|
|
110
|
-
const content = fs.readFileSync(fullPath, 'utf8');
|
|
111
|
-
files.push({ path: relPath, content });
|
|
112
|
-
} catch {
|
|
113
|
-
// Skip binary files
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
}
|
|
102
|
+
// Load manifest (last sync state)
|
|
103
|
+
const manifest = loadManifest(resolvedSlug || slug);
|
|
118
104
|
|
|
119
|
-
|
|
105
|
+
// Compute local file hashes
|
|
106
|
+
const localFiles = computeLocalHashes(sourceDir);
|
|
120
107
|
|
|
121
|
-
if (
|
|
108
|
+
if (Object.keys(localFiles).length === 0) {
|
|
122
109
|
console.log(`\nNo files to push from ${sourceDir}`);
|
|
123
110
|
return;
|
|
124
111
|
}
|
|
125
112
|
|
|
113
|
+
// Get remote snapshot for three-way compare
|
|
126
114
|
console.log('');
|
|
127
|
-
console.log(`Pushing
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
{
|
|
133
|
-
method: 'POST',
|
|
134
|
-
token: creds.token,
|
|
135
|
-
body: { files },
|
|
136
|
-
}
|
|
115
|
+
console.log(`Pushing to ${businessName}...`);
|
|
116
|
+
|
|
117
|
+
const snapshotResult = await apiRequestJson(
|
|
118
|
+
`/businesses/${businessId}/workspaces/${workspaceId}/snapshot?include_content=false`,
|
|
119
|
+
{ method: 'GET', token: creds.token }
|
|
137
120
|
);
|
|
138
121
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
} else {
|
|
146
|
-
console.error(`\nPush failed: ${msg}`);
|
|
122
|
+
let remoteFiles = {};
|
|
123
|
+
if (snapshotResult.ok && snapshotResult.data && snapshotResult.data.files) {
|
|
124
|
+
for (const file of snapshotResult.data.files) {
|
|
125
|
+
if (file.path && !file.binary) {
|
|
126
|
+
remoteFiles[file.path] = { hash: file.hash, size: file.size || 0 };
|
|
127
|
+
}
|
|
147
128
|
}
|
|
148
|
-
process.exit(1);
|
|
149
129
|
}
|
|
150
130
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
131
|
+
// Three-way compare
|
|
132
|
+
const diff = threeWayCompare(localFiles, remoteFiles, manifest);
|
|
133
|
+
|
|
134
|
+
// Determine what to push
|
|
135
|
+
const filesToPush = [];
|
|
136
|
+
|
|
137
|
+
// Files we changed that remote didn't
|
|
138
|
+
for (const p of [...diff.toPush, ...diff.newLocal]) {
|
|
139
|
+
const localPath = path.join(sourceDir, p.replace(/^\//, ''));
|
|
140
|
+
try {
|
|
141
|
+
const content = fs.readFileSync(localPath, 'utf8');
|
|
142
|
+
filesToPush.push({ path: p, content });
|
|
143
|
+
} catch {
|
|
144
|
+
// skip
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Force mode: also push conflicts
|
|
149
|
+
const conflictPaths = [];
|
|
150
|
+
if (force) {
|
|
151
|
+
for (const p of diff.conflicts) {
|
|
152
|
+
const localPath = path.join(sourceDir, p.replace(/^\//, ''));
|
|
153
|
+
try {
|
|
154
|
+
const content = fs.readFileSync(localPath, 'utf8');
|
|
155
|
+
filesToPush.push({ path: p, content });
|
|
156
|
+
} catch {
|
|
157
|
+
// skip
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
for (const p of diff.conflicts) {
|
|
162
|
+
conflictPaths.push(p);
|
|
163
|
+
}
|
|
155
164
|
}
|
|
156
|
-
|
|
157
|
-
|
|
165
|
+
|
|
166
|
+
console.log('');
|
|
167
|
+
|
|
168
|
+
if (filesToPush.length === 0 && conflictPaths.length === 0) {
|
|
169
|
+
console.log(' Already up to date.');
|
|
170
|
+
console.log('');
|
|
171
|
+
return;
|
|
158
172
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
173
|
+
|
|
174
|
+
// Push the files
|
|
175
|
+
let pushed = 0;
|
|
176
|
+
if (filesToPush.length > 0) {
|
|
177
|
+
const result = await apiRequestJson(
|
|
178
|
+
`/businesses/${businessId}/workspaces/${workspaceId}/sync`,
|
|
179
|
+
{
|
|
180
|
+
method: 'POST',
|
|
181
|
+
token: creds.token,
|
|
182
|
+
body: { files: filesToPush },
|
|
183
|
+
headers: { 'X-Atris-Actor-Source': 'cli' },
|
|
184
|
+
}
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
if (!result.ok) {
|
|
188
|
+
const msg = result.errorMessage || `HTTP ${result.status}`;
|
|
189
|
+
if (result.status === 409) {
|
|
190
|
+
console.error(` Computer is sleeping. Wake it first, then push.`);
|
|
191
|
+
} else if (result.status === 403) {
|
|
192
|
+
console.error(` Access denied: ${msg}`);
|
|
193
|
+
} else {
|
|
194
|
+
console.error(` Push failed: ${msg}`);
|
|
195
|
+
}
|
|
196
|
+
process.exit(1);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Display results
|
|
200
|
+
for (const p of diff.toPush) {
|
|
201
|
+
console.log(` \u2191 ${p.replace(/^\//, '')} pushing your changes`);
|
|
202
|
+
pushed++;
|
|
203
|
+
}
|
|
204
|
+
for (const p of diff.newLocal) {
|
|
205
|
+
console.log(` + ${p.replace(/^\//, '')} new file`);
|
|
206
|
+
pushed++;
|
|
207
|
+
}
|
|
208
|
+
if (force) {
|
|
209
|
+
for (const p of diff.conflicts) {
|
|
210
|
+
console.log(` ! ${p.replace(/^\//, '')} overwritten (--force)`);
|
|
211
|
+
pushed++;
|
|
164
212
|
}
|
|
165
213
|
}
|
|
166
214
|
}
|
|
167
|
-
|
|
215
|
+
|
|
216
|
+
// Show conflicts
|
|
217
|
+
for (const p of conflictPaths) {
|
|
218
|
+
console.log(` \u26A0 ${p.replace(/^\//, '')} CONFLICT \u2014 skipped (use --force to override)`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Show unchanged
|
|
222
|
+
if (diff.unchanged.length > 0) {
|
|
223
|
+
// Don't list them all, just count
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Summary
|
|
227
|
+
console.log('');
|
|
228
|
+
const parts = [];
|
|
229
|
+
if (pushed > 0) parts.push(`${pushed} pushed`);
|
|
230
|
+
if (diff.unchanged.length > 0) parts.push(`${diff.unchanged.length} unchanged`);
|
|
231
|
+
if (conflictPaths.length > 0) parts.push(`${conflictPaths.length} conflict${conflictPaths.length > 1 ? 's' : ''}`);
|
|
232
|
+
if (parts.length > 0) console.log(` ${parts.join(', ')}.`);
|
|
233
|
+
|
|
234
|
+
// Get commit hash after push
|
|
235
|
+
let commitHash = null;
|
|
236
|
+
try {
|
|
237
|
+
const headResult = await apiRequestJson(
|
|
238
|
+
`/businesses/${businessId}/workspaces/${workspaceId}/git/head`,
|
|
239
|
+
{ method: 'GET', token: creds.token }
|
|
240
|
+
);
|
|
241
|
+
if (headResult.ok && headResult.data && headResult.data.commit) {
|
|
242
|
+
commitHash = headResult.data.commit;
|
|
243
|
+
}
|
|
244
|
+
} catch {
|
|
245
|
+
// Git might not be initialized yet
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Update manifest with new state (merge local + remote)
|
|
249
|
+
const mergedFiles = { ...remoteFiles };
|
|
250
|
+
for (const p of Object.keys(localFiles)) {
|
|
251
|
+
if (filesToPush.some(f => f.path === p)) {
|
|
252
|
+
mergedFiles[p] = localFiles[p]; // we pushed this, so our hash is now the truth
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const newManifest = buildManifest(mergedFiles, commitHash);
|
|
256
|
+
saveManifest(resolvedSlug || slug, newManifest);
|
|
168
257
|
}
|
|
169
258
|
|
|
170
259
|
module.exports = { pushAtris };
|
package/lib/manifest.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const { computeContentHash } = require('./journal');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Get the manifest file path for a business slug.
|
|
8
|
+
* Stored at ~/.atris/businesses/{slug}/manifest.json
|
|
9
|
+
*/
|
|
10
|
+
function getManifestPath(slug) {
|
|
11
|
+
return path.join(os.homedir(), '.atris', 'businesses', slug, 'manifest.json');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Load the manifest for a business, or null if no previous sync.
|
|
16
|
+
*/
|
|
17
|
+
function loadManifest(slug) {
|
|
18
|
+
const p = getManifestPath(slug);
|
|
19
|
+
if (!fs.existsSync(p)) return null;
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Save a manifest after a successful sync.
|
|
29
|
+
*/
|
|
30
|
+
function saveManifest(slug, manifest) {
|
|
31
|
+
const p = getManifestPath(slug);
|
|
32
|
+
const dir = path.dirname(p);
|
|
33
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
34
|
+
fs.writeFileSync(p, JSON.stringify(manifest, null, 2));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Compute SHA-256 hash of normalized content.
|
|
39
|
+
* Delegates to journal.js computeContentHash.
|
|
40
|
+
*/
|
|
41
|
+
function computeFileHash(content) {
|
|
42
|
+
return computeContentHash(content);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build a manifest object from a set of files.
|
|
47
|
+
* files: { [path]: { hash, size } }
|
|
48
|
+
*/
|
|
49
|
+
function buildManifest(files, commitHash) {
|
|
50
|
+
return {
|
|
51
|
+
last_sync: new Date().toISOString(),
|
|
52
|
+
last_commit: commitHash || null,
|
|
53
|
+
files,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Walk a local directory and compute hashes for all text files.
|
|
59
|
+
* Returns: { "/path": { hash, size } }
|
|
60
|
+
*/
|
|
61
|
+
const SKIP_DIRS = new Set([
|
|
62
|
+
'node_modules', '__pycache__', '.git', 'venv', '.venv',
|
|
63
|
+
'lost+found', '.cache', '.atris',
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
function computeLocalHashes(localDir) {
|
|
67
|
+
const files = {};
|
|
68
|
+
|
|
69
|
+
function walk(dir) {
|
|
70
|
+
let entries;
|
|
71
|
+
try {
|
|
72
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
73
|
+
} catch {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
for (const entry of entries) {
|
|
77
|
+
if (entry.name.startsWith('.')) continue;
|
|
78
|
+
const fullPath = path.join(dir, entry.name);
|
|
79
|
+
if (entry.isDirectory()) {
|
|
80
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
81
|
+
walk(fullPath);
|
|
82
|
+
} else if (entry.isFile()) {
|
|
83
|
+
const relPath = '/' + path.relative(localDir, fullPath);
|
|
84
|
+
try {
|
|
85
|
+
const content = fs.readFileSync(fullPath, 'utf8');
|
|
86
|
+
const hash = computeFileHash(content);
|
|
87
|
+
files[relPath] = { hash, size: Buffer.byteLength(content) };
|
|
88
|
+
} catch {
|
|
89
|
+
// skip binary or unreadable
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
walk(localDir);
|
|
96
|
+
return files;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Three-way comparison between local files, remote files, and the last-synced manifest.
|
|
101
|
+
*
|
|
102
|
+
* localFiles: { "/path": { hash, size } }
|
|
103
|
+
* remoteFiles: { "/path": { hash, size } }
|
|
104
|
+
* manifest: { files: { "/path": { hash, size } } } or null (first sync)
|
|
105
|
+
*
|
|
106
|
+
* Returns: { toPull, toPush, conflicts, unchanged, deletedLocal, deletedRemote, newLocal, newRemote }
|
|
107
|
+
* Each array contains file path strings.
|
|
108
|
+
*/
|
|
109
|
+
function threeWayCompare(localFiles, remoteFiles, manifest) {
|
|
110
|
+
const result = {
|
|
111
|
+
toPull: [],
|
|
112
|
+
toPush: [],
|
|
113
|
+
conflicts: [],
|
|
114
|
+
unchanged: [],
|
|
115
|
+
deletedLocal: [],
|
|
116
|
+
deletedRemote: [],
|
|
117
|
+
newLocal: [],
|
|
118
|
+
newRemote: [],
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// First sync — no manifest
|
|
122
|
+
if (!manifest || !manifest.files) {
|
|
123
|
+
// Everything remote is "new from remote"
|
|
124
|
+
for (const p of Object.keys(remoteFiles)) {
|
|
125
|
+
if (localFiles[p] && localFiles[p].hash === remoteFiles[p].hash) {
|
|
126
|
+
result.unchanged.push(p);
|
|
127
|
+
} else if (localFiles[p]) {
|
|
128
|
+
// Both exist with different hashes, no baseline — treat as conflict
|
|
129
|
+
result.conflicts.push(p);
|
|
130
|
+
} else {
|
|
131
|
+
result.newRemote.push(p);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// Local files not in remote are new local
|
|
135
|
+
for (const p of Object.keys(localFiles)) {
|
|
136
|
+
if (!remoteFiles[p]) {
|
|
137
|
+
result.newLocal.push(p);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const base = manifest.files;
|
|
144
|
+
const allPaths = new Set([
|
|
145
|
+
...Object.keys(localFiles),
|
|
146
|
+
...Object.keys(remoteFiles),
|
|
147
|
+
...Object.keys(base),
|
|
148
|
+
]);
|
|
149
|
+
|
|
150
|
+
for (const p of allPaths) {
|
|
151
|
+
const inLocal = p in localFiles;
|
|
152
|
+
const inRemote = p in remoteFiles;
|
|
153
|
+
const inBase = p in base;
|
|
154
|
+
|
|
155
|
+
const localHash = inLocal ? localFiles[p].hash : null;
|
|
156
|
+
const remoteHash = inRemote ? remoteFiles[p].hash : null;
|
|
157
|
+
const baseHash = inBase ? base[p].hash : null;
|
|
158
|
+
|
|
159
|
+
if (inLocal && inRemote && inBase) {
|
|
160
|
+
// All three exist — standard three-way
|
|
161
|
+
const localChanged = localHash !== baseHash;
|
|
162
|
+
const remoteChanged = remoteHash !== baseHash;
|
|
163
|
+
|
|
164
|
+
if (!localChanged && !remoteChanged) {
|
|
165
|
+
result.unchanged.push(p);
|
|
166
|
+
} else if (!localChanged && remoteChanged) {
|
|
167
|
+
result.toPull.push(p);
|
|
168
|
+
} else if (localChanged && !remoteChanged) {
|
|
169
|
+
result.toPush.push(p);
|
|
170
|
+
} else {
|
|
171
|
+
// Both changed
|
|
172
|
+
if (localHash === remoteHash) {
|
|
173
|
+
// Both changed to the same thing
|
|
174
|
+
result.unchanged.push(p);
|
|
175
|
+
} else {
|
|
176
|
+
result.conflicts.push(p);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
} else if (inRemote && !inBase && !inLocal) {
|
|
180
|
+
// New on remote
|
|
181
|
+
result.newRemote.push(p);
|
|
182
|
+
} else if (inLocal && !inBase && !inRemote) {
|
|
183
|
+
// New locally
|
|
184
|
+
result.newLocal.push(p);
|
|
185
|
+
} else if (inBase && !inRemote && inLocal) {
|
|
186
|
+
// Was in base, deleted on remote, still local
|
|
187
|
+
result.deletedRemote.push(p);
|
|
188
|
+
} else if (inBase && !inLocal && inRemote) {
|
|
189
|
+
// Was in base, deleted locally, still remote
|
|
190
|
+
result.deletedLocal.push(p);
|
|
191
|
+
} else if (inBase && !inLocal && !inRemote) {
|
|
192
|
+
// Deleted on both sides — nothing to do
|
|
193
|
+
// (don't add to any list)
|
|
194
|
+
} else if (inLocal && inRemote && !inBase) {
|
|
195
|
+
// Both sides have it but no base — new on both
|
|
196
|
+
if (localHash === remoteHash) {
|
|
197
|
+
result.unchanged.push(p);
|
|
198
|
+
} else {
|
|
199
|
+
result.conflicts.push(p);
|
|
200
|
+
}
|
|
201
|
+
} else if (inRemote && inBase && !inLocal) {
|
|
202
|
+
// Deleted locally but remote still has it (and maybe changed)
|
|
203
|
+
result.deletedLocal.push(p);
|
|
204
|
+
} else if (inLocal && inBase && !inRemote) {
|
|
205
|
+
// Deleted on remote but local still has it (and maybe changed)
|
|
206
|
+
result.deletedRemote.push(p);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return result;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
module.exports = {
|
|
214
|
+
loadManifest,
|
|
215
|
+
saveManifest,
|
|
216
|
+
computeFileHash,
|
|
217
|
+
buildManifest,
|
|
218
|
+
computeLocalHashes,
|
|
219
|
+
threeWayCompare,
|
|
220
|
+
getManifestPath,
|
|
221
|
+
SKIP_DIRS,
|
|
222
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "atris",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.1",
|
|
4
4
|
"description": "atrisDev (atris dev) - CLI for AI coding agents. Works with Claude Code, Cursor, Windsurf. Make any codebase AI-navigable.",
|
|
5
5
|
"main": "bin/atris.js",
|
|
6
6
|
"bin": {
|
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
"utils/",
|
|
13
13
|
"lib/",
|
|
14
14
|
"README.md",
|
|
15
|
-
"AGENT.md",
|
|
16
15
|
"AGENTS.md",
|
|
17
16
|
"atris.md",
|
|
18
17
|
"GETTING_STARTED.md",
|
|
@@ -20,8 +19,14 @@
|
|
|
20
19
|
"atris/atrisDev.md",
|
|
21
20
|
"atris/CLAUDE.md",
|
|
22
21
|
"atris/GEMINI.md",
|
|
23
|
-
"atris/
|
|
24
|
-
"atris/
|
|
22
|
+
"atris/GETTING_STARTED.md",
|
|
23
|
+
"atris/team/navigator/MEMBER.md",
|
|
24
|
+
"atris/team/executor/MEMBER.md",
|
|
25
|
+
"atris/team/validator/MEMBER.md",
|
|
26
|
+
"atris/team/brainstormer/MEMBER.md",
|
|
27
|
+
"atris/team/launcher/MEMBER.md",
|
|
28
|
+
"atris/team/researcher/MEMBER.md",
|
|
29
|
+
"atris/team/_template/MEMBER.md",
|
|
25
30
|
"atris/features/_templates/",
|
|
26
31
|
"atris/policies/",
|
|
27
32
|
"atris/skills/"
|
package/utils/auth.js
CHANGED
|
@@ -124,6 +124,101 @@ function getCredentialsPath() {
|
|
|
124
124
|
return path.join(getAtrisDir(), 'credentials.json');
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
function getSessionsDir() {
|
|
128
|
+
const dir = path.join(getAtrisDir(), 'sessions');
|
|
129
|
+
if (!fs.existsSync(dir)) {
|
|
130
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
131
|
+
}
|
|
132
|
+
return dir;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function getTerminalSessionId() {
|
|
136
|
+
// Unique per terminal window/tab — works across macOS terminals, tmux, VS Code, Ghostty
|
|
137
|
+
const envId = process.env.TERM_SESSION_ID // macOS Terminal.app
|
|
138
|
+
|| process.env.ITERM_SESSION_ID // iTerm2
|
|
139
|
+
|| process.env.TMUX_PANE // tmux pane
|
|
140
|
+
|| process.env.WT_SESSION // Windows Terminal
|
|
141
|
+
|| process.env.WEZTERM_PANE; // WezTerm
|
|
142
|
+
if (envId) return envId;
|
|
143
|
+
|
|
144
|
+
// Universal fallback: TTY device name (unique per terminal tab on macOS/Linux)
|
|
145
|
+
// Each Ghostty/iTerm/Terminal tab gets a unique /dev/ttysNNN
|
|
146
|
+
try {
|
|
147
|
+
// Method 1: check if stdin is a TTY and resolve its path
|
|
148
|
+
if (process.stdin.isTTY) {
|
|
149
|
+
const resolved = fs.realpathSync('/dev/stdin');
|
|
150
|
+
if (resolved && resolved.startsWith('/dev/tty')) return resolved;
|
|
151
|
+
}
|
|
152
|
+
} catch {}
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
// Method 2: shell out to tty command
|
|
156
|
+
const { execSync } = require('child_process');
|
|
157
|
+
const tty = execSync('tty', { encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'] }).trim();
|
|
158
|
+
if (tty && tty !== 'not a tty' && tty.startsWith('/dev/')) return tty;
|
|
159
|
+
} catch {}
|
|
160
|
+
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function sanitizeSessionId(id) {
|
|
165
|
+
// Make filesystem-safe: replace non-alphanumeric with dashes, truncate
|
|
166
|
+
return id.replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 64);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function getSessionFilePath() {
|
|
170
|
+
const sessionId = getTerminalSessionId();
|
|
171
|
+
if (!sessionId) return null;
|
|
172
|
+
return path.join(getSessionsDir(), `${sanitizeSessionId(sessionId)}.json`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function setSessionProfile(profileName) {
|
|
176
|
+
const sessionPath = getSessionFilePath();
|
|
177
|
+
if (!sessionPath) {
|
|
178
|
+
// No terminal session ID — fall back to global switch
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
fs.writeFileSync(sessionPath, JSON.stringify({
|
|
182
|
+
profile: profileName,
|
|
183
|
+
set_at: new Date().toISOString(),
|
|
184
|
+
}));
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function getSessionProfile() {
|
|
189
|
+
const sessionPath = getSessionFilePath();
|
|
190
|
+
if (!sessionPath || !fs.existsSync(sessionPath)) return null;
|
|
191
|
+
try {
|
|
192
|
+
const data = JSON.parse(fs.readFileSync(sessionPath, 'utf8'));
|
|
193
|
+
return data.profile || null;
|
|
194
|
+
} catch {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function clearSessionProfile() {
|
|
200
|
+
const sessionPath = getSessionFilePath();
|
|
201
|
+
if (sessionPath && fs.existsSync(sessionPath)) {
|
|
202
|
+
fs.unlinkSync(sessionPath);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function cleanStaleSessions() {
|
|
207
|
+
// Remove session files older than 7 days
|
|
208
|
+
const dir = path.join(getAtrisDir(), 'sessions');
|
|
209
|
+
if (!fs.existsSync(dir)) return;
|
|
210
|
+
const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
|
211
|
+
try {
|
|
212
|
+
for (const f of fs.readdirSync(dir)) {
|
|
213
|
+
const fp = path.join(dir, f);
|
|
214
|
+
try {
|
|
215
|
+
const stat = fs.statSync(fp);
|
|
216
|
+
if (stat.mtimeMs < cutoff) fs.unlinkSync(fp);
|
|
217
|
+
} catch {}
|
|
218
|
+
}
|
|
219
|
+
} catch {}
|
|
220
|
+
}
|
|
221
|
+
|
|
127
222
|
function getProfilesDir() {
|
|
128
223
|
const dir = path.join(getAtrisDir(), 'profiles');
|
|
129
224
|
if (!fs.existsSync(dir)) {
|
|
@@ -205,6 +300,32 @@ function saveCredentials(token, refreshToken, email, userId, provider) {
|
|
|
205
300
|
}
|
|
206
301
|
|
|
207
302
|
function loadCredentials() {
|
|
303
|
+
// Priority: ATRIS_PROFILE env var → per-terminal session file → global credentials.json
|
|
304
|
+
|
|
305
|
+
// 1. Explicit env var override
|
|
306
|
+
const profileOverride = process.env.ATRIS_PROFILE;
|
|
307
|
+
if (profileOverride) {
|
|
308
|
+
const profile = loadProfile(profileOverride);
|
|
309
|
+
if (profile) return profile;
|
|
310
|
+
const profiles = listProfiles();
|
|
311
|
+
const q = profileOverride.toLowerCase();
|
|
312
|
+
const match = profiles.find(p => p.toLowerCase() === q)
|
|
313
|
+
|| profiles.find(p => p.toLowerCase().startsWith(q))
|
|
314
|
+
|| profiles.find(p => p.toLowerCase().includes(q));
|
|
315
|
+
if (match) {
|
|
316
|
+
const matched = loadProfile(match);
|
|
317
|
+
if (matched) return matched;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// 2. Per-terminal session override (set by atris switch)
|
|
322
|
+
const sessionProfile = getSessionProfile();
|
|
323
|
+
if (sessionProfile) {
|
|
324
|
+
const profile = loadProfile(sessionProfile);
|
|
325
|
+
if (profile) return profile;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// 3. Global credentials.json
|
|
208
329
|
const credentialsPath = getCredentialsPath();
|
|
209
330
|
|
|
210
331
|
if (!fs.existsSync(credentialsPath)) {
|
|
@@ -456,4 +577,10 @@ module.exports = {
|
|
|
456
577
|
deleteProfile,
|
|
457
578
|
profileNameFromEmail,
|
|
458
579
|
autoSaveProfile,
|
|
580
|
+
// Per-terminal sessions
|
|
581
|
+
getTerminalSessionId,
|
|
582
|
+
setSessionProfile,
|
|
583
|
+
getSessionProfile,
|
|
584
|
+
clearSessionProfile,
|
|
585
|
+
cleanStaleSessions,
|
|
459
586
|
};
|