atris 3.71.3 → 3.72.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/atris/skills/campaigns/SKILL.md +1 -1
- package/bin/atris.js +7 -1
- package/commands/auth.js +29 -2
- package/commands/autoland.js +102 -2
- package/commands/campaign.js +86 -13
- package/commands/clean.js +60 -4
- package/commands/gm.js +1273 -7
- package/commands/member.js +28 -3
- package/commands/mission.js +72 -23
- package/commands/sync.js +2 -2
- package/commands/task.js +49 -13
- package/commands/zen.js +183 -0
- package/lib/auto-accept-certified.js +9 -4
- package/lib/campaign-cadence.js +15 -5
- package/lib/campaign-core.js +44 -17
- package/lib/campaign-loop.js +45 -14
- package/lib/campaign-reading.js +14 -1
- package/lib/campaign-rsi.js +28 -11
- package/lib/campaign-schedule.js +124 -0
- package/lib/campaign-store.js +34 -2
- package/lib/campaign-trace.js +40 -14
- package/lib/engine-resource.js +5 -1
- package/lib/gm-bet.js +2219 -0
- package/lib/gm-phone-page.js +1102 -0
- package/lib/gm-phone.js +682 -0
- package/lib/gm-season.js +1162 -0
- package/lib/gm-target.js +292 -0
- package/lib/job-code-verify.js +169 -0
- package/lib/known-commands.js +1 -1
- package/lib/member-autoland.js +105 -14
- package/lib/member-progress.js +2 -2
- package/lib/member-report.js +11 -1
- package/lib/member-self-next-job.js +6 -1
- package/lib/member-up.js +17 -8
- package/lib/merged-review-close.js +206 -0
- package/lib/mission-protected-lane.js +52 -4
- package/lib/qr.js +338 -0
- package/lib/site-publish.js +16 -2
- package/lib/site.js +17 -7
- package/lib/yes-lock.js +530 -0
- package/lib/zen-apply.js +619 -0
- package/lib/zen-workspace.js +121 -0
- package/lib/zen.js +409 -0
- package/package.json +1 -1
- package/utils/api.js +15 -2
- package/utils/auth.js +8 -7
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The workspace and understanding layers of atris zen. Zen runs the safe,
|
|
4
|
+
// existing cleanups (`atris clean`), then names the few things only a person
|
|
5
|
+
// can do, each with one command. That short list is the shared picture: zen
|
|
6
|
+
// did everything it could, and here is exactly what is left for you.
|
|
7
|
+
|
|
8
|
+
const fs = require('node:fs');
|
|
9
|
+
const path = require('node:path');
|
|
10
|
+
const { spawnSync } = require('node:child_process');
|
|
11
|
+
|
|
12
|
+
const CLI = path.join(__dirname, '..', 'bin', 'atris.js');
|
|
13
|
+
|
|
14
|
+
function plural(n, one, many) {
|
|
15
|
+
return `${n} ${n === 1 ? one : many}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function runCliJson(args, root) {
|
|
19
|
+
const result = spawnSync(process.execPath, [CLI, ...args], {
|
|
20
|
+
cwd: root,
|
|
21
|
+
encoding: 'utf8',
|
|
22
|
+
timeout: 180000,
|
|
23
|
+
env: { ...process.env, ATRIS_SKIP_UPDATE_CHECK: '1', ATRIS_NONINTERACTIVE: '1' },
|
|
24
|
+
});
|
|
25
|
+
const text = String(result.stdout || '');
|
|
26
|
+
const start = text.indexOf('{');
|
|
27
|
+
if (start < 0) return null;
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(text.slice(start));
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The map as it stands on disk: MAP.md plus any split files under atris/map/.
|
|
36
|
+
function mapSnapshot(root) {
|
|
37
|
+
const files = [path.join(root, 'atris', 'MAP.md')];
|
|
38
|
+
const dir = path.join(root, 'atris', 'map');
|
|
39
|
+
try {
|
|
40
|
+
for (const name of fs.readdirSync(dir).sort()) files.push(path.join(dir, name));
|
|
41
|
+
} catch {
|
|
42
|
+
// No split map.
|
|
43
|
+
}
|
|
44
|
+
return files.map((f) => {
|
|
45
|
+
try {
|
|
46
|
+
return `${f}\0${fs.readFileSync(f, 'utf8')}`;
|
|
47
|
+
} catch {
|
|
48
|
+
return `${f}\0`;
|
|
49
|
+
}
|
|
50
|
+
}).join('\0\0');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function workspaceZen({ root, dryRun = false } = {}) {
|
|
54
|
+
const out = { ran: false, done: [], todo: [], yours: [] };
|
|
55
|
+
if (!root || !fs.existsSync(path.join(root, 'atris'))) return out;
|
|
56
|
+
out.ran = true;
|
|
57
|
+
|
|
58
|
+
const mapBefore = dryRun ? null : mapSnapshot(root);
|
|
59
|
+
const clean = runCliJson(dryRun ? ['clean', '--dry-run', '--json'] : ['clean', '--json'], root);
|
|
60
|
+
const r = clean?.results || {};
|
|
61
|
+
const lines = [];
|
|
62
|
+
const journals = r.journals?.archived_count || 0;
|
|
63
|
+
if (journals) lines.push(`${dryRun ? '' : 'archived '}${plural(journals, 'old journal', 'old journals')}${dryRun ? ' to archive' : ''}`);
|
|
64
|
+
const stale = r.stale_tasks?.count || 0;
|
|
65
|
+
if (stale) lines.push(`${dryRun ? '' : 'put away '}${plural(stale, 'stale task', 'stale tasks')}${dryRun ? ' to put away' : ''}`);
|
|
66
|
+
const healed = r.map_refs?.healed_count || 0;
|
|
67
|
+
if (healed) lines.push(`${dryRun ? '' : 'fixed '}${plural(healed, 'broken map link', 'broken map links')}${dryRun ? ' to fix' : ''}`);
|
|
68
|
+
const sections = r.sections?.cleaned_count || 0;
|
|
69
|
+
if (sections) lines.push(`${dryRun ? '' : 'cleared '}${plural(sections, 'empty task section', 'empty task sections')}${dryRun ? ' to clear' : ''}`);
|
|
70
|
+
if (!dryRun && mapSnapshot(root) !== mapBefore) lines.push('refreshed the map');
|
|
71
|
+
if (dryRun) out.todo.push(...lines);
|
|
72
|
+
else out.done.push(...lines);
|
|
73
|
+
|
|
74
|
+
const unhealable = r.map_refs?.unhealable_count || 0;
|
|
75
|
+
if (unhealable) out.yours.push({ text: `${plural(unhealable, 'map link points', 'map links point')} nowhere`, cmd: 'atris clean' });
|
|
76
|
+
const pages = r.stale_pages?.items || [];
|
|
77
|
+
if (pages.length) {
|
|
78
|
+
out.yours.push({
|
|
79
|
+
text: `${plural(pages.length, 'wiki page is', 'wiki pages are')} behind ${pages.length === 1 ? 'its source' : 'their sources'}`,
|
|
80
|
+
cmd: 'atris clean --dry-run',
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
const dead = r.dead_code?.count || 0;
|
|
84
|
+
if (dead) out.yours.push({ text: `${plural(dead, 'file looks', 'files look')} unused`, cmd: 'atris clean --dry-run' });
|
|
85
|
+
|
|
86
|
+
// Every wiki surface that needs pruning shares one command, so they share
|
|
87
|
+
// one line too: a person sees one job, not three.
|
|
88
|
+
try {
|
|
89
|
+
const seen = new Set();
|
|
90
|
+
const parts = [];
|
|
91
|
+
for (const f of require('./wiki').findWikiMetabolismFindings(root)) {
|
|
92
|
+
if (seen.has(f.surface)) continue;
|
|
93
|
+
seen.add(f.surface);
|
|
94
|
+
const unit = f.surface.endsWith('/') ? 'pages' : 'entries';
|
|
95
|
+
parts.push(f.code === 'over-budget'
|
|
96
|
+
? { one: `wiki ${f.surface} has ${f.count} ${unit}, its budget is ${f.budget}`, short: `${f.surface} ${f.count} of ${f.budget}` }
|
|
97
|
+
: { one: `wiki ${f.surface} grew by ${f.growth} ${unit} since its last pruning`, short: `${f.surface} grew by ${f.growth}` });
|
|
98
|
+
}
|
|
99
|
+
if (parts.length === 1) out.yours.push({ text: parts[0].one, cmd: 'atris wiki consolidate' });
|
|
100
|
+
if (parts.length > 1) {
|
|
101
|
+
out.yours.push({ text: `wiki needs pruning in ${parts.length} places: ${parts.map((x) => x.short).join(', ')}`, cmd: 'atris wiki consolidate' });
|
|
102
|
+
}
|
|
103
|
+
} catch {
|
|
104
|
+
// No wiki, nothing to prune.
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Only ask where tasks already live; asking would otherwise create task state.
|
|
108
|
+
const state = path.join(root, '.atris', 'state');
|
|
109
|
+
const hasTasks = ['tasks.projection.json', 'atris.db'].some((f) => fs.existsSync(path.join(state, f)));
|
|
110
|
+
const reviews = hasTasks ? runCliJson(['task', 'reviews', '--json'], root) : null;
|
|
111
|
+
const ready = reviews?.queue?.counts?.certified || 0;
|
|
112
|
+
if (ready) {
|
|
113
|
+
out.yours.push({
|
|
114
|
+
text: `${plural(ready, 'finished job is', 'finished jobs are')} waiting for your ok`,
|
|
115
|
+
cmd: 'atris task reviews',
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
module.exports = { workspaceZen };
|
package/lib/zen.js
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Zen state: the main checkout matches origin, stray code is not left in the
|
|
4
|
+
// tree, the team's live state stays on disk, and there are no leftovers
|
|
5
|
+
// (stashes, idle side copies, merged branches). This module only reads.
|
|
6
|
+
|
|
7
|
+
const fs = require('node:fs');
|
|
8
|
+
const os = require('node:os');
|
|
9
|
+
const path = require('node:path');
|
|
10
|
+
const { spawnSync } = require('node:child_process');
|
|
11
|
+
const { runGit } = require('./checkout-sync');
|
|
12
|
+
|
|
13
|
+
// Paths that hold the team's live state or Atris-generated agent config.
|
|
14
|
+
// Uncommitted changes here are expected and never break zen.
|
|
15
|
+
const DEFAULT_TEAM_STATE = [
|
|
16
|
+
'atris/',
|
|
17
|
+
'.atris/',
|
|
18
|
+
'.claude/',
|
|
19
|
+
'.codex/',
|
|
20
|
+
'.cursor/',
|
|
21
|
+
'CLAUDE.md',
|
|
22
|
+
'AGENTS.md',
|
|
23
|
+
'GEMINI.md',
|
|
24
|
+
// Live data: a running app reads it from the tree, so zen never rolls it back.
|
|
25
|
+
'*.db',
|
|
26
|
+
'*.db-wal',
|
|
27
|
+
'*.db-shm',
|
|
28
|
+
'*.sqlite',
|
|
29
|
+
'*.sqlite3',
|
|
30
|
+
'*.duckdb',
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
function gitOut(args, cwd) {
|
|
34
|
+
const result = runGit(args, cwd);
|
|
35
|
+
return result.status === 0 ? result.stdout.trim() : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readTeamState(root) {
|
|
39
|
+
const extra = [];
|
|
40
|
+
try {
|
|
41
|
+
const config = JSON.parse(fs.readFileSync(path.join(root, '.atris', 'zen.json'), 'utf8'));
|
|
42
|
+
if (Array.isArray(config.team_state)) {
|
|
43
|
+
for (const entry of config.team_state) {
|
|
44
|
+
if (typeof entry === 'string' && entry.trim()) extra.push(entry.trim());
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
// No config, or unreadable config: defaults only.
|
|
49
|
+
}
|
|
50
|
+
return [...DEFAULT_TEAM_STATE, ...extra];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isTeamState(file, teamState) {
|
|
54
|
+
return teamState.some((entry) => {
|
|
55
|
+
if (entry.startsWith('*.')) return file.endsWith(entry.slice(1));
|
|
56
|
+
return entry.endsWith('/') ? file.startsWith(entry) : file === entry || file.startsWith(`${entry}/`);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function defaultBranch(root, remote) {
|
|
61
|
+
const head = gitOut(['symbolic-ref', '--quiet', '--short', `refs/remotes/${remote}/HEAD`], root);
|
|
62
|
+
if (head && head.startsWith(`${remote}/`)) return head.slice(remote.length + 1);
|
|
63
|
+
for (const name of ['main', 'master']) {
|
|
64
|
+
if (runGit(['rev-parse', '--verify', '--quiet', `refs/remotes/${remote}/${name}`], root).status === 0) {
|
|
65
|
+
return name;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function dirtyEntries(root) {
|
|
72
|
+
const result = runGit(['status', '--porcelain=v1', '-z'], root);
|
|
73
|
+
if (result.status !== 0) return [];
|
|
74
|
+
const entries = result.stdout.split('\0');
|
|
75
|
+
const files = [];
|
|
76
|
+
for (let i = 0; i < entries.length; i += 1) {
|
|
77
|
+
const entry = entries[i];
|
|
78
|
+
if (!entry) continue;
|
|
79
|
+
const item = { xy: entry.slice(0, 2), path: entry.slice(3), orig: null };
|
|
80
|
+
// Renames and copies carry the original path as the next entry.
|
|
81
|
+
if (entry[0] === 'R' || entry[0] === 'C') {
|
|
82
|
+
item.orig = entries[i + 1] || null;
|
|
83
|
+
i += 1;
|
|
84
|
+
}
|
|
85
|
+
files.push(item);
|
|
86
|
+
}
|
|
87
|
+
return files;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function dirtyPaths(root) {
|
|
91
|
+
return dirtyEntries(root).map((entry) => entry.path);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function listWorktrees(root) {
|
|
95
|
+
const out = gitOut(['worktree', 'list', '--porcelain'], root) || '';
|
|
96
|
+
const worktrees = [];
|
|
97
|
+
let current = null;
|
|
98
|
+
for (const line of out.split('\n')) {
|
|
99
|
+
if (line.startsWith('worktree ')) {
|
|
100
|
+
current = { path: line.slice('worktree '.length), branch: null, prunable: false, locked: false };
|
|
101
|
+
worktrees.push(current);
|
|
102
|
+
} else if (current && line.startsWith('branch ')) {
|
|
103
|
+
current.branch = line.slice('branch '.length).replace(/^refs\/heads\//, '');
|
|
104
|
+
} else if (current && line.startsWith('prunable')) {
|
|
105
|
+
current.prunable = true;
|
|
106
|
+
} else if (current && (line === 'locked' || line.startsWith('locked '))) {
|
|
107
|
+
// Locked by its owner (often a running agent). Git will not remove it.
|
|
108
|
+
current.locked = true;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return worktrees;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Text of the jobs this machine runs on a schedule: the user crontab and
|
|
115
|
+
// launchd agents. A side copy named there is in use even when it looks idle.
|
|
116
|
+
// ATRIS_ZEN_SCHEDULE_FILE points at a file to read instead (tests).
|
|
117
|
+
function scheduleText() {
|
|
118
|
+
const override = process.env.ATRIS_ZEN_SCHEDULE_FILE;
|
|
119
|
+
if (override) {
|
|
120
|
+
try {
|
|
121
|
+
return fs.readFileSync(override, 'utf8');
|
|
122
|
+
} catch {
|
|
123
|
+
return '';
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const parts = [];
|
|
127
|
+
const cron = spawnSync('crontab', ['-l'], { encoding: 'utf8', timeout: 5000 });
|
|
128
|
+
if (cron.status === 0) parts.push(cron.stdout);
|
|
129
|
+
const agents = path.join(os.homedir(), 'Library', 'LaunchAgents');
|
|
130
|
+
try {
|
|
131
|
+
for (const name of fs.readdirSync(agents)) {
|
|
132
|
+
if (name.endsWith('.plist')) parts.push(fs.readFileSync(path.join(agents, name), 'utf8'));
|
|
133
|
+
}
|
|
134
|
+
} catch {
|
|
135
|
+
// No launchd agents.
|
|
136
|
+
}
|
|
137
|
+
return parts.join('\n');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function scheduledCopies(worktrees, text = scheduleText()) {
|
|
141
|
+
if (!text) return new Set();
|
|
142
|
+
const home = os.homedir();
|
|
143
|
+
const expanded = text.replace(/\$\{?HOME\}?/g, home).replace(/(^|[\s"'=:])~\//g, `$1${home}/`);
|
|
144
|
+
const used = new Set();
|
|
145
|
+
for (const wt of worktrees) {
|
|
146
|
+
const at = path.resolve(wt.path);
|
|
147
|
+
const pattern = new RegExp(`${at.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w.-])`);
|
|
148
|
+
if (pattern.test(expanded)) used.add(at);
|
|
149
|
+
}
|
|
150
|
+
return used;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const FRESH_MINUTES = 30;
|
|
154
|
+
|
|
155
|
+
function freshMinutesFromEnv() {
|
|
156
|
+
const n = Number.parseFloat(process.env.ATRIS_ZEN_FRESH_MINUTES || FRESH_MINUTES);
|
|
157
|
+
return Number.isFinite(n) ? n : FRESH_MINUTES;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function untrackedFiles(root, dir) {
|
|
161
|
+
const out = gitOut(['ls-files', '--others', '--exclude-standard', '-z', '--', dir], root);
|
|
162
|
+
return out ? out.split('\0').filter(Boolean) : [];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Every changed file in a checkout, with untracked folders opened up.
|
|
166
|
+
function changedFiles(checkout) {
|
|
167
|
+
return dirtyEntries(checkout).flatMap((entry) => {
|
|
168
|
+
const paths = entry.xy === '??' && entry.path.endsWith('/') ? untrackedFiles(checkout, entry.path) : [entry.path];
|
|
169
|
+
return entry.orig ? [...paths, entry.orig] : paths;
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function lastTouched(checkout) {
|
|
174
|
+
let newest = 0;
|
|
175
|
+
for (const p of changedFiles(checkout)) newest = Math.max(newest, mtime(path.join(checkout, p)));
|
|
176
|
+
return newest;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// A side copy whose only changes are team notes, untouched and not switched
|
|
180
|
+
// or committed in the fresh window, holds nothing anyone is working on. Live
|
|
181
|
+
// data files never count as notes, so a copy holding one is left alone.
|
|
182
|
+
function idleWithOnlyNotes(checkout, teamState, cutoff) {
|
|
183
|
+
const live = teamState.filter((entry) => entry.startsWith('*.'));
|
|
184
|
+
const files = changedFiles(checkout);
|
|
185
|
+
if (!files.length) return false;
|
|
186
|
+
if (!files.every((f) => isTeamState(f, teamState) && !isTeamState(f, live))) return false;
|
|
187
|
+
return !headMovedSince(checkout, cutoff) && lastTouched(checkout) <= cutoff;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const ABANDONED_DAYS = 3;
|
|
191
|
+
|
|
192
|
+
function abandonedDaysFromEnv() {
|
|
193
|
+
const n = Number.parseFloat(process.env.ATRIS_ZEN_ABANDONED_DAYS || ABANDONED_DAYS);
|
|
194
|
+
return Number.isFinite(n) ? n : ABANDONED_DAYS;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function mtime(file) {
|
|
198
|
+
try {
|
|
199
|
+
return fs.lstatSync(file).mtimeMs;
|
|
200
|
+
} catch {
|
|
201
|
+
return 0;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// When the last entry in a history file was written, read from the entry
|
|
206
|
+
// itself. Git upkeep (gc, reflog expire) rewrites these files without adding
|
|
207
|
+
// entries, so the file time alone would make an old copy look fresh.
|
|
208
|
+
function lastEntryTime(log) {
|
|
209
|
+
let fd;
|
|
210
|
+
try {
|
|
211
|
+
fd = fs.openSync(log, 'r');
|
|
212
|
+
const size = fs.fstatSync(fd).size;
|
|
213
|
+
const length = Math.min(size, 8192);
|
|
214
|
+
const buf = Buffer.alloc(length);
|
|
215
|
+
fs.readSync(fd, buf, 0, length, size - length);
|
|
216
|
+
const last = buf.toString('utf8').trimEnd().split('\n').pop() || '';
|
|
217
|
+
const m = last.match(/> (\d+) [+-]\d{4}\t/);
|
|
218
|
+
return m ? Number(m[1]) * 1000 : null;
|
|
219
|
+
} catch {
|
|
220
|
+
return null;
|
|
221
|
+
} finally {
|
|
222
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// True when a checkout switched or made a commit after the cutoff.
|
|
227
|
+
function headMovedSince(checkout, cutoff) {
|
|
228
|
+
const gitDir = gitOut(['rev-parse', '--git-dir'], checkout);
|
|
229
|
+
if (!gitDir) return false;
|
|
230
|
+
const dir = path.resolve(checkout, gitDir);
|
|
231
|
+
const logged = lastEntryTime(path.join(dir, 'logs', 'HEAD'));
|
|
232
|
+
if (logged !== null) return logged > cutoff;
|
|
233
|
+
return Math.max(mtime(path.join(dir, 'logs', 'HEAD')), mtime(path.join(dir, 'HEAD'))) > cutoff;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// A checkout whose core.bare got set to true still has its .git folder and
|
|
237
|
+
// every file, but git stops looking at the files, so "not a git repository"
|
|
238
|
+
// would send the reader the wrong way. Name it, with the one-line undo.
|
|
239
|
+
const BARE_FLIPPED = 'git here is set to bare (core.bare is true), so it ignores the files in this folder; git config core.bare false undoes it';
|
|
240
|
+
|
|
241
|
+
function flippedToBare(cwd) {
|
|
242
|
+
if (gitOut(['rev-parse', '--is-bare-repository'], cwd) !== 'true') return false;
|
|
243
|
+
const gitDir = gitOut(['rev-parse', '--absolute-git-dir'], cwd);
|
|
244
|
+
if (!gitDir || path.basename(gitDir) !== '.git') return false;
|
|
245
|
+
const folder = path.dirname(gitDir);
|
|
246
|
+
const here = fs.realpathSync(cwd);
|
|
247
|
+
return here === folder || here.startsWith(folder + path.sep);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function checkZen({ cwd = process.cwd(), fetch = true, freshMinutes = freshMinutesFromEnv(), now = Date.now() } = {}) {
|
|
251
|
+
const root = gitOut(['rev-parse', '--show-toplevel'], cwd);
|
|
252
|
+
if (!root) return { ok: false, error: flippedToBare(cwd) ? BARE_FLIPPED : 'not a git repository' };
|
|
253
|
+
|
|
254
|
+
const remote = gitOut(['remote'], root)?.split('\n').includes('origin') ? 'origin' : null;
|
|
255
|
+
let fetched = null;
|
|
256
|
+
if (remote && fetch) fetched = runGit(['fetch', '--quiet', remote], root).status === 0;
|
|
257
|
+
|
|
258
|
+
const main = remote ? defaultBranch(root, remote) : null;
|
|
259
|
+
const upstream = main ? `${remote}/${main}` : null;
|
|
260
|
+
const branch = gitOut(['symbolic-ref', '--quiet', '--short', 'HEAD'], root);
|
|
261
|
+
const problems = [];
|
|
262
|
+
|
|
263
|
+
if (!upstream) {
|
|
264
|
+
problems.push({ kind: 'no_upstream', text: 'no origin main branch to compare against' });
|
|
265
|
+
} else if (branch !== main) {
|
|
266
|
+
problems.push({
|
|
267
|
+
kind: 'off_main',
|
|
268
|
+
text: branch ? `on branch ${branch}, not ${main}` : `not on a branch (detached), not ${main}`,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
let ahead = 0;
|
|
273
|
+
let behind = 0;
|
|
274
|
+
if (upstream) {
|
|
275
|
+
const counts = gitOut(['rev-list', '--left-right', '--count', `HEAD...${upstream}`], root);
|
|
276
|
+
if (counts) [ahead, behind] = counts.split(/\s+/).map((n) => Number.parseInt(n, 10) || 0);
|
|
277
|
+
if (ahead) problems.push({ kind: 'ahead', count: ahead, text: `${ahead} unpushed ${ahead === 1 ? 'commit' : 'commits'}` });
|
|
278
|
+
if (behind) problems.push({ kind: 'behind', count: behind, text: `${behind} ${behind === 1 ? 'commit' : 'commits'} behind ${upstream}` });
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const teamState = readTeamState(root);
|
|
282
|
+
const dirty = dirtyPaths(root);
|
|
283
|
+
const stray = dirty.filter((file) => !isTeamState(file, teamState));
|
|
284
|
+
const teamFiles = dirty.length - stray.length;
|
|
285
|
+
if (stray.length) {
|
|
286
|
+
problems.push({
|
|
287
|
+
kind: 'stray_code',
|
|
288
|
+
count: stray.length,
|
|
289
|
+
files: stray,
|
|
290
|
+
text: `${stray.length} stray code ${stray.length === 1 ? 'edit' : 'edits'} outside team state`,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const stashOut = gitOut(['stash', 'list', '--format=%H'], root) || '';
|
|
295
|
+
const stashes = stashOut ? stashOut.split('\n').length : 0;
|
|
296
|
+
if (stashes) problems.push({ kind: 'stashes', count: stashes, text: `${stashes} old ${stashes === 1 ? 'stash' : 'stashes'}` });
|
|
297
|
+
|
|
298
|
+
const others = listWorktrees(root).filter((wt) => path.resolve(wt.path) !== path.resolve(root));
|
|
299
|
+
const idle = [];
|
|
300
|
+
const busy = [];
|
|
301
|
+
const notesOnly = [];
|
|
302
|
+
const inUse = [];
|
|
303
|
+
const locked = [];
|
|
304
|
+
const cutoff = now - freshMinutes * 60000;
|
|
305
|
+
const scheduled = scheduledCopies(others);
|
|
306
|
+
for (const wt of others) {
|
|
307
|
+
if (scheduled.has(path.resolve(wt.path))) continue;
|
|
308
|
+
if (wt.locked) {
|
|
309
|
+
locked.push(wt);
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (wt.prunable || !fs.existsSync(wt.path)) {
|
|
313
|
+
idle.push(wt);
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
const status = runGit(['status', '--porcelain'], wt.path);
|
|
317
|
+
if (status.status !== 0) busy.push(wt);
|
|
318
|
+
else if (status.stdout.trim()) (idleWithOnlyNotes(wt.path, teamState, cutoff) ? notesOnly : busy).push(wt);
|
|
319
|
+
else if (headMovedSince(wt.path, cutoff)) inUse.push(wt);
|
|
320
|
+
else idle.push(wt);
|
|
321
|
+
}
|
|
322
|
+
if (notesOnly.length) {
|
|
323
|
+
problems.push({
|
|
324
|
+
kind: 'notes_only_worktrees',
|
|
325
|
+
count: notesOnly.length,
|
|
326
|
+
paths: notesOnly.map((wt) => wt.path),
|
|
327
|
+
text: `${notesOnly.length} idle side ${notesOnly.length === 1 ? 'copy holds' : 'copies hold'} only team notes`,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
if (idle.length) {
|
|
331
|
+
problems.push({
|
|
332
|
+
kind: 'idle_worktrees',
|
|
333
|
+
count: idle.length,
|
|
334
|
+
paths: idle.map((wt) => wt.path),
|
|
335
|
+
text: `${idle.length} idle side ${idle.length === 1 ? 'copy' : 'copies'} with no unsaved work`,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
let merged = [];
|
|
340
|
+
if (upstream) {
|
|
341
|
+
const checkedOut = new Set(others.map((wt) => wt.branch).filter(Boolean));
|
|
342
|
+
merged = (gitOut(['branch', '--merged', upstream, '--format=%(refname:short)'], root) || '')
|
|
343
|
+
.split('\n')
|
|
344
|
+
.filter((name) => name && name !== branch && name !== main && !checkedOut.has(name));
|
|
345
|
+
if (merged.length) {
|
|
346
|
+
problems.push({
|
|
347
|
+
kind: 'merged_branches',
|
|
348
|
+
count: merged.length,
|
|
349
|
+
branches: merged,
|
|
350
|
+
text: `${merged.length} ${merged.length === 1 ? 'branch' : 'branches'} already merged`,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return {
|
|
356
|
+
ok: true,
|
|
357
|
+
zen: problems.length === 0,
|
|
358
|
+
root,
|
|
359
|
+
branch,
|
|
360
|
+
upstream,
|
|
361
|
+
fetched,
|
|
362
|
+
ahead,
|
|
363
|
+
behind,
|
|
364
|
+
team_files: teamFiles,
|
|
365
|
+
stashes,
|
|
366
|
+
worktrees_with_work: busy.map((wt) => wt.path),
|
|
367
|
+
worktrees_in_use: inUse.map((wt) => wt.path),
|
|
368
|
+
worktrees_locked: locked.map((wt) => wt.path),
|
|
369
|
+
fresh_minutes: freshMinutes,
|
|
370
|
+
problems,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Where the side copies are, so a line about them can be acted on: home
|
|
375
|
+
// shortened to ~, at most three named.
|
|
376
|
+
// Side copies left with unsaved work: one place per line so a long path
|
|
377
|
+
// still fits a narrow pane, then when zen will take them over.
|
|
378
|
+
function sideCopyLines(paths, days) {
|
|
379
|
+
const home = os.homedir();
|
|
380
|
+
const n = paths.length;
|
|
381
|
+
const lines = [`${n} side ${n === 1 ? 'copy has' : 'copies have'} unsaved work, left for ${n === 1 ? 'its owner' : 'their owners'}`];
|
|
382
|
+
for (const p of paths.slice(0, 3)) {
|
|
383
|
+
lines.push(` ${p === home || p.startsWith(home + path.sep) ? `~${p.slice(home.length)}` : p}`);
|
|
384
|
+
}
|
|
385
|
+
if (n > 3) lines.push(` and ${n - 3} more`);
|
|
386
|
+
lines.push(`zen saves ${n === 1 ? 'it' : 'each one'} to zen-backup/ once nobody has touched it for ${days} ${days === 1 ? 'day' : 'days'}`);
|
|
387
|
+
return lines;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
module.exports = {
|
|
391
|
+
ABANDONED_DAYS,
|
|
392
|
+
idleWithOnlyNotes,
|
|
393
|
+
lastTouched,
|
|
394
|
+
untrackedFiles,
|
|
395
|
+
abandonedDaysFromEnv,
|
|
396
|
+
sideCopyLines,
|
|
397
|
+
DEFAULT_TEAM_STATE,
|
|
398
|
+
FRESH_MINUTES,
|
|
399
|
+
headMovedSince,
|
|
400
|
+
mtime,
|
|
401
|
+
checkZen,
|
|
402
|
+
defaultBranch,
|
|
403
|
+
dirtyEntries,
|
|
404
|
+
gitOut,
|
|
405
|
+
isTeamState,
|
|
406
|
+
listWorktrees,
|
|
407
|
+
readTeamState,
|
|
408
|
+
scheduledCopies,
|
|
409
|
+
};
|
package/package.json
CHANGED
package/utils/api.js
CHANGED
|
@@ -77,8 +77,21 @@ function httpRequest(urlString, options) {
|
|
|
77
77
|
const req = transport.request(requestOptions, (res) => {
|
|
78
78
|
// Follow redirects (301, 302, 307, 308)
|
|
79
79
|
if ([301, 302, 307, 308].includes(res.statusCode) && res.headers.location) {
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
res.resume();
|
|
81
|
+
let redirectUrl;
|
|
82
|
+
try {
|
|
83
|
+
redirectUrl = new URL(res.headers.location, parsed);
|
|
84
|
+
} catch {
|
|
85
|
+
reject(new Error('invalid redirect target'));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
// Headers and bodies can both contain credentials. Never replay them
|
|
89
|
+
// to another origin, including a different port or an HTTPS downgrade.
|
|
90
|
+
if (redirectUrl.origin !== parsed.origin) {
|
|
91
|
+
reject(new Error(`refusing cross-origin redirect to ${redirectUrl.host || redirectUrl.protocol}`));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
resolve(httpRequest(redirectUrl.toString(), options));
|
|
82
95
|
return;
|
|
83
96
|
}
|
|
84
97
|
|
package/utils/auth.js
CHANGED
|
@@ -2,7 +2,7 @@ const os = require('os');
|
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const { stateHome } = require('../lib/state-home');
|
|
4
4
|
const fs = require('fs');
|
|
5
|
-
const {
|
|
5
|
+
const { execFile } = require('child_process');
|
|
6
6
|
const readline = require('readline');
|
|
7
7
|
const agentClient = require('./agent-client');
|
|
8
8
|
|
|
@@ -12,19 +12,20 @@ const agentClient = require('./agent-client');
|
|
|
12
12
|
*/
|
|
13
13
|
function openBrowser(url) {
|
|
14
14
|
const platform = os.platform();
|
|
15
|
-
// Sanitize URL to prevent shell injection — only allow valid URL characters
|
|
16
|
-
const sanitizedUrl = url.replace(/[^a-zA-Z0-9\-._~:/?#\[\]@!$&'()*+,;=%]/g, '');
|
|
17
15
|
let command;
|
|
16
|
+
let args = [url];
|
|
18
17
|
|
|
19
18
|
if (platform === 'darwin') {
|
|
20
|
-
command =
|
|
19
|
+
command = 'open';
|
|
21
20
|
} else if (platform === 'win32') {
|
|
22
|
-
command =
|
|
21
|
+
command = 'rundll32.exe';
|
|
22
|
+
args = ['url.dll,FileProtocolHandler', url];
|
|
23
23
|
} else {
|
|
24
|
-
command =
|
|
24
|
+
command = 'xdg-open';
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
// Pass the URL as data, including on Windows, without a command shell.
|
|
28
|
+
execFile(command, args, (error) => {
|
|
28
29
|
if (error) {
|
|
29
30
|
console.log(`\nCouldn't open browser automatically. Please visit:\n${url}`);
|
|
30
31
|
}
|