atris 3.74.6 → 3.74.7

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/bin/atris.js CHANGED
@@ -1967,6 +1967,15 @@ function showWelcomeVisualization() {
1967
1967
  // Silent: the lineup is a hint, not a gate.
1968
1968
  }
1969
1969
 
1970
+ // The four values settle close calls, so every new chat sees them, not
1971
+ // only an agent that reads atris.md to the end (lib/values.js).
1972
+ try {
1973
+ const values = require('../lib/values').valuesForRoot(cwd);
1974
+ values.forEach((value, index) => console.log(row(index ? '' : 'values', `${value.name.padEnd(14)}${value.gist}`)));
1975
+ } catch {
1976
+ // Advisory only; never blocks boot.
1977
+ }
1978
+
1970
1979
  const hasNoTasks = glance.active === 0
1971
1980
  && glance.review === 0
1972
1981
  && glance.reviewCertified === 0;
package/commands/idea.js CHANGED
@@ -31,6 +31,7 @@ function ideaUsage() {
31
31
  ' atris idea retry <n> [--repo <path>] [--verify "<cmd>"]',
32
32
  ' atris idea tick [--repo <path>] [--max-minutes <n>]',
33
33
  ' atris idea on|off [--repo <path>] [--dry-run]',
34
+ ' atris idea login|logout (the Claude login sealed builds use)',
34
35
  ].join('\n');
35
36
  }
36
37
 
@@ -213,6 +214,35 @@ function retryIdea(ref, { repo, verify = '', json, log }) {
213
214
  return 0;
214
215
  }
215
216
 
