atris 3.72.5 → 3.73.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/lib/gm-phone.js CHANGED
@@ -497,22 +497,29 @@ function createPhoneHandler(deps) {
497
497
  return { status: ok ? 200 : 500, body: { ok, ack: ok ? 'undone, target back how it was' : 'could not undo' } };
498
498
  }
499
499
 
500
- // think shows the next bet from the queue (or waits on the one being made);
501
- // sharper asks for a new one; do it needs a second tap because it
502
- // starts agents; not now only marks the bet skipped.
500
+ // think shows the next card at once (a choice of ideas, one idea, or a
501
+ // question when none is ready); pick opens one idea from a choice; sharper
502
+ // asks for a new bet; answer saves the owner's answer to the question on
503
+ // screen; do it needs a second tap because it starts
504
+ // agents; not now only marks the bet skipped.
503
505
  async function betAction(p, body) {
504
506
  let out;
505
507
  if (p === '/bet/think') out = bet.next({ sharper: body.sharper === true });
508
+ else if (p === '/bet/answer') out = bet.answer({ choice: String(body.choice || ''), text: String(body.text || '') });
509
+ else if (p === '/bet/report') out = bet.report({ value: body.value });
510
+ else if (p === '/bet/pick') out = bet.pick({ id: String(body.id || '') });
511
+ else if (p === '/bet/steer') out = await bet.talk({ member: String(body.member || ''), text: String(body.text || ''), confirm: body.confirm === true, note: body.note === true });
512
+ else if (p === '/bet/say') out = await bet.say({ text: String(body.text || ''), confirm: body.confirm === true, note: body.note === true });
506
513
  else if (p === '/bet/skip') out = bet.skip();
507
514
  else if (p === '/bet/do') {
508
515
  if (body.confirm !== true) return { status: 409, body: { ok: false, reason: 'needs_confirm', confirm_ms: CONFIRM_MS } };
509
516
  out = await bet.doIt();
510
517
  } else if (p === '/bet/nice') {
511
518
  out = bet.nice();
512
- } else if (p === '/bet/sendback' || p === '/bet/yes') {
513
- // both start or unblock agents, so both need a second tap
519
+ } else if (p === '/bet/yes') {
520
+ // a yes unblocks agents, so it needs a second tap
514
521
  if (body.confirm !== true) return { status: 409, body: { ok: false, reason: 'needs_confirm', confirm_ms: CONFIRM_MS } };
515
- out = p === '/bet/yes' ? await bet.answerYes() : await bet.sendBack();
522
+ out = await bet.answerYes();
516
523
  } else return { status: 404, body: { ok: false, reason: 'not_found' } };
517
524
  return { status: out.ok ? 200 : 409, body: out };
518
525
  }
@@ -530,7 +537,9 @@ function createPhoneHandler(deps) {
530
537
  };
531
538
 
