atris 3.72.5 → 3.73.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.
- package/README.md +2 -0
- package/atris/skills/commerce/SKILL.md +24 -3
- package/bin/atris.js +10 -0
- package/commands/gm.js +16 -5
- package/commands/member.js +59 -9
- package/commands/pay.js +329 -0
- package/commands/trust.js +117 -0
- package/commands/zen.js +17 -0
- package/lib/gm-bet.js +1443 -162
- package/lib/gm-phone-page.js +460 -357
- package/lib/gm-phone.js +58 -7
- package/lib/known-commands.js +1 -1
- package/lib/try-repo.js +442 -0
- package/lib/zen-apply.js +23 -3
- package/lib/zen-fleet.js +100 -0
- package/lib/zen.js +41 -6
- package/package.json +1 -1
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
|
|
501
|
-
//
|
|
502
|
-
//
|
|
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/
|
|
513
|
-
//
|
|
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 =
|
|
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
|
-
|
|
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,
|
package/lib/known-commands.js
CHANGED
|
@@ -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
|
package/lib/try-repo.js
ADDED
|
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
|
-
|
|
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 =
|
|
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 {
|