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/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/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/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
@@ -623,6 +623,7 @@ function applyZen({ cwd = process.cwd(), fetch = true, freshMinutes, abandonedDa
623
623
  notes,
624
624
  saved,
625
625
  team_files: after.team_files,
626
+ team_places: after.team_places,
626
627
  remaining: after.problems.map((p) => p.text),
627
628
  };
628
629
  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 = [];
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.74.5",
3
+ "version": "3.74.6",
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",