atris 3.74.5 → 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/lib/gm-phone.js CHANGED
@@ -317,6 +317,18 @@ function checkRequest(req, { token, hosts = [] }) {
317
317
  return { ok: false, status: 400, reason: 'url' };
318
318
  }
319
319
  if (!hosts.includes(host)) return { ok: false, status: 403, reason: 'host' };
320
+ // Another site is refused on every route, reads too: a page in the owner's
321
+ // browser that holds the key could otherwise read his cards. A fetch says
322
+ // where it came from in Origin or Sec-Fetch-Site; a scan or a typed link
323
+ // says none, and a plain client says nothing. The one exception is a
324
+ // top-level open of the page itself (a link tapped in a mail or chat app):
325
+ // a navigation cannot read anything, and the page load starts no work.
326
+ if (req.headers.origin != null && String(req.headers.origin) !== `http://${host}`) return { ok: false, status: 403, reason: 'origin' };
327
+ const site = String(req.headers['sec-fetch-site'] || '').toLowerCase();
328
+ const pageOpen = req.method === 'GET' && url.pathname === '/'
329
+ && String(req.headers['sec-fetch-mode'] || '').toLowerCase() === 'navigate'
330
+ && String(req.headers['sec-fetch-dest'] || '').toLowerCase() === 'document';
331
+ if (site && site !== 'same-origin' && site !== 'none' && !pageOpen) return { ok: false, status: 403, reason: 'site' };
320
332
  const header = req.headers['x-gm-token'];
321
333
  if (req.method === 'GET') {
322
334
  if (!tokenMatches(url.searchParams.get('t') || header, token)) return { ok: false, status: 403, reason: 'token' };
@@ -501,37 +513,21 @@ function createPhoneHandler(deps) {
501
513
  return { status: ok ? 200 : 500, body: { ok, ack: ok ? 'undone, target back how it was' : 'could not undo' } };
502
514
  }
503
515
 
504
- // think shows the next card at once (a choice of ideas, one idea, or a
505
- // question when none is ready); pick opens one idea from a choice; sharper
506
- // asks for a new bet; answer saves the owner's answer to the question on
507
- // screen; do it needs a second tap because it starts
508
- // agents; not now only marks the bet skipped.
509
- async function betAction(p, body) {
510
- let out;
511
- if (p === '/bet/think') out = bet.next({ sharper: body.sharper === true });
512
- // typed words in something else are read first, like say something: a
513
- // question back or confusion is explained and nothing is saved
514
- else if (p === '/bet/answer' && body.choice === 'other') out = await bet.say({ text: String(body.text || ''), card: String(body.card || '') });
515
- else if (p === '/bet/answer') out = bet.answer({ choice: String(body.choice || ''), text: String(body.text || ''), card: String(body.card || '') });
516
- else if (p === '/bet/report') out = bet.report({ value: body.value });
517
- else if (p === '/bet/pick') out = bet.pick({ id: String(body.id || '') });
518
- 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 });
519
- else if (p === '/bet/say') out = await bet.say({ text: String(body.text || ''), confirm: body.confirm === true, note: body.note === true, card: String(body.card || '') });
520
- else if (p === '/bet/skip') out = bet.skip();
521
- // the engine card and its one-tap swap; the tap is the owner's approval
522
- else if (p === '/bet/engine') out = await bet.engineCard({ member: String(body.member || '') });
523
- else if (p === '/bet/swap') out = await bet.swapEngine({ member: String(body.member || ''), engine: String(body.engine || ''), model: String(body.model || ''), scope: String(body.scope || '') });
524
- else if (p === '/bet/do') {
525
- if (body.confirm !== true) return { status: 409, body: { ok: false, reason: 'needs_confirm', confirm_ms: CONFIRM_MS } };
526
- out = await bet.doIt();
527
- } else if (p === '/bet/nice') {
528
- out = bet.nice();
529
- } else if (p === '/bet/yes') {
530
- // a yes unblocks agents, so it needs a second tap
531
- if (body.confirm !== true) return { status: 409, body: { ok: false, reason: 'needs_confirm', confirm_ms: CONFIRM_MS } };
532
- out = await bet.answerYes(typeof body.key === 'string' && body.key ? { key: body.key.slice(0, 300), job: typeof body.job === 'string' ? body.job.slice(0, 300) : '' } : {});
533
- } else return { status: 404, body: { ok: false, reason: 'not_found' } };
534
- return { status: out.ok ? 200 : 409, body: out };
516
+ // The one route for the card on screen (lib/gm-card.js): the card's id and
517
+ // one option on it, or typed words for its box, or the orb's next card, or
518
+ // the team or the job deck from the footer. The desk checks the card id
519
+ // and the option, and refuses anything else. A second tap is the page
520
+ // sending confirm on an option that asks for one.
521
+ function cardAction(body) {
522
+ const clean = {
523
+ card_id: typeof body.card_id === 'string' ? body.card_id.slice(0, 300) : null,
524
+ option_id: typeof body.option_id === 'string' ? body.option_id.slice(0, 120) : null,
525
+ confirm: body.confirm === true,
526
+ orb: body.orb === true,
527
+ open: body.open === 'team' || body.open === 'deck' ? body.open : null,
528
+ };
529
+ if (typeof body.text === 'string') clean.text = body.text.slice(0, 1000);
530
+ return Promise.resolve(bet.act(clean));
535
531
  }