532
539
  async function handle(req, res) {
533
- const check = checkRequest(req, { token: deps.token, hosts: deps.hosts });
540
+ // the token is read on every request, so a rotated link is dead at once
541
+ const token = typeof deps.token === 'function' ? deps.token() : deps.token;
542
+ const check = checkRequest(req, { token: token || '', hosts: deps.hosts });
534
543
  if (!check.ok) return send(res, check.status, check.status === 400 ? 'bad request' : check.status === 403 ? 'forbidden' : 'method not allowed', 'text/plain; charset=utf-8');
535
544
  try {
536
545
  if (req.method === 'GET' && check.path === '/') {
@@ -541,6 +550,7 @@ function createPhoneHandler(deps) {
541
550
  }
542
551
  if (req.method === 'GET' && check.path === '/state') return send(res, 200, state());
543
552
  if (req.method === 'GET' && check.path === '/bet') return bet ? send(res, 200, bet.warm()) : send(res, 404, { ok: false, reason: 'not_found' });
553
+ if (req.method === 'GET' && check.path === '/bet/team') return bet ? send(res, 200, bet.teamTable()) : send(res, 404, { ok: false, reason: 'not_found' });
544
554
  if (req.method === 'POST' && (check.path === '/action' || check.path === '/undo' || (bet && check.path.startsWith('/bet/')))) {
545
555
  if (String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase() !== 'application/json') return send(res, 415, { ok: false, reason: 'json_only' });
546
556
  let body;
@@ -646,6 +656,45 @@ function makeStepRunner({ cliPath, cwd, logDir }) {
646
656
  });
647
657
  }
648
658
 
659
+ // The phone link's token, one per workspace, kept in the home state dir so a
660
+ // restart gives the same link. Only this user can read the file (0600).
661
+ // rotate (atris gm phone --new-link) makes a new one; the old link stops working.
662
+ const TOKEN_SHAPE = /^[A-Za-z0-9_-]{43}$/;
663
+ function phoneToken(workspaceRoot, { stateDir, rotate = false } = {}) {
664
+ const file = path.join(stateDir, 'gm-phone-links.json');
665
+ let links = {};
666
+ try {
667
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
668
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) links = parsed;
669
+ } catch {
670
+ links = {};
671
+ }
672
+ const saved = links[workspaceRoot] && links[workspaceRoot].token;
673
+ let token = saved;
674
+ let reused = true;
675
+ if (rotate || typeof saved !== 'string' || !TOKEN_SHAPE.test(saved)) {
676
+ token = crypto.randomBytes(32).toString('base64url');
677
+ reused = false;
678
+ links[workspaceRoot] = { token, created_at: new Date().toISOString() };
679
+ fs.mkdirSync(stateDir, { recursive: true });
680
+ fs.writeFileSync(file, `${JSON.stringify(links, null, 2)}\n`, { mode: 0o600 });
681
+ }
682
+ // a file loosened by hand is tightened again
683
+ try { fs.chmodSync(file, 0o600); } catch { /* read-only state dir */ }
684
+ return { token, reused };
685
+ }
686
+
687
+ // The saved token for this workspace, or null; read fresh on every request.
688
+ function readPhoneToken(workspaceRoot, { stateDir } = {}) {
689
+ try {
690
+ const links = JSON.parse(fs.readFileSync(path.join(stateDir, 'gm-phone-links.json'), 'utf8'));
691
+ const t = links && links[workspaceRoot] && links[workspaceRoot].token;
692
+ return typeof t === 'string' && TOKEN_SHAPE.test(t) ? t : null;
693
+ } catch {
694
+ return null;
695
+ }
696
+ }
697
+
649
698
  // Snapshot of the target file, so the undo puts back exactly what was there.
650
699
  function targetSnapshot(workspaceRoot, dir) {
651
700
  const gmTarget = require('./gm-target');
@@ -681,6 +730,8 @@ module.exports = {
681
730
  liveLine,
682
731
  loadPhoneView,
683
732
  makeStepRunner,
733
+ phoneToken,
734
+ readPhoneToken,
684
735
  publicCard,
685
736
  resolveChoice,
686
737
  reviewerCommand,
@@ -5,7 +5,7 @@ const knownCommands = ['init', 'guide', 'doctor', 'doc-health', 'install', 'work
5
5
  'clean', 'close', 'harvest', 'verify', 'recover', 'search', 'scout', 'skill', 'member', 'codex-goal', 'app', 'apps', 'learn', 'lesson', 'taste', 'teach', 'plugin', 'experiments', 'bench', 'router', 'tree', 'receipt', 'proof', 'openclaw', 'pull', 'push', 'watch', 'cloud', 'live', 'align', 'terminal', 'computer', 'diff', 'business', 'sync', 'youtube', 'x-search',
6
6
  'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'decide', 'campaign', 'agents', 'probe', 'worktree', 'land', 'caretaker', 'autoland', 'drive', 'aeo', 'slop', 'voice', 'strings', 'write', 'security-review', 'secure', 'deck', 'site', 'theme', 'card', 'reel', 'improve', 'study', 'rainmaker', 'xp', 'play', 'gm', 'game', 'x', 'recap', 'report', 'signup', 'clarity', 'interview', 'meet', 'moves', 'unknowns', 'avail', 'scoreboard', 'sync-checkout', 'zen',
7
7
  'ci', 'github', 'vercel', 'supabase', 'linear', 'stripe', 'balance', 'usage', 'api-key', 'agent-client', 'topup', 'buy', 'transactions', 'hire', 'hires', 'mail', 'design', 'mcp', 'gmail', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
8
- 'fork', 'browse', 'publish', 'pack', 'sleep', 'wake', 'feedback', 'errors', 'wiki', 'code-review', 'cr', 'soul', 'fleet', 'fleet-report', 'loops', 'self-improve', 'compile', 'spaceship', 'truth', 'sign', 'engine', 'engines', 'link', 'playbook', 'feed', 'brief', 'social', 'host', 'people', 'follow', 'unfollow', 'friends', 'msg', 'inbox', 'invite', 'join', 'rsi'];
8
+ 'fork', 'browse', 'publish', 'pack', 'sleep', 'wake', 'feedback', 'errors', 'wiki', 'code-review', 'cr', 'soul', 'fleet', 'fleet-report', 'loops', 'self-improve', 'compile', 'spaceship', 'truth', 'sign', 'engine', 'engines', 'link', 'playbook', 'feed', 'brief', 'social', 'host', 'people', 'follow', 'unfollow', 'friends', 'msg', 'inbox', 'invite', 'join', 'rsi', 'pay', 'trust'];
9
9
 
10
10
  // Damerau-Levenshtein edit distance between two short strings. Counts an
11
11
  // adjacent transposition (e.g. "taks" -> "task") as a single edit, since
@@ -0,0 +1,442 @@
1
+ 'use strict';
2
+
3
+ // Try page: someone pastes a public GitHub link and sees their repo's atris map.
4
+ // The repo is shallow-cloned into a throwaway folder with no git credentials,
5
+ // `atris init --minimal` runs there, a map with file:line refs is built from the
6
+ // tree, and the folder is deleted. Nothing is ever pushed or written to GitHub.
7
+
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+ const http = require('http');
12
+ const https = require('https');
13
+ const { spawn } = require('child_process');
14
+ const { detectProjectContext } = require('../commands/init');
15
+
16
+ const LIMITS = Object.freeze({ timeoutMs: 60_000, maxBytes: 200 * 1024 * 1024 });
17
+ const ATRIS_BIN = path.join(__dirname, '..', 'bin', 'atris.js');
18
+ const SKIP_DIRS = new Set(['.git', 'node_modules', 'atris', '.atris', 'vendor', 'dist', 'build', 'target', '__pycache__', '.venv', 'venv', '.next', 'coverage']);
19
+ const LANGUAGES = {
20
+ '.js': 'JavaScript', '.mjs': 'JavaScript', '.cjs': 'JavaScript', '.jsx': 'JavaScript',
21
+ '.ts': 'TypeScript', '.tsx': 'TypeScript', '.py': 'Python', '.go': 'Go', '.rs': 'Rust',
22
+ '.rb': 'Ruby', '.java': 'Java', '.kt': 'Kotlin', '.swift': 'Swift', '.c': 'C', '.h': 'C',
23
+ '.cc': 'C++', '.cpp': 'C++', '.hpp': 'C++', '.cs': 'C#', '.php': 'PHP', '.ex': 'Elixir',
24
+ '.exs': 'Elixir', '.scala': 'Scala', '.sh': 'Shell', '.vue': 'Vue', '.svelte': 'Svelte',
25
+ };
26
+ const SYMBOL_PATTERNS = {
27
+ JavaScript: [/^\s*export\s+(?:default\s+)?(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)/, /^\s*(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)\s*\(/, /^\s*(?:export\s+)?(?:default\s+)?class\s+([A-Za-z_$][\w$]*)/, /^\s*export\s+const\s+([A-Za-z_$][\w$]*)/],
28
+ Python: [/^(?:async\s+)?def\s+([A-Za-z_]\w*)/, /^class\s+([A-Za-z_]\w*)/],
29
+ Go: [/^func\s+(?:\([^)]*\)\s*)?([A-Za-z_]\w*)\s*[([]/, /^type\s+([A-Za-z_]\w*)\s+(?:struct|interface)/],
30
+ Rust: [/^\s*pub(?:\([^)]*\))?\s+(?:async\s+)?(?:fn|struct|enum|trait)\s+([A-Za-z_]\w*)/, /^fn\s+([A-Za-z_]\w*)/],
31
+ Ruby: [/^\s*(?:class|module)\s+([A-Z]\w*)/, /^\s*def\s+(?:self\.)?([a-z_]\w*[?!]?)/],
32
+ Java: [/^\s*public\s+(?:final\s+|abstract\s+)*(?:class|interface|enum|record)\s+([A-Za-z_]\w*)/],
33
+ Kotlin: [/^\s*(?:data\s+|sealed\s+|open\s+)?(?:class|object|interface)\s+([A-Za-z_]\w*)/, /^fun\s+([A-Za-z_]\w*)/],
34
+ };
35
+ SYMBOL_PATTERNS.TypeScript = SYMBOL_PATTERNS.JavaScript;
36
+ const ENTRY_NAMES = /^(?:index|main|app|server|cli|lib|mod|__main__|manage)\.[a-z]+$/i;
37
+
38
+ function fail(message, code) {
39
+ const error = new Error(message);
40
+ error.code = code;
41
+ return error;
42
+ }
43
+
44
+ // Accepts https://github.com/o/r, github.com/o/r, git@github.com:o/r.git, or o/r.
45
+ // Anything else is refused before a single network call.
46
+ function parseRepoUrl(input) {
47
+ let text = String(input || '').trim();
48
+ if (!text) throw fail('Paste a public GitHub link, like github.com/owner/repo.', 'bad_url');
49
+ text = text.replace(/^git@github\.com:/i, 'github.com/').replace(/^https?:\/\//i, '').replace(/^www\./i, '');
50
+ const parts = text.split(/[?#]/)[0].split('/').filter(Boolean);
51
+ if (parts.length > 2 || /\./.test(parts[0] || '')) {
52
+ if (!/^github\.com$/i.test(parts[0] || '')) throw fail('Only public GitHub links work here.', 'bad_host');
53
+ parts.shift();
54
+ }
55
+ const owner = parts[0] || '';
56
+ const repo = (parts[1] || '').replace(/\.git$/i, '');
57
+ if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(owner) || !/^[A-Za-z0-9._-]{1,100}$/.test(repo) || /^\.+$/.test(repo)) {
58
+ throw fail('That does not look like a GitHub repo link. Try github.com/owner/repo.', 'bad_url');
59
+ }
60
+ const slug = `${owner}/${repo}`;
61
+ return { owner, repo, slug, webUrl: `https://github.com/${slug}`, cloneUrl: `https://github.com/${slug}.git` };
62
+ }
63
+
64
+ function getJson(url, { timeoutMs = 10_000 } = {}) {
65
+ return new Promise((resolve, reject) => {
66
+ const req = https.get(url, { headers: { 'User-Agent': 'atris-try', Accept: 'application/vnd.github+json' }, timeout: timeoutMs }, (res) => {
67
+ let body = '';
68
+ res.setEncoding('utf8');
69
+ res.on('data', (chunk) => { if (body.length < 1_000_000) body += chunk; });
70
+ res.on('end', () => {
71
+ let json = null;
72
+ try { json = JSON.parse(body); } catch {}
73
+ resolve({ status: res.statusCode, json });
74
+ });
75
+ });
76
+ req.on('timeout', () => req.destroy(fail('GitHub took too long to answer.', 'timeout')));
77
+ req.on('error', reject);
78
+ });
79
+ }
80
+
81
+ // Asks GitHub without credentials. A private repo answers 404 here, and the
82
+ // reported size lets us refuse a huge repo before cloning. A rate-limited or
83
+ // unreachable API returns null; the clone itself still has no credentials and
84
+ // a live size watch, so the caps hold either way.
85
+ async function checkPublicRepo(slug, { fetchJson = getJson, maxBytes = LIMITS.maxBytes } = {}) {
86
+ let res;
87
+ try {
88
+ res = await fetchJson(`https://api.github.com/repos/${slug}`);
89
+ } catch {
90
+ return null;
91
+ }
92
+ if (res.status === 404) throw fail('That repo is private or does not exist. Only public repos work here.', 'not_public');
93
+ if (res.status !== 200 || !res.json) return null;
94
+ if (res.json.private) throw fail('That repo is private. Only public repos work here.', 'not_public');
95
+ const sizeBytes = Number(res.json.size || 0) * 1024;
96
+ if (sizeBytes > maxBytes) throw fail(reasonText('too_big', maxBytes), 'too_big');
97
+ return { sizeBytes, defaultBranch: res.json.default_branch || null, stars: res.json.stargazers_count || 0 };
98
+ }
99
+
100
+ // Built from scratch, never copied from process.env, so no token on the host
101
+ // can reach git or atris. Isolated git config means no credential helper runs.
102
+ function sandboxEnv(home) {
103
+ return {
104
+ PATH: process.env.PATH || '/usr/bin:/bin',
105
+ HOME: home,
106
+ USER: 'visitor',
107
+ LANG: 'C.UTF-8',
108
+ TMPDIR: home,
109
+ GIT_TERMINAL_PROMPT: '0',
110
+ GIT_CONFIG_NOSYSTEM: '1',
111
+ GIT_CONFIG_GLOBAL: os.devNull,
112
+ GIT_ASKPASS: 'false',
113
+ CI: '1',
114
+ };
115
+ }
116
+
117
+ function dirBytes(dir, stopAt = Infinity) {
118
+ let total = 0;
119
+ const stack = [dir];
120
+ while (stack.length) {
121
+ const current = stack.pop();
122
+ let entries;
123
+ try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { continue; }
124
+ for (const entry of entries) {
125
+ const full = path.join(current, entry.name);
126
+ if (entry.isDirectory()) stack.push(full);
127
+ else if (entry.isFile()) {
128
+ try { total += fs.lstatSync(full).size; } catch {}
129
+ if (total > stopAt) return total;
130
+ }
131
+ }
132
+ }
133
+ return total;
134
+ }
135
+
136
+ // Runs a command and kills its whole process group at the deadline or when the
137
+ // watched folder grows past maxBytes.
138
+ function runBounded(cmd, args, { cwd, env, deadline, watchDir, maxBytes }) {
139
+ return new Promise((resolve) => {
140
+ const remaining = deadline - Date.now();
141
+ if (remaining <= 0) return resolve({ code: null, reason: 'timeout', stderr: '' });
142
+ const child = spawn(cmd, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'], detached: process.platform !== 'win32' });
143
+ let stderr = '';
144
+ let reason = null;
145
+ child.stdout.on('data', () => {});
146
+ child.stderr.on('data', (chunk) => { if (stderr.length < 4000) stderr += chunk; });
147
+ const kill = (why) => {
148
+ if (reason) return;
149
+ reason = why;
150
+ try { process.platform === 'win32' ? child.kill('SIGKILL') : process.kill(-child.pid, 'SIGKILL'); } catch {}
151
+ };
152
+ const timer = setTimeout(() => kill('timeout'), remaining);
153
+ const watch = watchDir && maxBytes ? setInterval(() => {
154
+ if (dirBytes(watchDir, maxBytes) > maxBytes) kill('too_big');
155
+ }, 250) : null;
156
+ child.on('error', (error) => { stderr += String(error.message || error); });
157
+ child.on('close', (code) => {
158
+ clearTimeout(timer);
159
+ if (watch) clearInterval(watch);
160
+ resolve({ code, reason, stderr });
161
+ });
162
+ });
163
+ }
164
+
165
+ function walkRepo(root, { maxFiles = 20_000 } = {}) {
166
+ const files = [];
167
+ const stack = [''];
168
+ while (stack.length && files.length < maxFiles) {
169
+ const rel = stack.pop();
170
+ let entries;
171
+ try { entries = fs.readdirSync(path.join(root, rel), { withFileTypes: true }); } catch { continue; }
172
+ entries.sort((a, b) => a.name.localeCompare(b.name));
173
+ for (const entry of entries) {
174
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name;
175
+ if (entry.isDirectory()) {
176
+ if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith('.')) stack.push(childRel);
177
+ } else if (entry.isFile()) {
178
+ let size = 0;
179
+ try { size = fs.lstatSync(path.join(root, childRel)).size; } catch {}
180
+ files.push({ rel: childRel, size });
181
+ if (files.length >= maxFiles) break;
182
+ }
183
+ }
184
+ }
185
+ return files;
186
+ }
187
+
188
+ function findSymbols(root, rel, language, limit) {
189
+ const patterns = SYMBOL_PATTERNS[language];
190
+ if (!patterns) return [];
191
+ let text;
192
+ try { text = fs.readFileSync(path.join(root, rel), 'utf8'); } catch { return []; }
193
+ const symbols = [];
194
+ const seen = new Set();
195
+ const lines = text.split('\n');
196
+ for (let i = 0; i < lines.length && symbols.length < limit; i++) {
197
+ for (const pattern of patterns) {
198
+ const match = lines[i].match(pattern);
199
+ if (match && !seen.has(match[1])) {
200
+ seen.add(match[1]);
201
+ symbols.push({ file: rel, line: i + 1, name: match[1] });
202
+ break;
203
+ }
204
+ }
205
+ }
206
+ return symbols;
207
+ }
208
+
209
+ // Examples, docs, and fixtures are real files but not where the program lives,
210
+ // so they never count as a place to start reading.
211
+ function isSidePath(rel) {
212
+ return rel.split('/').slice(0, -1).some((part) => /^(examples?|samples?|docs?|demos?|benchmarks?|bench|fixtures?|testdata)$/i.test(part));
213
+ }
214
+
215
+ function isTestPath(rel) {
216
+ return /(^|\/)(tests?|__tests__|spec)(\/|\.[a-z]+$|$)|[._-](test|spec)\.[a-z]+$|_test\.go$|^test_.*\.py$|\/test_[^/]*\.py$/i.test(rel);
217
+ }
218
+
219
+ // The map itself: what the repo is made of, where to start reading, and the
220
+ // first named things in the files that matter most, each with a file:line ref.
221
+ function buildRepoMap(root, { maxSymbolFiles = 12, symbolsPerFile = 5 } = {}) {
222
+ const files = walkRepo(root);
223
+ const profile = detectProjectContext(root);
224
+ const byLanguage = new Map();
225
+ const byDir = new Map();
226
+ const codeByDir = new Map();
227
+ let bytes = 0;
228
+ let testFiles = 0;
229
+ for (const file of files) {
230
+ bytes += file.size;
231
+ const language = LANGUAGES[path.extname(file.rel).toLowerCase()];
232
+ if (language) byLanguage.set(language, (byLanguage.get(language) || 0) + 1);
233
+ const top = file.rel.includes('/') ? `${file.rel.split('/')[0]}/` : '(top level)';
234
+ byDir.set(top, (byDir.get(top) || 0) + 1);
235
+ if (isTestPath(file.rel)) testFiles++;
236
+ else if (language && !isSidePath(file.rel) && top !== '(top level)') codeByDir.set(top, (codeByDir.get(top) || 0) + 1);
237
+ }
238
+ const languages = [...byLanguage.entries()].sort((a, b) => b[1] - a[1]).map(([name, count]) => ({ name, files: count }));
239
+ const topDirs = [...byDir.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12).map(([name, count]) => ({ name, files: count }));
240
+ const codeDirs = [...codeByDir.entries()].sort((a, b) => b[1] - a[1]).map(([name, count]) => ({ name, files: count }));
241
+ const codeFiles = files.filter((file) => LANGUAGES[path.extname(file.rel).toLowerCase()] && file.size < 512 * 1024 && !isTestPath(file.rel) && !isSidePath(file.rel));
242
+ const depth = (rel) => rel.split('/').length;
243
+ const entryPoints = codeFiles
244
+ .filter((file) => ENTRY_NAMES.test(path.basename(file.rel)) && depth(file.rel) <= 3)
245
+ .sort((a, b) => depth(a.rel) - depth(b.rel) || a.rel.localeCompare(b.rel))
246
+ .slice(0, 6)
247
+ .map((file) => file.rel);
248
+ const ranked = [
249
+ ...entryPoints,
250
+ ...codeFiles
251
+ .filter((file) => !entryPoints.includes(file.rel))
252
+ .sort((a, b) => depth(a.rel) - depth(b.rel) || b.size - a.size)
253
+ .map((file) => file.rel),
254
+ ];
255
+ const symbols = [];
256
+ for (const rel of ranked) {
257
+ if (symbols.length >= maxSymbolFiles * symbolsPerFile) break;
258
+ const found = findSymbols(root, rel, LANGUAGES[path.extname(rel).toLowerCase()], symbolsPerFile);
259
+ if (found.length) symbols.push(...found);
260
+ if (new Set(symbols.map((s) => s.file)).size >= maxSymbolFiles) break;
261
+ }
262
+ const readme = files.find((file) => /^readme(\.|$)/i.test(file.rel));
263
+ return {
264
+ files: files.length,
265
+ bytes,
266
+ testFiles,
267
+ type: profile.type,
268
+ framework: profile.framework,
269
+ testCommand: profile.testCommand,
270
+ languages,
271
+ topDirs,
272
+ codeDirs,
273
+ entryPoints,
274
+ readme: readme ? readme.rel : null,
275
+ symbols,
276
+ };
277
+ }
278
+
279
+ // Three moves atris would make first, picked from what the map shows.
280
+ function nextMoves(map) {
281
+ const moves = [];
282
+ const biggest = (map.codeDirs || [])[0];
283
+ moves.push(biggest
284
+ ? `Fill in the map for ${biggest.name}, the biggest code folder with ${biggest.files} files, so any agent finds code there in one hop.`
285
+ : 'Fill in the map with line refs for every file, so any agent finds code in one hop.');
286
+ moves.push(map.testFiles
287
+ ? `Run your ${map.testFiles} test files before every change and block the change if they fail.`
288
+ : 'Add a first test so every change has a check that can fail.');
289
+ moves.push(map.entryPoints.length
290
+ ? `Start the task list from ${map.entryPoints[0]}, where the program begins.`
291
+ : 'Start a task list from the readme, one small change at a time.');
292
+ return moves;
293
+ }
294
+
295
+ function renderMapMarkdown(map, repo) {
296
+ const lines = [`# MAP.md`, '', `> Built by the atris try page from ${repo.webUrl}.`, ''];
297
+ lines.push(`## What this is`, '', `- ${map.files} files, ${map.languages.map((l) => `${l.name} (${l.files})`).join(', ') || 'no code files found'}`);
298
+ if (map.type !== 'unknown') lines.push(`- project type: ${map.type}${map.framework && !['none', map.type].includes(map.framework) ? ` (${map.framework})` : ''}`);
299
+ lines.push(`- test files: ${map.testFiles}`, '');
300
+ if (map.entryPoints.length) lines.push('## Start here', '', ...map.entryPoints.map((rel) => `- \`${rel}:1\``), '');
301
+ lines.push('## Folders', '', ...map.topDirs.map((dir) => `- \`${dir.name}\` ${dir.files} files`), '');
302
+ if (map.symbols.length) {
303
+ lines.push('## Where things live', '');
304
+ for (const symbol of map.symbols) lines.push(`- \`${symbol.file}:${symbol.line}\` \`${symbol.name}\``);
305
+ lines.push('');
306
+ }
307
+ return lines.join('\n');
308
+ }
309
+
310
+ function escapeHtml(text) {
311
+ return String(text).replace(/[&<>"']/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch]));
312
+ }
313
+
314
+ const PAGE_STYLE = 'body{font:16px/1.5 system-ui,sans-serif;max-width:760px;margin:40px auto;padding:0 20px;color:#111}code,pre{font:14px ui-monospace,monospace}pre{background:#f5f5f4;padding:16px;overflow:auto;border-radius:6px}input{font:inherit;padding:8px;width:70%}button{font:inherit;padding:8px 14px}.err{color:#a1260d}';
315
+
316
+ function page(title, body) {
317
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(title)}</title><style>${PAGE_STYLE}</style></head><body>${body}</body></html>`;
318
+ }
319
+
320
+ function renderFormHtml({ error = '', value = '' } = {}) {
321
+ return page('see your repo through atris', `<h1>see your repo through atris</h1>
322
+ <p>paste a public github link. in about a minute you get the map atris would build for your code.</p>
323
+ <form action="/try" method="get"><input name="repo" placeholder="github.com/owner/repo" value="${escapeHtml(value)}" required> <button>map it</button></form>
324
+ ${error ? `<p class="err">${escapeHtml(error)}</p>` : ''}`);
325
+ }
326
+
327
+ function renderResultHtml(result) {
328
+ if (!result.ok) return renderFormHtml({ error: result.error, value: result.input || '' });
329
+ const { map, repo } = result;
330
+ const found = `${map.files} files, mostly ${map.languages.slice(0, 3).map((l) => l.name).join(', ') || 'docs and config'}, with ${map.testFiles} test files.`;
331
+ return page(`${repo.slug} through atris`, `<h1>${escapeHtml(repo.slug)}</h1>
332
+ <h2>what atris found in your code</h2>
333
+ <p>${escapeHtml(found)}</p>
334
+ <pre>${escapeHtml(result.markdown)}</pre>
335
+ <h2>three moves atris would make next</h2>
336
+ <ol>${result.moves.map((move) => `<li>${escapeHtml(move)}</li>`).join('')}</ol>
337
+ <p>run it on your machine:</p>
338
+ <pre>npm i -g atris</pre>
339
+ <p><small>mapped in ${(result.ms / 1000).toFixed(1)}s. the copy was deleted after mapping. nothing was written to github.</small></p>`);
340
+ }
341
+
342
+ function reasonText(reason, maxBytes) {
343
+ if (reason === 'too_big') return `That repo is over ${Math.max(1, Math.round(maxBytes / 1048576))} MB. The try page maps smaller repos.`;
344
+ return 'That repo took longer than a minute to map. Try a smaller one.';
345
+ }
346
+
347
+ async function tryRepo(input, opts = {}) {
348
+ const started = Date.now();
349
+ const timeoutMs = opts.timeoutMs || LIMITS.timeoutMs;
350
+ const maxBytes = opts.maxBytes || LIMITS.maxBytes;
351
+ const deadline = started + timeoutMs;
352
+ let sandbox = null;
353
+ try {
354
+ const repo = parseRepoUrl(input);
355
+ await checkPublicRepo(repo.slug, { fetchJson: opts.fetchJson, maxBytes });
356
+ sandbox = fs.mkdtempSync(path.join(opts.tmpRoot || os.tmpdir(), 'atris-try-'));
357
+ const home = path.join(sandbox, 'home');
358
+ const dir = path.join(sandbox, 'repo');
359
+ fs.mkdirSync(home);
360
+ const env = sandboxEnv(home);
361
+ const clone = await runBounded('git', ['clone', '--quiet', '--depth', '1', '--single-branch', '--no-tags', '--', opts.cloneUrl || repo.cloneUrl, dir], { cwd: sandbox, env, deadline, watchDir: sandbox, maxBytes });
362
+ if (clone.reason) throw fail(reasonText(clone.reason, maxBytes), clone.reason);
363
+ if (clone.code !== 0) throw fail('Could not copy that repo. It may be private, empty, or gone.', 'clone_failed');
364
+ if (dirBytes(dir, maxBytes) > maxBytes) throw fail(reasonText('too_big', maxBytes), 'too_big');
365
+ const init = await runBounded(process.execPath, [ATRIS_BIN, 'init', '--minimal', '--yes', '--no-commit'], { cwd: dir, env, deadline });
366
+ if (init.reason) throw fail(reasonText(init.reason, maxBytes), init.reason);
367
+ if (init.code !== 0) throw fail('atris could not set up in that repo.', 'init_failed');
368
+ const map = buildRepoMap(dir);
369
+ const markdown = renderMapMarkdown(map, repo);
370
+ fs.writeFileSync(path.join(dir, 'atris', 'MAP.md'), markdown);
371
+ const result = { ok: true, input, repo, map, markdown, moves: nextMoves(map), ms: Date.now() - started };
372
+ result.html = renderResultHtml(result);
373
+ return result;
374
+ } catch (error) {
375
+ const result = { ok: false, input, code: error.code || 'error', error: error.code ? error.message : 'Something went wrong mapping that repo.', ms: Date.now() - started };
376
+ result.html = renderResultHtml(result);
377
+ return result;
378
+ } finally {
379
+ if (sandbox && !opts.keep) fs.rmSync(sandbox, { recursive: true, force: true });
380
+ }
381
+ }
382
+
383
+ // Small public server. Each visitor gets one map at a time and a few per window,
384
+ // and the whole box runs a bounded number of maps at once.
385
+ function createTryServer({ run = tryRepo, perWindow = 5, windowMs = 10 * 60_000, maxConcurrent = 2, trustProxy = false } = {}) {
386
+ const visits = new Map();
387
+ const busy = new Set();
388
+ let running = 0;
389
+ const send = (res, status, html) => {
390
+ res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
391
+ res.end(html);
392
+ };
393
+ return http.createServer(async (req, res) => {
394
+ const url = new URL(req.url, 'http://localhost');
395
+ if (req.method !== 'GET') return send(res, 405, renderFormHtml({ error: 'Use the form.' }));
396
+ if (url.pathname === '/') return send(res, 200, renderFormHtml());
397
+ if (url.pathname !== '/try') return send(res, 404, renderFormHtml({ error: 'Nothing here.' }));
398
+ const forwarded = trustProxy ? String(req.headers['x-forwarded-for'] || '').split(',')[0].trim() : '';
399
+ const ip = forwarded || req.socket.remoteAddress || 'unknown';
400
+ const now = Date.now();
401
+ const recent = (visits.get(ip) || []).filter((t) => now - t < windowMs);
402
+ if (busy.has(ip) || recent.length >= perWindow) {
403
+ return send(res, 429, renderFormHtml({ error: 'You have mapped a few repos already. Try again in a few minutes.' }));
404
+ }
405
+ if (running >= maxConcurrent) return send(res, 503, renderFormHtml({ error: 'The page is busy right now. Try again in a minute.' }));
406
+ recent.push(now);
407
+ visits.set(ip, recent);
408
+ busy.add(ip);
409
+ running++;
410
+ try {
411
+ const result = await run(url.searchParams.get('repo') || '');
412
+ send(res, result.ok ? 200 : 400, result.html);
413
+ } finally {
414
+ busy.delete(ip);
415
+ running--;
416
+ }
417
+ });
418
+ }
419
+
420
+ // Host the page: `PORT=8080 TRY_TRUST_PROXY=1 node lib/try-repo.js`.
421
+ // Trust the proxy header only behind a proxy you run, or visitors can fake it.
422
+ function startTryServer(env = process.env) {
423
+ const port = Number(env.PORT) || 8787;
424
+ const host = env.HOST || '0.0.0.0';
425
+ const server = createTryServer({ trustProxy: env.TRY_TRUST_PROXY === '1' });
426
+ server.listen(port, host, () => console.log(`try page listening on http://${host}:${server.address().port}`));
427
+ return server;
428
+ }
429
+
430
+ if (require.main === module) startTryServer();
431
+
432
+ module.exports = {
433
+ parseRepoUrl,
434
+ checkPublicRepo,
435
+ sandboxEnv,
436
+ buildRepoMap,
437
+ renderMapMarkdown,
438
+ renderResultHtml,
439
+ nextMoves,
440
+ tryRepo,
441
+ createTryServer,
442
+ };
package/lib/zen-apply.js CHANGED
@@ -15,6 +15,7 @@ const {
15
15
  checkZen,
16
16
  defaultBranch,
17
17
  dirtyEntries,
18
+ dirtyFiles,
18
19
  FRESH_MINUTES,
19
20
  gitOut,
20
21
  headMovedSince,
@@ -188,8 +189,9 @@ function saveStrayEdits(root, teamState, tag, cutoff) {
188
189
  for (const entry of dirtyEntries(root)) {
189
190
  if (isTeamState(entry.path, teamState)) continue;
190
191
  const paths = entry.xy === '??' && entry.path.endsWith('/')
191
- ? untrackedFiles(root, entry.path)
192
+ ? untrackedFiles(root, entry.path).filter((p) => !isTeamState(p, teamState))
192
193
  : [entry.path, entry.orig].filter(Boolean);
194
+ if (!paths.length) continue;
193
195
  const isFresh = paths.some((p) => mtime(path.join(root, p)) > cutoff);
194
196
  for (const p of paths) (isFresh ? fresh : files).add(p);
195
197
  }
@@ -365,7 +367,15 @@ function moveKeepingNotes(root, args, target, tag, from = 'HEAD') {
365
367
  .flatMap((e) => (e.path.endsWith('/') ? untrackedFiles(root, e.path) : [e.path]))
366
368
  .filter((p) => changed.has(p))
367
369
  .concat(added);
368
- if (!tracked.length && !untracked.length) return { ok: false, error: firstLine(first.stderr) };
370
+ // A note you moved with git mv whose old path changed upstream: the old
371
+ // path goes back to HEAD so the move can run, and the new edits are merged
372
+ // into your moved copy afterwards.
373
+ const renamed = dirty.filter((e) => e.xy[0] === 'R' && e.orig && changed.has(e.orig) && !changed.has(e.path));
374
+ for (const e of renamed) {
375
+ runGit(['reset', '-q', '--', e.orig, e.path], root);
376
+ runGit(['checkout', '-q', 'HEAD', '--', e.orig], root);
377
+ }
378
+ if (!tracked.length && !untracked.length && !renamed.length) return { ok: false, error: firstLine(first.stderr) };
369
379
 
370
380
  const dir = path.join(commonDir(root), 'atris-zen', tag);
371
381
  fs.mkdirSync(dir, { recursive: true });
@@ -423,6 +433,16 @@ function moveKeepingNotes(root, args, target, tag, from = 'HEAD') {
423
433
  fs.copyFileSync(saved, path.join(root, p));
424
434
  }
425
435
  }
436
+ for (const e of renamed) {
437
+ const old = path.join(root, e.orig);
438
+ if (second.status !== 0 || !fs.existsSync(old)) continue;
439
+ if (fromCommit && followMove(root, fromCommit, e.orig, e.path, old)) {
440
+ fs.rmSync(old, { force: true });
441
+ notes.push(`${e.orig} changed upstream after you moved it to ${e.path}; the new edits went with it`);
442
+ } else {
443
+ notes.push(`${e.orig} changed upstream after you moved it to ${e.path}; the new version is back at ${e.orig}`);
444
+ }
445
+ }
426
446
  if (second.status !== 0) return { ok: false, error: firstLine(second.stderr), notes, tracked, landed };
427
447
  return { ok: true, notes, tracked, landed };
428
448
  }
@@ -522,7 +542,7 @@ function applyZen({ cwd = process.cwd(), fetch = true, freshMinutes, abandonedDa
522
542
 
523
543
  let branch = gitOut(['symbolic-ref', '--quiet', '--short', 'HEAD'], root);
524
544
  if (main && branch !== main) {
525
- const stillStray = dirtyEntries(root).some((e) => !isTeamState(e.path, teamState));
545
+ const stillStray = dirtyFiles(root).some((p) => !isTeamState(p, teamState));
526
546
  if (stillStray || homeInUse) {
527
547
  left.push(`stayed on ${branch || 'a detached commit'}, because it was used in ${window}`);
528
548
  } else {