atris 3.74.6 → 3.74.8
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/idea.js +36 -0
- package/commands/zen.js +4 -2
- package/lib/engine-login.js +105 -0
- package/lib/fleet.js +4 -1
- package/lib/gm-bet.js +706 -161
- package/lib/gm-card.js +45 -4
- package/lib/gm-phone-page.js +450 -59
- package/lib/idea-tick.js +20 -0
- package/lib/values.js +38 -0
- package/lib/zen-apply.js +16 -1
- package/lib/zen.js +88 -14
- package/package.json +1 -1
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
|
-
//
|
|
282
|
-
function
|
|
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
|
|
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
|
|
288
|
-
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'}`;
|
|
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,57 @@ function checkZen({ cwd = process.cwd(), fetch = true, freshMinutes = freshMinut
|
|
|
439
483
|
};
|
|
440
484
|
}
|
|
441
485
|
|
|
442
|
-
//
|
|
443
|
-
//
|
|
444
|
-
//
|
|
445
|
-
|
|
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
|
-
|
|
451
|
-
|
|
510
|
+
// Copies that share a folder (one workflow's runs, say) read as one line.
|
|
511
|
+
const folders = new Map();
|
|
512
|
+
for (const p of paths) {
|
|
513
|
+
const dir = path.dirname(p);
|
|
514
|
+
if (!folders.has(dir)) folders.set(dir, []);
|
|
515
|
+
folders.get(dir).push(p);
|
|
516
|
+
}
|
|
517
|
+
const rows = [];
|
|
518
|
+
for (const [dir, group] of folders) {
|
|
519
|
+
if (group.length >= 3) rows.push({ text: `${group.length} in ${shortPlace(dir)}/`, count: group.length });
|
|
520
|
+
else for (const p of group) rows.push({ text: shortPlace(p), count: 1 });
|
|
452
521
|
}
|
|
453
|
-
|
|
522
|
+
for (const row of rows.slice(0, 3)) lines.push(` ${row.text}`);
|
|
523
|
+
const rest = rows.slice(3).reduce((sum, row) => sum + row.count, 0);
|
|
524
|
+
if (rest) lines.push(` and ${rest} more`);
|
|
454
525
|
lines.push(`zen saves ${n === 1 ? 'it' : 'each one'} to zen-backup/ once nobody has touched it for ${days} ${days === 1 ? 'day' : 'days'}`);
|
|
526
|
+
if (waitMs > 0) lines.push(`${n === 1 ? 'that is' : 'the first one is'} due in ${waitText(waitMs)}`);
|
|
455
527
|
return lines;
|
|
456
528
|
}
|
|
457
529
|
|
|
458
530
|
module.exports = {
|
|
459
531
|
ABANDONED_DAYS,
|
|
460
532
|
branchMovedSince,
|
|
533
|
+
folderLinksOnly,
|
|
461
534
|
idleWithOnlyNotes,
|
|
462
535
|
lastTouched,
|
|
536
|
+
nextSaveIn,
|
|
463
537
|
untrackedFiles,
|
|
464
538
|
abandonedDaysFromEnv,
|
|
465
539
|
sideCopyLines,
|
package/package.json
CHANGED