217
+ // One command for the login a sealed build needs: Claude's own setup-token
218
+ // runs on the person's terminal, the token is picked off the screen (or
219
+ // pasted when it cannot be), proven with a sign-in from a blank home, then
220
+ // saved where only the owner can read it. Nothing is saved when it fails.
221
+ function ideaLogin({ log = console.log, deps = {} } = {}) {
222
+ const login = { ...require('../lib/engine-login'), ...deps };
223
+ const tty = deps.isTTY !== undefined ? deps.isTTY : Boolean(process.stdin.isTTY && process.stdout.isTTY);
224
+ if (!tty) {
225
+ log('run this in your own terminal: atris idea login');
226
+ return 2;
227
+ }
228
+ log('a browser window opens for Claude. approve it there; there is nothing to copy.');
229
+ let token = login.findToken(login.recordSetupToken().text);
230
+ if (!token && login.askToken) token = String(login.askToken() || '').trim();
231
+ if (!token) {
232
+ log('no login came back from claude setup-token. nothing saved.');
233
+ return 1;
234
+ }
235
+ const check = login.checkToken(token);
236
+ if (!check.ok) {
237
+ log(`that login did not sign in${check.detail ? `: ${check.detail}` : ''}. nothing saved.`);
238
+ return 1;
239
+ }
240
+ login.saveToken(token);
241
+ log('done. sealed builds now sign in to Claude on your subscription.');
242
+ log('undo: atris idea logout');
243
+ return 0;
244
+ }
245
+
216
246
  function ideaAtris(args = process.argv.slice(3), { cwd = process.cwd(), log = console.log, keepDeps = {} } = {}) {
217
247
  const list = Array.isArray(args) ? args : [];
218
248
  if (argsWantHelp(list)) {
@@ -224,6 +254,12 @@ function ideaAtris(args = process.argv.slice(3), { cwd = process.cwd(), log = co
224
254
  const json = flags.json === true;
225
255
  const verb = words[0];
226
256
 
257
+ if (verb === 'login' && words.length === 1) return ideaLogin({ log, deps: keepDeps.login });
258
+ if (verb === 'logout' && words.length === 1) {
259
+ const { forgetToken } = require('../lib/engine-login');
260
+ log(forgetToken() ? 'sealed builds no longer have a Claude login.' : 'there was no saved login.');
261
+ return 0;
262
+ }
227
263
  if (verb === 'retry' && words.length <= 2) return retryIdea(words[1], { repo, verify: flags.verify || '', json, log });
228
264
  if ((verb === 'on' || verb === 'off') && words.length === 1) {
229
265
  return switchTimer(verb === 'on', { repo, dryRun: flags.dryRun === true, json, log, keepDeps });
package/commands/zen.js CHANGED
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const { abandonedDaysFromEnv, checkZen, sideCopyLines } = require('../lib/zen');
3
+ const { abandonedDaysFromEnv, checkZen, nextSaveIn, sideCopyLines } = require('../lib/zen');
4
4
  const { applyZen } = require('../lib/zen-apply');
5
5
  const { workspaceZen } = require('../lib/zen-workspace');
6
6
  const { fleetFile, fleetRepos, fleetZen, printFleet } = require('../lib/zen-fleet');
@@ -97,7 +97,9 @@ function printCheck(result, workspace) {
97
97
  console.log(' run atris zen to make it zen');
98
98
  }
99
99
  if (result.worktrees_with_work.length) {
100
- for (const text of sideCopyLines(result.worktrees_with_work, abandonedDaysFromEnv())) console.log(` ${text}`);
100
+ const days = abandonedDaysFromEnv();
101
+ const wait = nextSaveIn(result.worktrees_with_work, days);
102
+ for (const text of sideCopyLines(result.worktrees_with_work, days, wait)) console.log(` ${text}`);
101
103
  }
102
104
  const held = result.worktrees_locked?.length || 0;
103
105
  if (held) {
@@ -0,0 +1,105 @@
1
+ 'use strict';
2
+
3
+ // The one Claude login a sealed build may use. A sealed build hides the real
4
+ // home, and the normal Claude login lives in the macOS keychain and refreshes
5
+ // itself, so a build cannot borrow it. `atris idea login` runs Claude's own
6
+ // `claude setup-token` (a long-lived login on the same subscription), keeps the
7
+ // token in a file only the owner can read, and the sealed build passes it in.
8
+
9
+ const fs = require('node:fs');
10
+ const os = require('node:os');
11
+ const path = require('node:path');
12
+ const { spawnSync } = require('node:child_process');
13
+ const { atrisHome } = require('./state-home');
14
+
15
+ const TOKEN_PATTERN = /sk-ant-oat\d{2}-[A-Za-z0-9_-]{20,}/g;
16
+
17
+ function tokenFile(env = process.env) {
18
+ return path.join(atrisHome(env), 'secrets', 'claude-oauth-token');
19
+ }
20
+
21
+ // The token from a recording of `claude setup-token`, colors and cursor moves
22
+ // stripped. The last one wins, since the screen may redraw.
23
+ function findToken(text) {
24
+ const plain = String(text || '').replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '').replace(/\x1b[\]P][^\x07\x1b]*(\x07|\x1b\\)/g, '');
25
+ const hits = plain.match(TOKEN_PATTERN);
26
+ return hits ? hits[hits.length - 1] : '';
27
+ }
28
+
29
+ function savedToken(env = process.env) {
30
+ try {
31
+ return fs.readFileSync(tokenFile(env), 'utf8').trim();
32
+ } catch {
33
+ return '';
34
+ }
35
+ }
36
+
37
+ function saveToken(token, env = process.env) {
38
+ const file = tokenFile(env);
39
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
40
+ fs.chmodSync(path.dirname(file), 0o700);
41
+ fs.writeFileSync(file, `${token}\n`, { mode: 0o600 });
42
+ fs.chmodSync(file, 0o600);
43
+ return file;
44
+ }
45
+
46
+ function forgetToken(env = process.env) {
47
+ const file = tokenFile(env);
48
+ if (!fs.existsSync(file)) return false;
49
+ fs.rmSync(file, { force: true });
50
+ return true;
51
+ }
52
+
53
+ // Runs `claude setup-token` on the person's own terminal while `script`
54
+ // records what it prints, then reads the recording and deletes it.
55
+ function recordSetupToken({ spawn = spawnSync, platform = process.platform } = {}) {
56
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'atris-login-'));
57
+ fs.chmodSync(dir, 0o700);
58
+ const log = path.join(dir, 'screen');
59
+ try {
60
+ const args = platform === 'darwin'
61
+ ? ['-q', log, 'claude', 'setup-token']
62
+ : ['-q', '-c', 'claude setup-token', log];
63
+ const result = spawn('script', args, { stdio: 'inherit' });
64
+ const text = fs.existsSync(log) ? fs.readFileSync(log, 'utf8') : '';
65
+ return { status: result.status, text };
66
+ } finally {
67
+ fs.rmSync(dir, { recursive: true, force: true });
68
+ }
69
+ }
70
+
71
+ // When the token cannot be read off the screen, the person pastes it once,
72
+ // without it showing on screen.
73
+ function askToken({ spawn = spawnSync } = {}) {
74
+ const script = 'printf "paste the token claude printed (it stays hidden): " >&2; stty -echo; read -r t; stty echo; printf "\\n" >&2; printf %s "$t"';
75
+ const result = spawn('sh', ['-c', script], { stdio: ['inherit', 'pipe', 'inherit'], encoding: 'utf8' });
76
+ return String(result.stdout || '').trim();
77
+ }
78
+
79
+ // Signs in the way a sealed build does: a blank home, no keychain, only the
80
+ // token. Returns { ok, detail }.
81
+ function checkToken(token, { spawn = spawnSync } = {}) {
82
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'atris-login-check-'));
83
+ try {
84
+ const result = spawn('claude', ['-p', 'Reply with the single word ok.', '--model', 'haiku',
85
+ '--settings', JSON.stringify({ disableAllHooks: true }), '--strict-mcp-config', '--tools', ''], {
86
+ cwd: home,
87
+ encoding: 'utf8',
88
+ timeout: 120000,
89
+ env: {
90
+ PATH: process.env.PATH || '',
91
+ HOME: home,
92
+ CLAUDE_CONFIG_DIR: path.join(home, '.claude'),
93
+ CLAUDE_CODE_OAUTH_TOKEN: token,
94
+ ANTHROPIC_API_KEY: '',
95
+ },
96
+ });
97
+ const out = `${result.stdout || ''}${result.stderr || ''}`.trim();
98
+ const ok = result.status === 0 && /\bok\b/i.test(result.stdout || '');
99
+ return { ok, detail: ok ? '' : (out.split('\n').pop() || `exit ${result.status}`).slice(0, 200) };
100
+ } finally {
101
+ fs.rmSync(home, { recursive: true, force: true });
102
+ }
103
+ }
104
+
105
+ module.exports = { findToken, savedToken, saveToken, forgetToken, recordSetupToken, askToken, checkToken };
package/lib/fleet.js CHANGED
@@ -1907,7 +1907,10 @@ function reviewOnlyEngineEnvironment(worktreePath, options = {}) {
1907
1907
  // owner policy: claude work runs on the subscription login, never API
1908
1908
  // billing, so the api key is stripped even when the parent env has one.
1909
1909
  ANTHROPIC_API_KEY: '',
1910
- CLAUDE_CODE_OAUTH_TOKEN: selectedSecret(['claude'], 'CLAUDE_CODE_OAUTH_TOKEN'),
1910
+ // `atris idea login` saves this one login for sealed builds; a token
1911
+ // already in the environment wins.
1912
+ CLAUDE_CODE_OAUTH_TOKEN: selectedSecret(['claude'], 'CLAUDE_CODE_OAUTH_TOKEN')
1913
+ || (selectedEngine === 'claude' ? require('./engine-login').savedToken() : ''),
1911
1914
  CURSOR_API_KEY: selectedSecret(['cursor'], 'CURSOR_API_KEY'),
1912
1915
  DEVIN_API_KEY: selectedSecret(['devin'], 'DEVIN_API_KEY'),
1913
1916
  XAI_API_KEY: selectedSecret(['grok'], 'XAI_API_KEY'),
package/lib/idea-tick.js CHANGED
@@ -186,6 +186,19 @@ function defaultRunLap(text, options) {
186
186
  return require('../commands/one-lap').runOneLap(text, options);
187
187
  }
188
188
 
189
+ // Sealed builds get no keychain, so a Claude build with no saved login fails
190
+ // at its first word. Saying so before the build keeps the idea's attempts.
191
+ function defaultLoginGap(repo) {
192
+ try {
193
+ const executor = require('../commands/one-lap').readyExecutor(repo);
194
+ if (!executor || executor.id !== 'claude') return '';
195
+ if (process.env.CLAUDE_CODE_OAUTH_TOKEN || require('./engine-login').savedToken()) return '';
196
+ return 'no Claude login for sealed builds';
197
+ } catch {
198
+ return '';
199
+ }
200
+ }
201
+
189
202
  // Where a stopped lap stopped: its check failed, its check passed but the
190
203
  // review did not, or it never got that far.
191
204
  function lapStage(lap) {
@@ -248,6 +261,7 @@ async function runIdeaTick({
248
261
  lockOptions = {},
249
262
  loopRepo = null,
250
263
  clock = () => Date.now(),
264
+ loginGap = runLap === defaultRunLap ? defaultLoginGap : () => '',
251
265
  } = {}) {
252
266
  const repo = path.resolve(root);
253
267
  const lock = acquireTickLock(repo, lockOptions);
@@ -282,6 +296,12 @@ async function runIdeaTick({
282
296
  return stop(idea, { stage: 'checks', cause: 'no fast check found', reason: `this repo has no fast check I can find; give one: atris idea retry ${idea.n} --verify "<cmd>"` });
283
297
  }
284
298
 
299
+ const gap = loginGap(repo);
300
+ if (gap) {
301
+ log(`idea ${idea.n} waits: ${gap}. run: atris idea login`);
302
+ return { status: 'needs_login', idea_id: idea.id, reason: gap };
303
+ }
304
+
285
305
  started = iso();
286
306
  current = updateIdea(repo, idea.id, { status: 'building', attempts: Number(idea.attempts || 0) + 1, started_at: started }) || idea;
287
307
  log(`building idea ${idea.n}: ${oneLine(idea.text, 80)}`);
package/lib/values.js ADDED
@@ -0,0 +1,38 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ const PACKAGE_ROOT = path.join(__dirname, '..');
7
+
8
+ // The bold-lead bullets under `## values` in atris.md, each as its name and
9
+ // its first sentence, so the boot can show them in every new chat instead of
10
+ // leaving them deep in a long file. A workspace made before the values
11
+ // shipped has no such section; the package copy stands in for it.
12
+ function extractValues(content) {
13
+ const lines = String(content || '').split(/\r?\n/);
14
+ const start = lines.findIndex((line) => /^## values\s*$/i.test(line));
15
+ if (start === -1) return [];
16
+ const values = [];
17
+ for (let index = start + 1; index < lines.length && !/^##\s+/.test(lines[index]); index += 1) {
18
+ const match = lines[index].match(/^- \*\*([^*]+?)\.\*\*\s+([^.]+)\./);
19
+ if (match) values.push({ name: match[1].toLowerCase(), gist: match[2][0].toLowerCase() + match[2].slice(1) });
20
+ }
21
+ return values;
22
+ }
23
+
24
+ function readValues(root) {
25
+ for (const file of [path.join(root, 'atris.md'), path.join(root, 'atris', 'atris.md')]) {
26
+ if (!fs.existsSync(file)) continue;
27
+ const found = extractValues(fs.readFileSync(file, 'utf8'));
28
+ if (found.length) return found;
29
+ }
30
+ return [];
31
+ }
32
+
33
+ function valuesForRoot(root = process.cwd()) {
34
+ const own = readValues(root);
35
+ return own.length ? own : readValues(PACKAGE_ROOT);
36
+ }
37
+
38
+ module.exports = { extractValues, valuesForRoot };
package/lib/zen-apply.js CHANGED
@@ -16,6 +16,7 @@ const {
16
16
  checkZen,
17
17
  defaultBranch,
18
18
  dirtyEntries,
19
+ folderLinksOnly,
19
20
  dirtyFiles,
20
21
  FRESH_MINUTES,
21
22
  gitOut,
@@ -26,6 +27,7 @@ const {
26
27
  mtime,
27
28
  idleWithOnlyNotes,
28
29
  lastTouched,
30
+ nextSaveIn,
29
31
  sideCopyLines,
30
32
  scheduledCopies,
31
33
  untrackedFiles,
@@ -151,6 +153,19 @@ function putAwaySideCopies(root, cutoff, staleCutoff, tag, teamState) {
151
153
  busy.push(wt.path);
152
154
  continue;
153
155
  }
156
+ // A copy whose only changes are links to ignored folders is clean: the
157
+ // links go, then the copy, and they come back if git will not remove it.
158
+ const links = status.stdout.trim() ? folderLinksOnly(wt.path, root) : [];
159
+ if (links.length) {
160
+ const targets = links.map((rel) => [path.join(wt.path, rel), fs.readlinkSync(path.join(wt.path, rel))]);
161
+ for (const [at] of targets) fs.unlinkSync(at);
162
+ if (runGit(['worktree', 'remove', wt.path], root).status === 0) removed += 1;
163
+ else {
164
+ for (const [at, to] of targets) fs.symlinkSync(to, at);
165
+ busy.push(wt.path);
166
+ }
167
+ continue;
168
+ }
154
169
  if (status.stdout.trim()) {
155
170
  // Unsaved work nobody has touched in days goes to a branch, then away.
156
171
  // So does an idle copy holding only team notes: nothing in it is work.
@@ -529,7 +544,7 @@ function applyZen({ cwd = process.cwd(), fetch = true, freshMinutes, abandonedDa
529
544
  done.push(`saved ${plural(copies.kept.length, 'side copy', 'side copies')} untouched for ${plural(staleDays, 'day', 'days')} to zen-backup/ and put ${copies.kept.length === 1 ? 'it' : 'them'} away`);
530
545
  saved.push(...copies.kept.map((b) => `refs/heads/${b}`));
531
546
  }
532
- if (copies.busy.length) left.push(...sideCopyLines(copies.busy, staleDays));
547
+ if (copies.busy.length) left.push(...sideCopyLines(copies.busy, staleDays, nextSaveIn(copies.busy, staleDays, now.getTime())));
533
548
  if (copies.scheduled) left.push(`${plural(copies.scheduled, 'side copy is', 'side copies are')} used by a scheduled job, so ${copies.scheduled === 1 ? 'it stays' : 'they stay'}`);
534
549
  if (copies.locked) left.push(`${plural(copies.locked, 'side copy is', 'side copies are')} locked by ${copies.locked === 1 ? 'its owner, so it stays' : 'their owners, so they stay'}`);
535
550
  if (copies.inUse) left.push(`${plural(copies.inUse, 'side copy was', 'side copies were')} used in ${window}`);
package/lib/zen.js CHANGED
@@ -231,6 +231,27 @@ function lastTouched(checkout) {
231
231
  return newest;
232
232
  }
233
233
 
234
+ // Links a side copy holds where the repo ignores a folder, like node_modules
235
+ // pointing at another checkout's packages. A link holds no work, but git only
236
+ // matches `node_modules/` against a real folder, so it reads as unsaved. The
237
+ // rule is asked of the main checkout, where that path is not a link. Empty
238
+ // when anything else in the copy changed.
239
+ function folderLinksOnly(checkout, root) {
240
+ const entries = dirtyEntries(checkout);
241
+ const links = [];
242
+ for (const entry of entries) {
243
+ if (entry.xy !== '??') return [];
244
+ const rel = entry.path.replace(/\/+$/, '');
245
+ let link = false;
246
+ try {
247
+ link = fs.lstatSync(path.join(checkout, rel)).isSymbolicLink();
248
+ } catch {}
249
+ if (!link || runGit(['check-ignore', '-q', `${rel}/`], root).status !== 0) return [];
250
+ links.push(rel);
251
+ }
252
+ return links;
253
+ }
254
+
234
255
  // A side copy whose only changes are team notes, untouched and not switched
235
256
  // or committed in the fresh window, holds nothing anyone is working on. Live
236
257
  // data files never count as notes, so a copy holding one is left alone.
@@ -278,14 +299,37 @@ function lastEntryTime(log) {
278
299
  }
279
300
  }
280
301
 
281
- // True when a checkout switched or made a commit after the cutoff.
282
- function headMovedSince(checkout, cutoff) {
302
+ // When a checkout last switched or made a commit.
303
+ function headTime(checkout) {
283
304
  const gitDir = gitOut(['rev-parse', '--git-dir'], checkout);
284
- if (!gitDir) return false;
305
+ if (!gitDir) return 0;
285
306
  const dir = path.resolve(checkout, gitDir);
286
307
  const logged = lastEntryTime(path.join(dir, 'logs', 'HEAD'));
287
- if (logged !== null) return logged > cutoff;
288
- return Math.max(mtime(path.join(dir, 'logs', 'HEAD')), mtime(path.join(dir, 'HEAD'))) > cutoff;
308
+ if (logged !== null) return logged;
309
+ return Math.max(mtime(path.join(dir, 'logs', 'HEAD')), mtime(path.join(dir, 'HEAD')));
310
+ }
311
+
312
+ // True when a checkout switched or made a commit after the cutoff.
313
+ function headMovedSince(checkout, cutoff) {
314
+ return headTime(checkout) > cutoff;
315
+ }
316
+
317
+ // How long until the first of these side copies has sat untouched long
318
+ // enough for zen to save it, or null when none is waiting on the clock.
319
+ function nextSaveIn(paths, days, now = Date.now()) {
320
+ let soonest = null;
321
+ for (const p of paths) {
322
+ const wait = Math.max(headTime(p), lastTouched(p)) + days * 86400000 - now;
323
+ if (wait > 0 && (soonest === null || wait < soonest)) soonest = wait;
324
+ }
325
+ return soonest;
326
+ }
327
+
328
+ function waitText(ms) {
329
+ const hours = Math.ceil(ms / 3600000);
330
+ if (hours < 24) return `${hours} ${hours === 1 ? 'hour' : 'hours'}`;
331
+ const days = Math.round(hours / 24);
332
+ return `${days} ${days === 1 ? 'day' : 'days'}`;
289
333
  }
290
334
 
291
335
  // True when a branch was made or moved after the cutoff. A running job makes
@@ -381,7 +425,7 @@ function checkZen({ cwd = process.cwd(), fetch = true, freshMinutes = freshMinut
381
425
  }
382
426
  const status = runGit(['status', '--porcelain'], wt.path);
383
427
  if (status.status !== 0) busy.push(wt);
384
- else if (status.stdout.trim()) (idleWithOnlyNotes(wt.path, teamState, cutoff) ? notesOnly : busy).push(wt);
428
+ else if (status.stdout.trim() && !folderLinksOnly(wt.path, root).length) (idleWithOnlyNotes(wt.path, teamState, cutoff) ? notesOnly : busy).push(wt);
385
429
  else if (headMovedSince(wt.path, cutoff)) inUse.push(wt);
386
430
  else idle.push(wt);
387
431
  }
@@ -439,27 +483,44 @@ function checkZen({ cwd = process.cwd(), fetch = true, freshMinutes = freshMinut
439
483
  };
440
484
  }
441
485
 
442
- // Where the side copies are, so a line about them can be acted on: home
443
- // shortened to ~, at most three named.
444
- // Side copies left with unsaved work: one place per line so a long path
445
- // still fits a narrow pane, then when zen will take them over.
446
- function sideCopyLines(paths, days) {
486
+ // A place a person can paste: home as ~, and the system temp folder (git
487
+ // may report it through /private) as $TMPDIR when that variable is set, so a
488
+ // test leftover there fits a narrow pane.
489
+ function shortPlace(p) {
447
490
  const home = os.homedir();
491
+ const tmp = (process.env.TMPDIR || '').replace(/\/+$/, '');
492
+ if (tmp) {
493
+ let real = tmp;
494
+ try {
495
+ real = fs.realpathSync(tmp);
496
+ } catch {}
497
+ for (const dir of new Set([tmp, real])) {
498
+ if (p === dir || p.startsWith(dir + path.sep)) return `$TMPDIR${p.slice(dir.length)}`;
499
+ }
500
+ }
501
+ return p === home || p.startsWith(home + path.sep) ? `~${p.slice(home.length)}` : p;
502
+ }
503
+
504
+ // Side copies left with unsaved work: at most three named, one place per
505
+ // line so a long path still fits a narrow pane, then when zen will take
506
+ // them over and, when known, how long until the first one goes.
507
+ function sideCopyLines(paths, days, waitMs = null) {
448
508
  const n = paths.length;
449
509
  const lines = [`${n} side ${n === 1 ? 'copy has' : 'copies have'} unsaved work, left for ${n === 1 ? 'its owner' : 'their owners'}`];
450
- for (const p of paths.slice(0, 3)) {
451
- lines.push(` ${p === home || p.startsWith(home + path.sep) ? `~${p.slice(home.length)}` : p}`);
452
- }
510
+ for (const p of paths.slice(0, 3)) lines.push(` ${shortPlace(p)}`);
453
511
  if (n > 3) lines.push(` and ${n - 3} more`);
454
512
  lines.push(`zen saves ${n === 1 ? 'it' : 'each one'} to zen-backup/ once nobody has touched it for ${days} ${days === 1 ? 'day' : 'days'}`);
513
+ if (waitMs > 0) lines.push(`${n === 1 ? 'that is' : 'the first one is'} due in ${waitText(waitMs)}`);
455
514
  return lines;
456
515
  }
457
516
 
458
517
  module.exports = {
459
518
  ABANDONED_DAYS,
460
519
  branchMovedSince,
520
+ folderLinksOnly,
461
521
  idleWithOnlyNotes,
462
522
  lastTouched,
523
+ nextSaveIn,
463
524
  untrackedFiles,
464
525
  abandonedDaysFromEnv,
465
526
  sideCopyLines,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.74.6",
3
+ "version": "3.74.7",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof.",
5
5
  "mcpName": "io.github.atrislabs/atris",
6
6
  "main": "bin/atris.js",