536
532
 
537
533
  // A typed send carries its own id, made on the phone when the words are
@@ -542,11 +538,11 @@ function createPhoneHandler(deps) {
542
538
  const sends = new Map();
543
539
  function sendOnce(p, body) {
544
540
  const id = typeof body.send === 'string' && SEND_ID.test(body.send) ? `${p} ${body.send}` : null;
545
- if (!id) return betAction(p, body);
541
+ if (!id) return cardAction(body);
546
542
  const seen = sends.get(id);
547
543
  if (seen && now() - seen.at < SENDS_MS) return seen.out;
548
544
  sends.delete(id);
549
- const out = betAction(p, body);
545
+ const out = cardAction(body);
550
546
  sends.set(id, { at: now(), out });
551
547
  const forget = () => { if (sends.get(id) && sends.get(id).out === out) sends.delete(id); };
552
548
  out.then(r => { if (!r.body || r.body.ok !== true) forget(); }, forget);
@@ -579,10 +575,9 @@ function createPhoneHandler(deps) {
579
575
  });
580
576
  }
581
577
  if (req.method === 'GET' && check.path === '/state') return send(res, 200, state());
582
- if (req.method === 'GET' && check.path === '/bet') return bet ? send(res, 200, bet.warm()) : send(res, 404, { ok: false, reason: 'not_found' });
583
- if (req.method === 'GET' && check.path === '/bet/team') return bet ? send(res, 200, bet.teamTable()) : send(res, 404, { ok: false, reason: 'not_found' });
584
- if (req.method === 'GET' && check.path === '/bet/jobs') return bet ? send(res, 200, bet.jobDeck()) : send(res, 404, { ok: false, reason: 'not_found' });
585
- if (req.method === 'POST' && (check.path === '/action' || check.path === '/undo' || (bet && check.path.startsWith('/bet/')))) {
578
+ // the card on screen now; a read starts no work
579
+ if (req.method === 'GET' && check.path === '/card') return bet ? send(res, 200, bet.card()) : send(res, 404, { ok: false, reason: 'not_found' });
580
+ if (req.method === 'POST' && (check.path === '/action' || check.path === '/undo' || (bet && check.path === '/act'))) {
586
581
  if (String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase() !== 'application/json') return send(res, 415, { ok: false, reason: 'json_only' });
587
582
  let body;
588
583
  try {
@@ -591,7 +586,7 @@ function createPhoneHandler(deps) {
591
586
  } catch (err) {
592
587
  return send(res, err.message === 'too_large' ? 413 : 400, { ok: false, reason: 'bad_body' });
593
588
  }
594
- if (check.path.startsWith('/bet/')) {
589
+ if (check.path === '/act') {
595
590
  const out = await sendOnce(check.path, body || {});
596
591
  return send(res, out.status, out.body);
597
592
  }
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/task-db.js CHANGED
@@ -35,6 +35,8 @@ const { explicitDoneLooksLike, taskExplanation } = require('./task-explanation')
35
35
  const { treeHashFor } = require('./tree-hash');
36
36
  const { sleepSync } = require('./sleep-sync');
37
37
  const { atrisHome } = require('./state-home');
38
+ const { careerXpRoot } = require('./career-xp-root');
39
+ const { withCareerXpLock } = require('./career-xp-lock');
38
40
 
39
41
  const TASK_EPISODES_FILE = path.join('.atris', 'state', 'task_episodes.jsonl');
40
42
  const TODO_RENDER_DONE_LIMIT = 8;
@@ -2054,10 +2056,12 @@ function taskEpisodeFromRevision(row, event, payload) {
2054
2056
  }
2055
2057
 
2056
2058
  function appendTaskEpisode(workspaceRoot, episode) {
2057
- const filePath = path.join(workspaceRoot, TASK_EPISODES_FILE);
2058
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
2059
- fs.appendFileSync(filePath, JSON.stringify(episode) + '\n', 'utf8');
2060
- return filePath;
2059
+ const workspace = careerXpRoot(workspaceRoot);
2060
+ return withCareerXpLock(workspace, () => {
2061
+ const filePath = path.join(workspace, TASK_EPISODES_FILE);
2062
+ fs.appendFileSync(filePath, JSON.stringify(episode) + '\n', 'utf8');
2063
+ return filePath;
2064
+ });
2061
2065
  }
2062
2066
 
2063
2067
  function clipProjectionText(value, max = PROJECTION_PAYLOAD_TEXT_LIMIT) {
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/yes-lock.js CHANGED
@@ -322,7 +322,7 @@ function rowRequests(row) {
322
322
  // What a step looks like from outside: its newest waiting command, and its
323
323
  // yes that is given and not used yet.
324
324
  function stepSummary(requests) {
325
- const pending = requests.filter(r => !r.approved_at && !r.used_at);
325
+ const pending = requests.filter(r => !r.approved_at && !r.used_at && !r.denied_at);
326
326
  const open = requests.find(r => r.approved_at && !r.used_at) || null;
327
327
  const used = requests.filter(r => r.used_at);
328
328
  const last = pending[pending.length - 1] || null;
@@ -370,7 +370,7 @@ function approveYesLock(stateDir, missionId, step, seal, at = new Date().toISOSt
370
370
  const { locks } = effectiveLocks(stateDir, missionId, seal || sealFromState(stateDir, missionId));
371
371
  const lock = locks.find(l => l.step === step);
372
372
  if (!lock) return null;
373
- const waiting = lock.requests.filter(r => !r.approved_at && !r.used_at);
373
+ const waiting = lock.requests.filter(r => !r.approved_at && !r.used_at && !r.denied_at);
374
374
  const target = hash ? waiting.find(r => r.hash === hash) : waiting[waiting.length - 1];
375
375
  if (!target) return null;
376
376
  target.approved_at = at || new Date().toISOString();
@@ -378,6 +378,20 @@ function approveYesLock(stateDir, missionId, step, seal, at = new Date().toISOSt
378
378
  return { ...lock, ...stepSummary(lock.requests), approved: target };
379
379
  }
380
380
 
381
+ // The owner's no: the one waiting request with that hash is told no for good.
382
+ // It never runs, its card is gone, and asking again only hears the no.
383
+ // Without a hash (an older caller), the newest waiting request on the step.
384
+ function denyYesLock(stateDir, missionId, step, { hash = null, at = new Date().toISOString() } = {}) {
385
+ const { locks } = effectiveLocks(stateDir, missionId, sealFromState(stateDir, missionId));
386
+ const lock = locks.find(l => l.step === step);
387
+ const waiting = lock ? lock.requests.filter(r => !r.approved_at && !r.used_at && !r.denied_at) : [];
388
+ const target = hash ? waiting.find(r => r.hash === hash) : waiting[waiting.length - 1];
389
+ if (!target) return null;
390
+ target.denied_at = at;
391
+ saveLocks(stateDir, missionId, locks);
392
+ return { ...lock, denied: target };
393
+ }
394
+
381
395
  // For readers that have no mission record at hand (the desk), the state file
382
396
  // is its own seal: it is only ever used to show and answer cards, never to let
383
397
  // a command through.
@@ -393,7 +407,7 @@ function askedYesLocks(stateDir, missionId) {
393
407
  const cards = [];
394
408
  for (const row of data.locks) {
395
409
  for (const r of rowRequests(row)) {
396
- if (r.approved_at || r.used_at) continue;
410
+ if (r.approved_at || r.used_at || r.denied_at) continue;
397
411
  cards.push({ step: row.step, text: row.text, categories: row.categories, held_command: r.command, held_key: r.key, hash: r.hash, asked_at: r.asked_at });
398
412
  }
399
413
  }
@@ -1156,6 +1170,10 @@ function check({ stateDir, missionId, seal, tool, args = [], gitBin = null, cwd
1156
1170
  saveLocks(stateDir, missionId, locks);
1157
1171
  return { consume: owners.map(l => l.step), claims };
1158
1172
  }
1173
+ // the owner said no to this exact command: it stays held, and no new card is asked
1174
+ if (owners.some(l => l.requests.some(r => r.hash === hash && r.denied_at && !r.approved_at))) {
1175
+ return { hold: true, steps: owners.map(l => l.step), message: `held: the owner said no to "${shown}". do not run it again; leave it undone and go on with the rest.` };
1176
+ }
1159
1177
  // held: a request on each step still missing a yes, one per exact command
1160
1178
  owners.forEach((lock, n) => {
1161
1179
  if (ready[n]) return;
@@ -1190,6 +1208,7 @@ module.exports = {
1190
1208
  gitContext,
1191
1209
  approveYesLock,
1192
1210
  askedYesLocks,
1211
+ denyYesLock,
1193
1212
  check,
1194
1213
  consume,
1195
1214
  effectiveLocks,
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}`);
@@ -623,6 +638,7 @@ function applyZen({ cwd = process.cwd(), fetch = true, freshMinutes, abandonedDa
623
638
  notes,
624
639
  saved,
625
640
  team_files: after.team_files,
641
+ team_places: after.team_places,
626
642
  remaining: after.problems.map((p) => p.text),
627
643
  };
628
644
  try {
package/lib/zen.js CHANGED
@@ -107,6 +107,19 @@ function dirtyFiles(root) {
107
107
  : [entry.path]));
108
108
  }
109
109
 
110
+ // Where unsaved team files sit, by top folder (or file at the root),
111
+ // biggest first and ties by name: [{ place: 'atris/', count: 52 }, ...].
112
+ function placeCounts(files) {
113
+ const counts = new Map();
114
+ for (const file of files) {
115
+ const slash = file.indexOf('/');
116
+ const place = slash === -1 ? file : file.slice(0, slash + 1);
117
+ counts.set(place, (counts.get(place) || 0) + 1);
118
+ }
119
+ return [...counts].map(([place, count]) => ({ place, count }))
120
+ .sort((a, b) => b.count - a.count || (a.place < b.place ? -1 : a.place > b.place ? 1 : 0));
121
+ }
122
+
110
123
  function listWorktrees(root) {
111
124
  const out = gitOut(['worktree', 'list', '--porcelain'], root) || '';
112
125
  const worktrees = [];
@@ -218,6 +231,27 @@ function lastTouched(checkout) {
218
231
  return newest;
219
232
  }
220
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
+
221
255
  // A side copy whose only changes are team notes, untouched and not switched
222
256
  // or committed in the fresh window, holds nothing anyone is working on. Live
223
257
  // data files never count as notes, so a copy holding one is left alone.
@@ -265,14 +299,37 @@ function lastEntryTime(log) {
265
299
  }
266
300
  }
267
301
 
268
- // True when a checkout switched or made a commit after the cutoff.
269
- function headMovedSince(checkout, cutoff) {
302
+ // When a checkout last switched or made a commit.
303
+ function headTime(checkout) {
270
304
  const gitDir = gitOut(['rev-parse', '--git-dir'], checkout);
271
- if (!gitDir) return false;
305
+ if (!gitDir) return 0;
272
306
  const dir = path.resolve(checkout, gitDir);
273
307
  const logged = lastEntryTime(path.join(dir, 'logs', 'HEAD'));
274
- if (logged !== null) return logged > cutoff;
275
- 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'}`;
276
333
  }
277
334
 
278
335
  // True when a branch was made or moved after the cutoff. A running job makes
@@ -334,6 +391,7 @@ function checkZen({ cwd = process.cwd(), fetch = true, freshMinutes = freshMinut
334
391
  const dirty = dirtyFiles(root);
335
392
  const stray = dirty.filter((file) => !isTeamState(file, teamState));
336
393
  const teamFiles = dirty.length - stray.length;
394
+ const teamPlaces = placeCounts(dirty.filter((file) => isTeamState(file, teamState)));
337
395
  if (stray.length) {
338
396
  problems.push({
339
397
  kind: 'stray_code',
@@ -367,7 +425,7 @@ function checkZen({ cwd = process.cwd(), fetch = true, freshMinutes = freshMinut
367
425
  }
368
426
  const status = runGit(['status', '--porcelain'], wt.path);
369
427
  if (status.status !== 0) busy.push(wt);
370
- 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);
371
429
  else if (headMovedSince(wt.path, cutoff)) inUse.push(wt);
372
430
  else idle.push(wt);
373
431
  }
@@ -415,6 +473,7 @@ function checkZen({ cwd = process.cwd(), fetch = true, freshMinutes = freshMinut
415
473
  ahead,
416
474
  behind,
417
475
  team_files: teamFiles,
476
+ team_places: teamPlaces,
418
477
  stashes,
419
478
  worktrees_with_work: busy.map((wt) => wt.path),
420
479
  worktrees_in_use: inUse.map((wt) => wt.path),
@@ -424,27 +483,44 @@ function checkZen({ cwd = process.cwd(), fetch = true, freshMinutes = freshMinut
424
483
  };
425
484
  }
426
485
 
427
- // Where the side copies are, so a line about them can be acted on: home
428
- // shortened to ~, at most three named.
429
- // Side copies left with unsaved work: one place per line so a long path
430
- // still fits a narrow pane, then when zen will take them over.
431
- 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) {
432
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) {
433
508
  const n = paths.length;
434
509
  const lines = [`${n} side ${n === 1 ? 'copy has' : 'copies have'} unsaved work, left for ${n === 1 ? 'its owner' : 'their owners'}`];
435
- for (const p of paths.slice(0, 3)) {
436
- lines.push(` ${p === home || p.startsWith(home + path.sep) ? `~${p.slice(home.length)}` : p}`);
437
- }
510
+ for (const p of paths.slice(0, 3)) lines.push(` ${shortPlace(p)}`);
438
511
  if (n > 3) lines.push(` and ${n - 3} more`);
439
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)}`);
440
514
  return lines;
441
515
  }
442
516
 
443
517
  module.exports = {
444
518
  ABANDONED_DAYS,
445
519
  branchMovedSince,
520
+ folderLinksOnly,
446
521
  idleWithOnlyNotes,
447
522
  lastTouched,
523
+ nextSaveIn,
448
524
  untrackedFiles,
449
525
  abandonedDaysFromEnv,
450
526
  sideCopyLines,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.74.5",
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",