atris 3.74.5 → 3.74.6
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/commands/gm.js +1 -1
- package/commands/task.js +4 -2
- package/commands/xp.js +68 -15
- package/commands/zen.js +10 -3
- package/lib/career-xp-lock.js +61 -0
- package/lib/career-xp-root.js +61 -0
- 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/task-db.js +8 -4
- package/lib/yes-lock.js +22 -3
- package/lib/zen-apply.js +1 -0
- package/lib/zen.js +15 -0
- 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/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/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
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 = [];
|
|
@@ -334,6 +347,7 @@ function checkZen({ cwd = process.cwd(), fetch = true, freshMinutes = freshMinut
|
|
|
334
347
|
const dirty = dirtyFiles(root);
|
|
335
348
|
const stray = dirty.filter((file) => !isTeamState(file, teamState));
|
|
336
349
|
const teamFiles = dirty.length - stray.length;
|
|
350
|
+
const teamPlaces = placeCounts(dirty.filter((file) => isTeamState(file, teamState)));
|
|
337
351
|
if (stray.length) {
|
|
338
352
|
problems.push({
|
|
339
353
|
kind: 'stray_code',
|
|
@@ -415,6 +429,7 @@ function checkZen({ cwd = process.cwd(), fetch = true, freshMinutes = freshMinut
|
|
|
415
429
|
ahead,
|
|
416
430
|
behind,
|
|
417
431
|
team_files: teamFiles,
|
|
432
|
+
team_places: teamPlaces,
|
|
418
433
|
stashes,
|
|
419
434
|
worktrees_with_work: busy.map((wt) => wt.path),
|
|
420
435
|
worktrees_in_use: inUse.map((wt) => wt.path),
|
package/package.json
CHANGED