atris 3.71.3 → 3.72.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/lib/zen.js ADDED
@@ -0,0 +1,395 @@
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
+ function checkZen({ cwd = process.cwd(), fetch = true, freshMinutes = freshMinutesFromEnv(), now = Date.now() } = {}) {
237
+ const root = gitOut(['rev-parse', '--show-toplevel'], cwd);
238
+ if (!root) return { ok: false, error: 'not a git repository' };
239
+
240
+ const remote = gitOut(['remote'], root)?.split('\n').includes('origin') ? 'origin' : null;
241
+ let fetched = null;
242
+ if (remote && fetch) fetched = runGit(['fetch', '--quiet', remote], root).status === 0;
243
+
244
+ const main = remote ? defaultBranch(root, remote) : null;
245
+ const upstream = main ? `${remote}/${main}` : null;
246
+ const branch = gitOut(['symbolic-ref', '--quiet', '--short', 'HEAD'], root);
247
+ const problems = [];
248
+
249
+ if (!upstream) {
250
+ problems.push({ kind: 'no_upstream', text: 'no origin main branch to compare against' });
251
+ } else if (branch !== main) {
252
+ problems.push({
253
+ kind: 'off_main',
254
+ text: branch ? `on branch ${branch}, not ${main}` : `not on a branch (detached), not ${main}`,
255
+ });
256
+ }
257
+
258
+ let ahead = 0;
259
+ let behind = 0;
260
+ if (upstream) {
261
+ const counts = gitOut(['rev-list', '--left-right', '--count', `HEAD...${upstream}`], root);
262
+ if (counts) [ahead, behind] = counts.split(/\s+/).map((n) => Number.parseInt(n, 10) || 0);
263
+ if (ahead) problems.push({ kind: 'ahead', count: ahead, text: `${ahead} unpushed ${ahead === 1 ? 'commit' : 'commits'}` });
264
+ if (behind) problems.push({ kind: 'behind', count: behind, text: `${behind} ${behind === 1 ? 'commit' : 'commits'} behind ${upstream}` });
265
+ }
266
+
267
+ const teamState = readTeamState(root);
268
+ const dirty = dirtyPaths(root);
269
+ const stray = dirty.filter((file) => !isTeamState(file, teamState));
270
+ const teamFiles = dirty.length - stray.length;
271
+ if (stray.length) {
272
+ problems.push({
273
+ kind: 'stray_code',
274
+ count: stray.length,
275
+ files: stray,
276
+ text: `${stray.length} stray code ${stray.length === 1 ? 'edit' : 'edits'} outside team state`,
277
+ });
278
+ }
279
+
280
+ const stashOut = gitOut(['stash', 'list', '--format=%H'], root) || '';
281
+ const stashes = stashOut ? stashOut.split('\n').length : 0;
282
+ if (stashes) problems.push({ kind: 'stashes', count: stashes, text: `${stashes} old ${stashes === 1 ? 'stash' : 'stashes'}` });
283
+
284
+ const others = listWorktrees(root).filter((wt) => path.resolve(wt.path) !== path.resolve(root));
285
+ const idle = [];
286
+ const busy = [];
287
+ const notesOnly = [];
288
+ const inUse = [];
289
+ const locked = [];
290
+ const cutoff = now - freshMinutes * 60000;
291
+ const scheduled = scheduledCopies(others);
292
+ for (const wt of others) {
293
+ if (scheduled.has(path.resolve(wt.path))) continue;
294
+ if (wt.locked) {
295
+ locked.push(wt);
296
+ continue;
297
+ }
298
+ if (wt.prunable || !fs.existsSync(wt.path)) {
299
+ idle.push(wt);
300
+ continue;
301
+ }
302
+ const status = runGit(['status', '--porcelain'], wt.path);
303
+ if (status.status !== 0) busy.push(wt);
304
+ else if (status.stdout.trim()) (idleWithOnlyNotes(wt.path, teamState, cutoff) ? notesOnly : busy).push(wt);
305
+ else if (headMovedSince(wt.path, cutoff)) inUse.push(wt);
306
+ else idle.push(wt);
307
+ }
308
+ if (notesOnly.length) {
309
+ problems.push({
310
+ kind: 'notes_only_worktrees',
311
+ count: notesOnly.length,
312
+ paths: notesOnly.map((wt) => wt.path),
313
+ text: `${notesOnly.length} idle side ${notesOnly.length === 1 ? 'copy holds' : 'copies hold'} only team notes`,
314
+ });
315
+ }
316
+ if (idle.length) {
317
+ problems.push({
318
+ kind: 'idle_worktrees',
319
+ count: idle.length,
320
+ paths: idle.map((wt) => wt.path),
321
+ text: `${idle.length} idle side ${idle.length === 1 ? 'copy' : 'copies'} with no unsaved work`,
322
+ });
323
+ }
324
+
325
+ let merged = [];
326
+ if (upstream) {
327
+ const checkedOut = new Set(others.map((wt) => wt.branch).filter(Boolean));
328
+ merged = (gitOut(['branch', '--merged', upstream, '--format=%(refname:short)'], root) || '')
329
+ .split('\n')
330
+ .filter((name) => name && name !== branch && name !== main && !checkedOut.has(name));
331
+ if (merged.length) {
332
+ problems.push({
333
+ kind: 'merged_branches',
334
+ count: merged.length,
335
+ branches: merged,
336
+ text: `${merged.length} ${merged.length === 1 ? 'branch' : 'branches'} already merged`,
337
+ });
338
+ }
339
+ }
340
+
341
+ return {
342
+ ok: true,
343
+ zen: problems.length === 0,
344
+ root,
345
+ branch,
346
+ upstream,
347
+ fetched,
348
+ ahead,
349
+ behind,
350
+ team_files: teamFiles,
351
+ stashes,
352
+ worktrees_with_work: busy.map((wt) => wt.path),
353
+ worktrees_in_use: inUse.map((wt) => wt.path),
354
+ worktrees_locked: locked.map((wt) => wt.path),
355
+ fresh_minutes: freshMinutes,
356
+ problems,
357
+ };
358
+ }
359
+
360
+ // Where the side copies are, so a line about them can be acted on: home
361
+ // shortened to ~, at most three named.
362
+ // Side copies left with unsaved work: one place per line so a long path
363
+ // still fits a narrow pane, then when zen will take them over.
364
+ function sideCopyLines(paths, days) {
365
+ const home = os.homedir();
366
+ const n = paths.length;
367
+ const lines = [`${n} side ${n === 1 ? 'copy has' : 'copies have'} unsaved work, left for ${n === 1 ? 'its owner' : 'their owners'}`];
368
+ for (const p of paths.slice(0, 3)) {
369
+ lines.push(` ${p === home || p.startsWith(home + path.sep) ? `~${p.slice(home.length)}` : p}`);
370
+ }
371
+ if (n > 3) lines.push(` and ${n - 3} more`);
372
+ lines.push(`zen saves ${n === 1 ? 'it' : 'each one'} to zen-backup/ once nobody has touched it for ${days} ${days === 1 ? 'day' : 'days'}`);
373
+ return lines;
374
+ }
375
+
376
+ module.exports = {
377
+ ABANDONED_DAYS,
378
+ idleWithOnlyNotes,
379
+ lastTouched,
380
+ untrackedFiles,
381
+ abandonedDaysFromEnv,
382
+ sideCopyLines,
383
+ DEFAULT_TEAM_STATE,
384
+ FRESH_MINUTES,
385
+ headMovedSince,
386
+ mtime,
387
+ checkZen,
388
+ defaultBranch,
389
+ dirtyEntries,
390
+ gitOut,
391
+ isTeamState,
392
+ listWorktrees,
393
+ readTeamState,
394
+ scheduledCopies,
395
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.71.3",
3
+ "version": "3.72.0",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof.",
5
5
  "main": "bin/atris.js",
6
6
  "bin": {