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/bin/atris.js +9 -0
- package/commands/gm.js +1 -1
- package/commands/idea.js +36 -0
- package/commands/task.js +4 -2
- package/commands/xp.js +68 -15
- package/commands/zen.js +14 -5
- package/lib/career-xp-lock.js +61 -0
- package/lib/career-xp-root.js +61 -0
- package/lib/engine-login.js +105 -0
- package/lib/fleet.js +4 -1
- package/lib/gm-bet.js +405 -17
- package/lib/gm-card.js +431 -0
- package/lib/gm-phone-page.js +481 -1004
- package/lib/gm-phone.js +33 -38
- package/lib/idea-tick.js +20 -0
- package/lib/task-db.js +8 -4
- package/lib/values.js +38 -0
- package/lib/yes-lock.js +22 -3
- package/lib/zen-apply.js +17 -1
- package/lib/zen.js +90 -14
- package/package.json +1 -1
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
|
-
//
|
|
505
|
-
//
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
583
|
-
if (req.method === 'GET' && check.path === '/
|
|
584
|
-
if (req.method === '
|
|
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
|
|
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
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
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
|
-
//
|
|
269
|
-
function
|
|
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
|
|
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
|
|
275
|
-
return Math.max(mtime(path.join(dir, 'logs', 'HEAD')), mtime(path.join(dir, 'HEAD')))
|
|
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
|
-
//
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
|
|
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