atris 3.74.8 → 3.74.9

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 CHANGED
@@ -797,19 +797,20 @@ function tail(text, used, width) {
797
797
  return clip(text, Math.max(10, width - used));
798
798
  }
799
799
 
800
- // Last guard on width: a long line breaks after a comma onto an indented
801
- // line; anything still too long ends in "...".
800
+ // Last guard on width, in terminal columns: a long line breaks after a
801
+ // comma onto an indented line; anything still too long ends in "...".
802
802
  function fitLines(lines, width) {
803
+ const { displayWidth, sliceColumns } = require('../lib/text-table');
803
804
  const out = [];
804
805
  for (const line of lines) {
805
806
  let rest = line;
806
- while (rest.length > width) {
807
- const cut = rest.lastIndexOf(', ', width - 1);
807
+ while (displayWidth(rest) > width) {
808
+ const cut = rest.lastIndexOf(', ', sliceColumns(rest, width).length - 1);
808
809
  if (cut <= 0) break;
809
810
  out.push(rest.slice(0, cut + 1));
810
811
  rest = ` ${rest.slice(cut + 2)}`;
811
812
  }
812
- out.push(rest.length > width ? `${rest.slice(0, width - 3)}...` : rest);
813
+ out.push(displayWidth(rest) > width ? `${sliceColumns(rest, width - 3)}...` : rest);
813
814
  }
814
815
  return out;
815
816
  }
@@ -830,13 +831,30 @@ function boardWorkspaceRoot(args = []) {
830
831
  return taskDb.workspaceRoot(path.resolve(workspaceArg));
831
832
  }
832
833
 
834
+ // The team page's cells (role, ovr, engine) for each roster member, read
835
+ // only when the board draws its box in a terminal; null otherwise.
836
+ function boardCells(board, workspaceRoot, ratings) {
837
+ if (!process.stdout.isTTY || !workspaceRoot) return null;
838
+ return require('../lib/gm-bet').teamCells(workspaceRoot, board.roster.map(r => r.name), { ratings });
839
+ }
840
+
833
841
  function loadBoard(workspaceRoot, options = {}) {
842
+ return loadSeasonBoard(workspaceRoot, options).board;
843
+ }
844
+
845
+ // The board and every member's rating (gm-season memberRatings, the same
846
+ // numbers the orb's team page reads) from one read of the season.
847
+ function loadSeasonBoard(workspaceRoot, options = {}) {
834
848
  const season = require('../lib/gm-season');
835
- return season.seasonBoard(season.loadSeason(workspaceRoot, { lastSeen: options.lastSeen }), options);
849
+ const played = season.loadSeason(workspaceRoot, { lastSeen: options.lastSeen });
850
+ return { board: season.seasonBoard(played, options), ratings: season.memberRatings(played.outcomes || []) };
836
851
  }
837
852
 
838
853
  // One screen, about 40 rows at most. Pure: board in, lines out.
839
- function renderBoardLines(board, { interactive = false, width = termWidth() } = {}) {
854
+ // In a terminal, given the team page's cells, the roster is the orb's team
855
+ // table as a divided box (member, role, ovr, engine, status); piped, or when
856
+ // the box cannot fit the width, it keeps the plain columns.
857
+ function renderBoardLines(board, { interactive = false, width = termWidth(), bordered = Boolean(process.stdout.isTTY), cells = null } = {}) {
840
858
  const { clockLabel } = require('../lib/gm-season');
841
859
  const lines = [];
842
860
  const nameW = nameWidth([
@@ -856,7 +874,12 @@ function renderBoardLines(board, { interactive = false, width = termWidth() } =
856
874
  lines.push('');
857
875
 
858
876
  lines.push('roster, last 30 days');
859
- if (board.roster.length) {
877
+ const boxed = bordered && cells && board.roster.length
878
+ ? require('../lib/gm-bet').teamBox(board.roster.map(r => ({ member: r.name, status: r.status })), cells, { width: width - 2 })
879
+ : null;
880
+ if (boxed) {
881
+ lines.push(...boxed.split('\n').map(line => ` ${line}`));
882
+ } else if (board.roster.length) {
860
883
  lines.push(` ${pad('name', nameW)}${pad('ovr', 8)}${pad('form', 7)}${pad('record', 18)}status`);
861
884
  for (const row of board.roster) {
862
885
  const record = `landed ${row.landed} of ${row.jobs}`;
@@ -913,9 +936,10 @@ function renderBoardLines(board, { interactive = false, width = termWidth() } =
913
936
  }
914
937
  lines.push('');
915
938
 
916
- lines.push('key: ovr 60 to 99 = share of finished jobs landed, rookie under 3 finished,');
939
+ lines.push(`key: ovr 60 to 99 = share of finished jobs landed, ${boxed ? 'new' : 'rookie'} under 3 finished,`);
917
940
  lines.push(' stricter than the website rating on purpose (drops and abandons count)');
918
- lines.push(' form: last 5, newest right: k landed, d dropped, x sent back, a abandoned');
941
+ if (boxed) lines.push(' ·· = nothing to show yet: no finished job, no role, or no pinned model');
942
+ else lines.push(' form: last 5, newest right: k landed, d dropped, x sent back, a abandoned');
919
943
  lines.push(' hours: claim to result. on a job: moved in the last 6h. review: pending');
920
944
  if (interactive) for (const line of PLAY_KEYS) lines.push(line);
921
945
  else lines.push(' run atris gm in a terminal to play');
@@ -990,9 +1014,10 @@ function printLeague() {
990
1014
  }
991
1015
 
992
1016
  function printBoard(args = []) {
993
- const board = loadBoard(boardWorkspaceRoot(args));
1017
+ const root = boardWorkspaceRoot(args);
1018
+ const { board, ratings } = loadSeasonBoard(root);
994
1019
  if (board.error) return failRead(board.error);
995
- console.log(renderBoardLines(board).join('\n'));
1020
+ console.log(renderBoardLines(board, { cells: boardCells(board, root, ratings) }).join('\n'));
996
1021
  return undefined;
997
1022
  }
998
1023
 
@@ -1107,11 +1132,11 @@ async function gmPlay(args = []) {
1107
1132
  leagueRowsSeen = [...board.rows, ...(board.hidden_clubs || [])];
1108
1133
  write(renderLeagueLines(board, { interactive: true }).join('\n'));
1109
1134
  } else {
1110
- const board = loadBoard(workspaceRoot, { seen, lastSeen });
1135
+ const { board, ratings } = loadSeasonBoard(workspaceRoot, { seen, lastSeen });
1111
1136
  lastBoard = board;
1112
1137
  seen = new Set(board.last_plays.map(play => play.id));
1113
1138
  stuck = board.stuck || [];
1114
- write(renderBoardLines(board, { interactive: true }).join('\n'));
1139
+ write(renderBoardLines(board, { interactive: true, cells: boardCells(board, workspaceRoot, ratings) }).join('\n'));
1115
1140
  season.writeLastSeen(workspaceRoot, board.now);
1116
1141
  }
1117
1142
  } catch (err) {
@@ -1986,9 +2011,11 @@ async function gmCommand(...args) {
1986
2011
  }
1987
2012
 
1988
2013
  module.exports = {
2014
+ boardCells,
1989
2015
  displayCommand,
1990
2016
  ensureStarterMission,
1991
2017
  gmCommand,
2018
+ loadSeasonBoard,
1992
2019
  pickClub,
1993
2020
  playAction,
1994
2021
  renderBoardLines,
@@ -11664,6 +11664,44 @@ async function memberStandupWalk(room, args = []) {
11664
11664
  return payload;
11665
11665
  }
11666
11666
 
11667
+ // The team page's cells (role, ovr, engine) per standup member, read only
11668
+ // when the standup can draw its table (a terminal); --json never carries
11669
+ // them. ovr comes from gm-season memberRatings, as on the orb's team page.
11670
+ function standupTableData(standup, cwd = process.cwd()) {
11671
+ if (!process.stdout.isTTY) return { bordered: false };
11672
+ const season = require('../lib/gm-season');
11673
+ const root = require('../lib/task-db').workspaceRoot(path.resolve(cwd));
11674
+ let ratings = new Map();
11675
+ try {
11676
+ const played = season.loadSeason(root, { lastSeen: null });
11677
+ if (played && !played.error) ratings = season.memberRatings(played.outcomes || []);
11678
+ } catch { ratings = new Map(); }
11679
+ const cells = require('../lib/gm-bet').teamCells(root, standup.map((s) => s.member), { ratings });
11680
+ return { bordered: true, cells };
11681
+ }
11682
+
11683
+ // The standup: in a terminal the orb's team table as a divided box
11684
+ // (member, role, ovr, engine, status) with each member's found and push
11685
+ // lines under it; piped, or when the box cannot fit, the boxes it always had.
11686
+ function memberStandupLines(standup, { bordered = false, width = process.stdout.columns, cells = null } = {}) {
11687
+ if (bordered && cells && standup.length) {
11688
+ const table = require('../lib/gm-bet').teamBox(standup.map((s) => ({ member: s.member, status: s.status })), cells, { width: Number(width) - 2 });
11689
+ if (table) {
11690
+ return [
11691
+ ...table.split('\n').map((line) => ` ${line}`),
11692
+ ...standup.flatMap((s) => [` ${s.member}`, ` found: ${s.latest_update}`, ` push: ${s.next_push}`]),
11693
+ ];
11694
+ }
11695
+ }
11696
+ return standup.flatMap((s, idx) => [
11697
+ `+-----------------------------------------------------------------------+`,
11698
+ `| ${idx + 1}. ${s.member.toUpperCase()} (${s.role}) · ${s.status} · engine:${s.engine}`,
11699
+ `| Found: ${s.latest_update}`,
11700
+ `| Push: ${s.next_push}`,
11701
+ `+-----------------------------------------------------------------------+`,
11702
+ ]);
11703
+ }
11704
+
11667
11705
  async function memberRoom(...args) {
11668
11706
  if (hasFlag(args, '--help') || hasFlag(args, '-h')) {
11669
11707
  console.log('Usage: atris member [day|standup|room] [--walk] [--limit N] [--decisions "y|idea|n"] [--json]');
@@ -11680,13 +11718,7 @@ async function memberRoom(...args) {
11680
11718
  ? `Your team runs on ${teamEngines.length > 1 ? `${teamEngines.slice(0, -1).join(', ')} and ${teamEngines[teamEngines.length - 1]}` : teamEngines[0]}.`
11681
11719
  : 'Your team picks an engine for each job.';
11682
11720
 
11683
- const standupRows = room.standup.flatMap((s, idx) => [
11684
- `+-----------------------------------------------------------------------+`,
11685
- `| ${idx + 1}. ${s.member.toUpperCase()} (${s.role}) · ${s.status} · engine:${s.engine}`,
11686
- `| Found: ${s.latest_update}`,
11687
- `| Push: ${s.next_push}`,
11688
- `+-----------------------------------------------------------------------+`,
11689
- ]);
11721
+ const standupRows = memberStandupLines(room.standup, standupTableData(room.standup));
11690
11722
 
11691
11723
  const handoffRows = room.handoffs.length
11692
11724
  ? room.handoffs.map((h) => ` - ${h.from} -> ${h.to}: ${h.summary} (run: ${h.next_command})`)
@@ -12019,6 +12051,8 @@ async function memberCommand(subcommand, ...args) {
12019
12051
 
12020
12052
  module.exports = {
12021
12053
  logDateForScan,
12054
+ memberStandupLines,
12055
+ standupTableData,
12022
12056
  memberRunDefaultWallSeconds,
12023
12057
  memberRunWantsReferee,
12024
12058
  proposalPromptForGoal,
package/lib/gm-bet.js CHANGED
@@ -849,6 +849,65 @@ function statusSentence(row) {
849
849
  return `${row.status}: ${on}, ${row.time}`;
850
850
  }
851
851
 
852
+ // The ovr cell from one member's entry in gm-season memberRatings: the
853
+ // rating, "new" with 1 or 2 finished, two dots with none finished.
854
+ function ovrCell(rated) {
855
+ if (!rated || !rated.finished) return NO_RATING;
856
+ return rated.ovr == null ? 'new' : String(rated.ovr);
857
+ }
858
+
859
+ // The engine pick the runner makes for a member, with the roster read once.
860
+ function teamEngineResolver(workspaceRoot) {
861
+ const reg = require('./engine-registry');
862
+ const { resolveEngineForMember } = require('./member-engine');
863
+ let rosterState = null;
864
+ try { rosterState = reg.readRosterState(workspaceRoot); } catch { rosterState = null; }
865
+ return name => resolveEngineForMember(name, workspaceRoot, { quiet: true, ...(rosterState ? { rosterState } : {}) });
866
+ }
867
+
868
+ // role, ovr, engine, and parked for each name: the team page's cells, and
869
+ // the same cells the board and the standup draw in a terminal, so every
870
+ // team view says the same thing. ratings is gm-season memberRatings over one
871
+ // loaded season; resolve gives a member's engine pick. A name with no card
872
+ // and no roster line is not on the team, so no engine shows for it.
873
+ function teamCells(workspaceRoot, names = [], { ratings = new Map(), resolve = null } = {}) {
874
+ const { memberTypeOf } = require('./member-type');
875
+ const pick = resolve || teamEngineResolver(workspaceRoot);
876
+ const out = new Map();
877
+ for (const name of names) {
878
+ const key = String(name || '');
879
+ if (!key || /[\\/]/.test(key) || key === '.' || key === '..') {
880
+ out.set(key, { role: NO_RATING, ovr: ovrCell(ratings.get(key)), engine: NO_RATING, parked: false });
881
+ continue;
882
+ }
883
+ const card = memberCardFacts(workspaceRoot, key);
884
+ const kind = memberTypeOf({ type: card.type });
885
+ let engine = kind.type === 'human' ? 'person' : NO_RATING;
886
+ if (kind.runnable) {
887
+ let picked = null;
888
+ try { picked = pick(key); } catch { picked = null; }
889
+ engine = picked && picked.found === false ? NO_RATING : engineWords(picked);
890
+ }
891
+ out.set(key, { role: memberRoleWords(key, card), ovr: ovrCell(ratings.get(key)), engine, parked: card.parked });
892
+ }
893
+ return out;
894
+ }
895
+
896
+ // The team table as a divided box for a terminal, in the team page's
897
+ // columns: one line per { member, status } with that member's cells from
898
+ // teamCells. A long role or status wraps to fit the width (the exact model
899
+ // never splits); null when even that does not fit.
900
+ function teamBox(rows, cells, { width } = {}) {
901
+ const { renderBorderedTable } = require('./text-table');
902
+ const wraps = new Set(['role', 'status']);
903
+ const columns = require('./gm-card').TEAM_COLUMNS.map(header => ({ header, wrap: wraps.has(header) }));
904
+ const blank = { role: NO_RATING, ovr: NO_RATING, engine: NO_RATING };
905
+ return renderBorderedTable(columns, (rows || []).map((row) => {
906
+ const c = (cells && cells.get(row.member)) || blank;
907
+ return [row.member, c.role, c.ovr, c.engine, row.status];
908
+ }), { width });
909
+ }
910
+
852
911
  // A verify command that always passes proves nothing. Binary checks only:
853
912
  // every piece of it is one of these, or it swallows failure with || true.
854
913
  const TRIVIAL_VERIFY = [
@@ -4252,33 +4311,7 @@ function createBetDesk(deps) {
4252
4311
  const loaded = (deps.loadSeason || (root => season.loadSeason(root, { now: now(), lastSeen: null })))(deps.workspaceRoot);
4253
4312
  if (loaded && !loaded.error) ratings = season.memberRatings(loaded.outcomes || []);
4254
4313
  } catch { ratings = new Map(); }
4255
- let resolve = deps.resolveEngine;
4256
- if (!resolve) {
4257
- const reg = require('./engine-registry');
4258
- const { resolveEngineForMember } = require('./member-engine');
4259
- let rosterState = null;
4260
- try { rosterState = reg.readRosterState(deps.workspaceRoot); } catch { rosterState = null; }
4261
- resolve = name => resolveEngineForMember(name, deps.workspaceRoot, { quiet: true, ...(rosterState ? { rosterState } : {}) });
4262
- }
4263
- const facts = new Map();
4264
- const { memberTypeOf } = require('./member-type');
4265
- for (const m of team || []) {
4266
- const card = memberCardFacts(deps.workspaceRoot, m.name);
4267
- const kind = memberTypeOf({ type: card.type });
4268
- let engine = kind.type === 'human' ? 'person' : NO_RATING;
4269
- if (kind.runnable) {
4270
- let picked = null;
4271
- try { picked = resolve(m.name); } catch { picked = null; }
4272
- engine = engineWords(picked);
4273
- }
4274
- const rated = ratings.get(m.name);
4275
- facts.set(m.name, {
4276
- role: memberRoleWords(m.name, card),
4277
- ovr: !rated || !rated.finished ? NO_RATING : rated.ovr == null ? 'new' : String(rated.ovr),
4278
- engine,
4279
- parked: card.parked,
4280
- });
4281
- }
4314
+ const facts = teamCells(deps.workspaceRoot, (team || []).map(m => m.name), { ratings, resolve: deps.resolveEngine || null });
4282
4315
  rosterCache = { at: now(), facts };
4283
4316
  return facts;
4284
4317
  }
@@ -4857,4 +4890,6 @@ module.exports = {
4857
4890
  writerCommand,
4858
4891
  WRITER_ANGLES,
4859
4892
  validateBet,
4893
+ teamBox,
4894
+ teamCells,
4860
4895
  };
@@ -357,8 +357,10 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
357
357
  // when a spiral starts, each dot keeps the speed it had and lets it fade
358
358
  // at CARRY a frame, so the dots turn inward without a stop
359
359
  var CARRY = 0.82;
360
- // what the dots did on the last frame, kept on the canvas so the motion can be measured
361
- var stats = { frame: 0, mode: 'idle', step: 0, mean: 0 };
360
+ // what the dots did on the last frame, kept on the canvas so the motion can be
361
+ // measured, and ms: how much of the dots' own clock that frame was, which is
362
+ // at most 50ms however long the frame took
363
+ var stats = { frame: 0, mode: 'idle', step: 0, mean: 0, ms: 0 };
362
364
  canvas.__orb = stats;
363
365
  // Two speeds, chosen by truth. A bet already waiting in the queue plays
364
366
  // the same curves at FAST of their length (tap to card in about two seconds, unhurried).
@@ -523,7 +525,7 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
523
525
  ctx.arc(d.sx, d.sy, d.r * (front ? 0.6 + l * 0.7 : 0.6) * shrink, 0, Math.PI * 2);
524
526
  ctx.fill();
525
527
  }
526
- stats.frame += 1; stats.mode = mode; stats.step = most; stats.mean = sum / Math.min(64, dots.length || 1);
528
+ stats.frame += 1; stats.mode = mode; stats.step = most; stats.mean = sum / Math.min(64, dots.length || 1); stats.ms = dt * 1000;
527
529
  if (mode === 'pull') {
528
530
  // where the dots land: one bright point that swells until the lava is born
529
531
  var arrived = Math.min(1, Math.max(0, (age - 0.3) / 0.7));
@@ -556,7 +558,9 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
556
558
  function start() { if (!raf) { lastT = 0; raf = requestAnimationFrame(frame); } }
557
559
  function stop() { if (raf) cancelAnimationFrame(raf); raf = 0; }
558
560
 
559
- function place(s, x, y) { lava.style.transform = 'translate(' + (x - base / 2) + 'px, ' + (y - base / 2) + 'px) scale(' + s + ')'; }
561
+ // at: the frame time this place is for, when it is a step of a motion, kept on
562
+ // the lava so the motion can be measured on the clock it runs on
563
+ function place(s, x, y, at) { lava.style.transform = 'translate(' + (x - base / 2) + 'px, ' + (y - base / 2) + 'px) scale(' + s + ')'; lava.__at = at; }
560
564
  function restPoint() {
561
565
  var top = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--safe-top')) || 0;
562
566
  return { x: document.documentElement.clientWidth / 2, y: window.scrollY + Math.max(16, top) + 8 + REST / 2 };
@@ -574,6 +578,9 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
574
578
  var contained = pace < 1;
575
579
  var growMs = quick ? ACT_GROW_MS : contained ? FAST_GROW_MS : (GROW_MS * pace);
576
580
  var settleMs = quick ? ACT_SETTLE_MS : contained ? FAST_SETTLE_MS : (SETTLE_MS * pace);
581
+ // after an act the card comes up at the bloom's peak; any other card once the lava has settled
582
+ planned('bloom', growMs);
583
+ if (!quick) planned('settle', settleMs);
577
584
  // the point the dots drew, where the sphere is on screen (smaller and higher on the calm line)
578
585
  var vx = rect.left + rect.width / 2, vy = rect.top + rect.height / 2;
579
586
  var bloom = quick ? ACT_BLOOM : BLOOM;
@@ -598,7 +605,7 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
598
605
  var t0 = performance.now();
599
606
  var step = function (now) {
600
607
  var t = Math.min(1, (now - t0) / growMs);
601
- place(sMin + (sMax - sMin) * ease(t), origin.x, origin.y);
608
+ place(sMin + (sMax - sMin) * ease(t), origin.x, origin.y, now);
602
609
  lava.style.opacity = String(smooth(0.02, 0.22, t));
603
610
  core.style.opacity = String(1 - smooth(0.05, 0.4, t));
604
611
  dim.style.opacity = contained ? '0' : String(smooth(0.88, 1, t));
@@ -612,7 +619,7 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
612
619
  var s0 = performance.now();
613
620
  var settle = function (n2) {
614
621
  var u = ease(Math.min(1, (n2 - s0) / settleMs));
615
- place(sMax + (rest - sMax) * u, origin.x + (p.x - origin.x) * u, origin.y + (p.y - origin.y) * u);
622
+ place(sMax + (rest - sMax) * u, origin.x + (p.x - origin.x) * u, origin.y + (p.y - origin.y) * u, n2);
616
623
  dim.style.opacity = contained ? '0' : String(1 - smooth(0.35, 0.85, u));
617
624
  if (u < 1) { requestAnimationFrame(settle); return; }
618
625
  settled();
@@ -636,7 +643,7 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
636
643
  var u = Math.min(1, (now - t0) / total);
637
644
  var s = u < k ? sMin + (sMax - sMin) * (0.5 - 0.5 * Math.cos(Math.PI * u / k)) : sMax + (rest - sMax) * ease((u - k) / (1 - k));
638
645
  var m = ease(u);
639
- place(s, origin.x + (p.x - origin.x) * m, origin.y + (p.y - origin.y) * m);
646
+ place(s, origin.x + (p.x - origin.x) * m, origin.y + (p.y - origin.y) * m, now);
640
647
  lava.style.opacity = String(smooth(0, 0.1, u));
641
648
  core.style.opacity = String(1 - smooth(0.04, 0.5, u));
642
649
  dim.style.opacity = '0';
@@ -700,6 +707,7 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
700
707
  mode = fast ? 'swarm' : 'idle';
701
708
  bridgeAt = reduced ? 0 : performance.now();
702
709
  start();
710
+ if (!reduced) planned('beat', beatMs);
703
711
  setTimeout(cb, reduced ? 0 : beatMs);
704
712
  };
705
713
  if (reduced || lava.style.visibility !== 'visible') { reform(); return; }
@@ -709,12 +717,13 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
709
717
  lava.style.position = 'fixed';
710
718
  place(from.s, from.x, from.y);
711
719
  var sEnd = 30 / base;
720
+ planned('glide', glideMs);
712
721
  var t0 = performance.now();
713
722
  var step = function (now) {
714
723
  var k = Math.min(1, (now - t0) / glideMs);
715
724
  var t = fast ? 0.5 - 0.5 * Math.cos(Math.PI * k) : ease(k);
716
725
  if (now - t0 >= leaveMs) takeOff();
717
- place(from.s + (sEnd - from.s) * t, from.x + (to.x - from.x) * t, from.y + (to.y - from.y) * t);
726
+ place(from.s + (sEnd - from.s) * t, from.x + (to.x - from.x) * t, from.y + (to.y - from.y) * t, now);
718
727
  core.style.opacity = String(smooth(0.3, 0.9, k));
719
728
  lava.style.opacity = String(1 - smooth(0.7, 1, k));
720
729
  if (k < 1) { requestAnimationFrame(step); return; }
@@ -767,6 +776,8 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
767
776
  pace = act ? ACT_PACE : fast ? FAST : 1;
768
777
  // after an act the dots spiral in from where the re-formed sphere is
769
778
  if (mode !== 'pull') startPull(fast || Boolean(act));
779
+ // the lava is born at 1.05 on the spiral's own clock, which runs at pace
780
+ planned('spiral', Math.max(0, 1.05 - age) * pace * 1000);
770
781
  timed = true;
771
782
  start();
772
783
  },
@@ -817,7 +828,7 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
817
828
  // card, and nothing from the card before survives a draw. Every tap posts
818
829
  // the card's id and the option's id, or the words typed in its box, to
819
830
  // /card; the Mac refuses anything else, and its answer is the next card.
820
- var bet = { shown: null, watching: null, tapAt: 0, noting: false, clock: null };
831
+ var bet = { shown: null, watching: null, tapAt: 0, plan: [], waited: 0, noting: false, clock: null };
821
832
  // while an act or a turn is under way the card takes no taps; the page
822
833
  // marks it on the body too, so anyone reading the page can tell
823
834
  var busyNow = false;
@@ -991,9 +1002,9 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
991
1002
  var input = spot.querySelector('input');
992
1003
  if (input) { input.readOnly = true; input.blur(); }
993
1004
  bet.busy = true;
994
- bet.tapAt = performance.now();
995
- document.body.removeAttribute('data-reveal-ms');
996
- return bet.tapAt;
1005
+ var at = tapped();
1006
+ planned('hold', HOLD_MS);
1007
+ return at;
997
1008
  }
998
1009
 
999
1010
  function afterHold(heldAt) {
@@ -1088,12 +1099,16 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1088
1099
  function flow(answer, from, typed, option) {
1089
1100
  var landed = null;
1090
1101
  var sphere = false;
1091
- var lineAt = 0;
1102
+ var lineAt = 0, waitFrom = 0;
1092
1103
  var land = function () {
1104
+ // the sphere is ready before the answer is in: it waits from now
1105
+ if (sphere && !landed) waitFrom = performance.now();
1093
1106
  if (!landed || !sphere) return;
1107
+ if (waitFrom) bet.waited += performance.now() - waitFrom;
1094
1108
  var r = landed;
1095
1109
  landed = null;
1096
1110
  var wait = r.ack && lineAt ? Math.max(0, AFTER_MS - (performance.now() - lineAt)) : 0;
1111
+ if (wait) planned('line', wait);
1097
1112
  setTimeout(function () {
1098
1113
  // nothing more to show: the sphere stays, with its line, and its dots settle into the idle drift
1099
1114
  if (r.ok && (r.home || !r.card)) { bet.busy = false; bet.shown = null; orb.calm(); line.set(r.home ? 'not now. tap when you want another' : 'tap to think', false); return; }
@@ -1133,6 +1148,7 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1133
1148
  // reduced motion: no travel; the card and the small orb fade, then the sphere is there
1134
1149
  if (reduced) {
1135
1150
  document.getElementById('lava').style.opacity = '0';
1151
+ planned('leave', 200);
1136
1152
  setTimeout(function () { swap(); stageFade(); orb.melt(then, to, function () {}, fast); }, 200);
1137
1153
  return;
1138
1154
  }
@@ -1185,12 +1201,35 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1185
1201
  }
1186
1202
 
1187
1203
  // ---- showing a card ---------------------------------------------------------------------
1188
- // the time from the tap to the card fully on screen, kept on the page so
1189
- // the speed can be measured
1204
+ // The time from the tap to the card fully on screen, kept on the page so
1205
+ // the speed can be measured. data-reveal-ms is the clock. data-reveal-plan
1206
+ // is each step of the motion in order with the length it is set to take,
1207
+ // and data-reveal-waited-ms is how long the sphere waited on the Mac's
1208
+ // answer, so late frames on a busy machine are told apart from the motion.
1209
+ var REVEAL = ['data-reveal-ms', 'data-reveal-plan', 'data-reveal-waited-ms'];
1210
+ function tapped() {
1211
+ bet.tapAt = performance.now();
1212
+ bet.plan = [];
1213
+ bet.waited = 0;
1214
+ REVEAL.forEach(function (a) { document.body.removeAttribute(a); });
1215
+ return bet.tapAt;
1216
+ }
1217
+ function planned(name, ms) { if (bet.tapAt) bet.plan.push(name + ' ' + Math.round(ms)); }
1190
1218
  function measured() {
1191
- if (bet.tapAt) document.body.setAttribute('data-reveal-ms', String(Math.round(performance.now() - bet.tapAt)));
1219
+ if (bet.tapAt) {
1220
+ document.body.setAttribute('data-reveal-plan', bet.plan.join(', '));
1221
+ document.body.setAttribute('data-reveal-waited-ms', String(Math.round(bet.waited)));
1222
+ document.body.setAttribute('data-reveal-ms', String(Math.round(performance.now() - bet.tapAt)));
1223
+ }
1192
1224
  bet.tapAt = 0;
1193
1225
  }
1226
+ // how long a card's own fade up is set to take, read from its style
1227
+ function fadeMs(n) {
1228
+ var s = getComputedStyle(n);
1229
+ var i = Math.max(0, s.transitionProperty.split(', ').indexOf('opacity'));
1230
+ var at = function (list) { var v = list.split(', '); return (parseFloat(v[i % v.length]) || 0) * 1000; };
1231
+ return at(s.transitionDelay) + at(s.transitionDuration);
1232
+ }
1194
1233
 
1195
1234
  // fast: the quick fade from the big orb; act: the short one after an act
1196
1235
  function showCard(card, animate, fast, act) {
@@ -1213,7 +1252,7 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1213
1252
  var done = function (e) { if (e.propertyName === 'opacity') { b.removeEventListener('transitionend', done); measured(); } };
1214
1253
  b.addEventListener('transitionend', done);
1215
1254
  void b.offsetWidth;
1216
- requestAnimationFrame(function () { b.classList.add('up'); });
1255
+ requestAnimationFrame(function () { b.classList.add('up'); planned('fade', fadeMs(b)); });
1217
1256
  }
1218
1257
 
1219
1258
  // ---- the calm line: the dots keep moving while the next idea is written -----------------
@@ -1487,12 +1526,14 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1487
1526
  b.classList.remove('turn-in');
1488
1527
  b.style.height = to + 'px';
1489
1528
  ghost.classList.add('gone');
1529
+ var turnMs = 80 + TURN_IN_MS + 20;
1530
+ planned('turn', turnMs);
1490
1531
  setTimeout(function () {
1491
1532
  ghost.remove();
1492
1533
  b.classList.remove('turning');
1493
1534
  b.style.height = '';
1494
1535
  after();
1495
- }, 80 + TURN_IN_MS + 20);
1536
+ }, turnMs);
1496
1537
  }
1497
1538
 
1498
1539
  // While the calm line shows, look every WAIT_LOOK_MS: its live words are
@@ -1536,8 +1577,7 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1536
1577
  if (waiting()) { goHome(); return; }
1537
1578
  buzz();
1538
1579
  bet.busy = true;
1539
- bet.tapAt = performance.now();
1540
- document.body.removeAttribute('data-reveal-ms');
1580
+ tapped();
1541
1581
  var failed = function () {
1542
1582
  bet.busy = false;
1543
1583
  orb.relax();
@@ -1548,6 +1588,8 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1548
1588
  line.set('', false);
1549
1589
  // nothing ready yet: the calm line, with the sphere where it is and its dots moving
1550
1590
  if (isWait(r.card)) { enterWait(r.card); return; }
1591
+ // the big orb moves once the answer is in
1592
+ bet.waited = performance.now() - bet.tapAt;
1551
1593
  orb.play(function (phase) { if (phase === 'settled') { bet.busy = false; showCard(r.card, true, true); } }, true);
1552
1594
  }, failed);
1553
1595
  }
@@ -1598,11 +1640,12 @@ body.waiting .bet { position: absolute; left: 24px; right: 24px; width: auto; to
1598
1640
  if (bet.busy) return;
1599
1641
  buzz();
1600
1642
  bet.busy = true;
1601
- bet.tapAt = performance.now();
1643
+ tapped();
1602
1644
  var answer = api('/act', { open: which, card_id: bet.shown ? bet.shown.id : null }).catch(offline);
1603
1645
  if (!document.body.classList.contains('has-card')) {
1604
1646
  answer.then(function (r) {
1605
1647
  if (!r.ok || !r.card) { bet.busy = false; line.set((r && r.message) || 'that did not go through. tap to try again', true); return; }
1648
+ bet.waited = performance.now() - bet.tapAt;
1606
1649
  spiralTo(r.card, null);
1607
1650
  });
1608
1651
  return;
package/lib/text-table.js CHANGED
@@ -6,6 +6,16 @@
6
6
  // than the terminal, columns marked `clip` or `wrap` shrink (rightmost
7
7
  // first): a clip column ends in "…", a wrap column carries on underneath.
8
8
  // Every other column keeps its full width.
9
+ //
10
+ // renderBorderedTable draws the same rows as a divided box, the look of the
11
+ // orb's team table. A box wider than the terminal narrows its `wrap`
12
+ // columns, the widest first, and their words carry on underneath; when it
13
+ // still does not fit it returns null and the caller prints its plain view.
14
+ //
15
+ // Every width here is terminal columns, not string length (displayWidth):
16
+ // a wide or fullwidth character takes 2, a joiner, variation selector, or
17
+ // combining mark takes 0, and a joined emoji is one 2-column cluster. Cuts
18
+ // and wraps fall between clusters, never inside one.
9
19
 
10
20
  const GAP = ' ';
11
21
  const ELLIPSIS = '…';
@@ -16,25 +26,110 @@ function terminalWidth(width) {
16
26
  return Number.isFinite(value) && value >= 20 ? value : FALLBACK_WIDTH;
17
27
  }
18
28
 
29
+ // East Asian wide and fullwidth code points, as [first, last] ranges.
30
+ const WIDE = [
31
+ [0x1100, 0x115f], [0x231a, 0x231b], [0x2329, 0x232a], [0x2e80, 0x303e], [0x3041, 0x33ff],
32
+ [0x3400, 0x4dbf], [0x4e00, 0x9fff], [0xa000, 0xa4cf], [0xa960, 0xa97f], [0xac00, 0xd7a3],
33
+ [0xf900, 0xfaff], [0xfe10, 0xfe19], [0xfe30, 0xfe6f], [0xff00, 0xff60], [0xffe0, 0xffe6],
34
+ [0x16fe0, 0x16fe4], [0x17000, 0x18cff], [0x1aff0, 0x1b2ff], [0x1f200, 0x1f2ff],
35
+ [0x20000, 0x2fffd], [0x30000, 0x3fffd],
36
+ ];
37
+ // takes no column: combining marks, joiners and other format characters,
38
+ // variation selectors, and the Hangul vowel and final jamo that join a syllable
39
+ const ZERO = /^[\p{Mn}\p{Me}\p{Cf}\u1160-\u11ff\ud7b0-\ud7ff]$/u;
40
+ // drawn as a 2-column emoji: emoji presentation, or an emoji asked for one
41
+ // with the U+FE0F selector or a keycap
42
+ const EMOJI = /\p{Emoji_Presentation}|\p{Emoji}\ufe0f|\u20e3/u;
43
+ const SEGMENTER = typeof Intl === 'object' && typeof Intl.Segmenter === 'function'
44
+ ? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
45
+ : null;
46
+
47
+ function isWide(code) {
48
+ for (const [first, last] of WIDE) {
49
+ if (code < first) return false;
50
+ if (code <= last) return true;
51
+ }
52
+ return false;
53
+ }
54
+
55
+ // The text as grapheme clusters: what a reader sees as one character.
56
+ // Without Intl.Segmenter a zero-width code point joins the cluster before it.
57
+ function graphemes(text) {
58
+ const value = String(text);
59
+ if (SEGMENTER) return Array.from(SEGMENTER.segment(value), (part) => part.segment);
60
+ const out = [];
61
+ let joined = false;
62
+ for (const ch of value) {
63
+ if (out.length && (joined || ZERO.test(ch))) out[out.length - 1] += ch;
64
+ else out.push(ch);
65
+ joined = ch === '\u200d';
66
+ }
67
+ return out;
68
+ }
69
+
70
+ // Terminal columns for one cluster: 0, 1, or 2.
71
+ function clusterWidth(cluster) {
72
+ const code = cluster.codePointAt(0);
73
+ if (cluster.length === 1 && code >= 0x20 && code < 0x7f) return 1;
74
+ if (code < 0x20 || (code >= 0x7f && code < 0xa0)) return 0;
75
+ if (EMOJI.test(cluster)) return 2;
76
+ for (const ch of cluster) {
77
+ if (!ZERO.test(ch)) return isWide(ch.codePointAt(0)) ? 2 : 1;
78
+ }
79
+ return 0;
80
+ }
81
+
82
+ // Terminal columns the text takes.
83
+ function displayWidth(text) {
84
+ const value = String(text);
85
+ // printable ascii is one column a character
86
+ if (/^[\x20-\x7e]*$/.test(value)) return value.length;
87
+ let total = 0;
88
+ for (const cluster of graphemes(value)) total += clusterWidth(cluster);
89
+ return total;
90
+ }
91
+
92
+ // The longest start of the text that fits in that many columns, whole
93
+ // clusters only.
94
+ function sliceColumns(text, columns) {
95
+ let out = '';
96
+ let used = 0;
97
+ for (const cluster of graphemes(text)) {
98
+ const w = clusterWidth(cluster);
99
+ if (used + w > columns) break;
100
+ out += cluster;
101
+ used += w;
102
+ }
103
+ return out;
104
+ }
105
+
106
+ // Pads with spaces to that many columns.
107
+ function padColumns(text, columns) {
108
+ const value = String(text);
109
+ return value + ' '.repeat(Math.max(0, columns - displayWidth(value)));
110
+ }
111
+
19
112
  function clipText(text, width) {
20
113
  const value = String(text);
21
- if (value.length <= width) return value;
114
+ if (displayWidth(value) <= width) return value;
22
115
  if (width <= 1) return ELLIPSIS.slice(0, Math.max(0, width));
23
- return `${value.slice(0, width - 1).trimEnd()}${ELLIPSIS}`;
116
+ return `${sliceColumns(value, width - 1).trimEnd()}${ELLIPSIS}`;
24
117
  }
25
118
 
26
- // Word-wrap one line to a width; a word longer than the width is split.
119
+ // Word-wrap one line to a width in columns; a word wider than the width is
120
+ // split between clusters (a lone cluster wider than the width stands alone).
27
121
  function wrapText(text, width) {
28
122
  const out = [];
29
123
  let line = '';
30
124
  for (let word of String(text).split(' ')) {
31
- while (word.length > width) {
125
+ while (displayWidth(word) > width) {
32
126
  if (line) { out.push(line); line = ''; }
33
- out.push(word.slice(0, width));
34
- word = word.slice(width);
127
+ const head = sliceColumns(word, width) || graphemes(word)[0];
128
+ out.push(head);
129
+ word = word.slice(head.length);
35
130
  }
36
131
  const next = line ? `${line} ${word}` : word;
37
- if (next.length > width && line) { out.push(line); line = word; } else line = next;
132
+ if (displayWidth(next) > width && line) { out.push(line); line = word; } else line = next;
38
133
  }
39
134
  out.push(line);
40
135
  return out;
@@ -54,19 +149,19 @@ function renderTable(columns, rows, { width } = {}) {
54
149
  const max = terminalWidth(width);
55
150
  const cells = rows.map((row) => columns.map((_, index) => cellLines(row[index])));
56
151
  const widths = columns.map((column, index) => Math.max(
57
- String(column.header).length,
58
- ...cells.map((row) => Math.max(...row[index].map((line) => line.length))),
152
+ displayWidth(column.header),
153
+ ...cells.map((row) => Math.max(...row[index].map(displayWidth))),
59
154
  ));
60
155
  let over = widths.reduce((sum, value) => sum + value, 0) + GAP.length * (columns.length - 1) - max;
61
156
  for (let index = columns.length - 1; index >= 0 && over > 0; index -= 1) {
62
157
  if (!columns[index].clip && !columns[index].wrap) continue;
63
- const floor = Math.max(String(columns[index].header).length, columns[index].min || 8);
158
+ const floor = Math.max(displayWidth(columns[index].header), columns[index].min || 8);
64
159
  const cut = Math.min(over, Math.max(0, widths[index] - floor));
65
160
  widths[index] -= cut;
66
161
  over -= cut;
67
162
  }
68
163
  const line = (parts) => parts
69
- .map((text, index) => clipText(text, widths[index]).padEnd(index === parts.length - 1 ? 0 : widths[index]))
164
+ .map((text, index) => padColumns(clipText(text, widths[index]), index === parts.length - 1 ? 0 : widths[index]))
70
165
  .join(GAP)
71
166
  .trimEnd();
72
167
  const out = [line(columns.map((column) => column.header))];
@@ -78,4 +173,35 @@ function renderTable(columns, rows, { width } = {}) {
78
173
  return out.join('\n');
79
174
  }
80
175
 
81
- module.exports = { FALLBACK_WIDTH, clipText, renderTable, terminalWidth };
176
+ // columns: [{ header, wrap?, min? }]; rows: arrays of cells. A divided box,
177
+ // or null when it cannot fit the terminal.
178
+ function renderBorderedTable(columns, rows, { width } = {}) {
179
+ const cells = rows.map((row) => columns.map((_, index) => cellLines(row[index])));
180
+ const widths = columns.map((column, index) => Math.max(
181
+ displayWidth(column.header),
182
+ ...cells.map((row) => Math.max(...row[index].map(displayWidth))),
183
+ ));
184
+ const floor = (index) => Math.max(displayWidth(columns[index].header), columns[index].min || 8);
185
+ let over = widths.reduce((sum, value) => sum + value + 3, 1) - terminalWidth(width);
186
+ while (over > 0) {
187
+ let widest = -1;
188
+ columns.forEach((column, index) => {
189
+ if (column.wrap && widths[index] > floor(index) && (widest < 0 || widths[index] > widths[widest])) widest = index;
190
+ });
191
+ if (widest < 0) return null;
192
+ widths[widest] -= 1;
193
+ over -= 1;
194
+ }
195
+ const rule = (left, mid, right) => `${left}${widths.map((w) => '─'.repeat(w + 2)).join(mid)}${right}`;
196
+ const line = (parts) => `│ ${parts.map((text, index) => padColumns(text, widths[index])).join(' │ ')} │`;
197
+ const out = [rule('┌', '┬', '┐'), line(columns.map((column) => column.header)), rule('├', '┼', '┤')];
198
+ for (const raw of cells) {
199
+ const row = raw.map((lines, index) => (columns[index].wrap ? lines.flatMap((text) => wrapText(text, widths[index])) : lines));
200
+ const height = Math.max(...row.map((lines) => lines.length));
201
+ for (let at = 0; at < height; at += 1) out.push(line(row.map((lines) => lines[at] || '')));
202
+ }
203
+ out.push(rule('└', '┴', '┘'));
204
+ return out.join('\n');
205
+ }
206
+
207
+ module.exports = { FALLBACK_WIDTH, clipText, displayWidth, renderBorderedTable, renderTable, sliceColumns, terminalWidth, wrapText };
package/lib/zen-apply.js CHANGED
@@ -547,7 +547,7 @@ function applyZen({ cwd = process.cwd(), fetch = true, freshMinutes, abandonedDa
547
547
  if (copies.busy.length) left.push(...sideCopyLines(copies.busy, staleDays, nextSaveIn(copies.busy, staleDays, now.getTime())));
548
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'}`);
549
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'}`);
550
- if (copies.inUse) left.push(`${plural(copies.inUse, 'side copy was', 'side copies were')} used in ${window}`);
550
+ if (copies.inUse) left.push(`${plural(copies.inUse, 'side copy was', 'side copies were')} used in ${window}, so ${copies.inUse === 1 ? 'it stays' : 'they stay'}`);
551
551
 
552
552
  const stray = saveStrayEdits(root, teamState, tag, cutoff);
553
553
  if (stray.saved) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.74.8",
3
+ "version": "3.74.9",
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",
@@ -428,10 +428,14 @@ run_haiku() {
428
428
  # The Antigravity editor ships its own `agy` (a VS Code-style launcher script).
429
429
  # It has no -p mode: it hands -p and --model to Electron and prints nothing, so
430
430
  # it is not a writer. Only the Antigravity CLI is.
431
+ # Only grep's answer counts, never head's exit. In `head | grep -q` under
432
+ # pipefail, grep quits on the match and a head that touches the pipe once
433
+ # more (uutils head on Ubuntu 26.04 does, to find the end of the file) dies
434
+ # of SIGPIPE, so a busy machine read the launcher as the CLI.
431
435
  agy_is_cli() {
432
436
  local bin
433
437
  bin=$(command -v agy 2>/dev/null) || return 1
434
- ! head -c 4096 "$bin" 2>/dev/null | grep -q 'VSCODE_IPC_HOOK_CLI'
438
+ ! grep -q 'VSCODE_IPC_HOOK_CLI' < <(head -c 4096 "$bin" 2>/dev/null)
435
439
  }
436
440
 
437
441
  # An agy that is not signed in opens a Google login and waits up to 60s, which
@@ -441,7 +445,7 @@ agy_is_cli() {
441
445
  agy_signed_in() {
442
446
  local out
443
447
  out=$(run_with_timeout 10 agy models </dev/null 2>&1) || true
444
- ! printf '%s' "$out" | grep -qi 'sign in'
448
+ ! grep -qi 'sign in' <<< "$out"
445
449
  }
446
450
 
447
451
  run_gemini() {
@@ -524,7 +528,9 @@ run_auto() {
524
528
  echo "$tried did not answer; writing the notes with $label instead." >&2
525
529
  fi
526
530
  rm -f "$WORK/yt_${ID}.kill"
527
- if out=$(run_writer "$cmd" "$model" "$effort" 2>>"$WORK/yt_${ID}.engine.err") && printf '%s' "$out" | grep -q '[[:alnum:]]'; then
531
+ # A here-string, not printf | grep -q: under pipefail printf can die of
532
+ # SIGPIPE after grep's match and turn a real answer into "did not answer".
533
+ if out=$(run_writer "$cmd" "$model" "$effort" 2>>"$WORK/yt_${ID}.engine.err") && grep -q '[[:alnum:]]' <<< "$out"; then
528
534
  printf '%s\n' "$out"
529
535
  echo "notes by $label" >&2
530
536
  return 0