atris 3.74.4 → 3.74.5

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.
Files changed (117) hide show
  1. package/FOR_AGENTS.md +12 -12
  2. package/atris/AGENTS.md +7 -7
  3. package/atris/CLAUDE.md +10 -10
  4. package/atris/policies/atris-backend.md +1 -1
  5. package/atris/policies/atris-design.md +71 -71
  6. package/atris/policies/design-seed.md +28 -28
  7. package/atris/policies/writing.md +1 -1
  8. package/atris/skills/apps/SKILL.md +3 -3
  9. package/atris/skills/atris-feedback/SKILL.md +2 -2
  10. package/atris/skills/autopilot/SKILL.md +10 -10
  11. package/atris/skills/blocks/SKILL.md +16 -16
  12. package/atris/skills/calendar/SKILL.md +1 -1
  13. package/atris/skills/clawhub/atris/SKILL.md +4 -4
  14. package/atris/skills/clawhub/philosophy-of-work/SKILL.md +7 -7
  15. package/atris/skills/create-app/SKILL.md +18 -18
  16. package/atris/skills/create-member/SKILL.md +11 -11
  17. package/atris/skills/design/SKILL.md +1 -1
  18. package/atris/skills/drive/SKILL.md +25 -25
  19. package/atris/skills/email-agent/SKILL.md +11 -11
  20. package/atris/skills/endgame/SKILL.md +33 -33
  21. package/atris/skills/magic-inbox/SKILL.md +15 -15
  22. package/atris/skills/notion/SKILL.md +1 -1
  23. package/atris/skills/ramp/SKILL.md +7 -7
  24. package/atris/skills/research-search/SKILL.md +7 -7
  25. package/atris/skills/slides/SKILL.md +7 -7
  26. package/atris/skills/tidy/SKILL.md +11 -11
  27. package/atris/skills/wake/SKILL.md +11 -11
  28. package/atris/skills/writing/SKILL.md +5 -5
  29. package/atris/team/_template/SOUL.md +3 -3
  30. package/atris/team/brainstormer/MEMBER.md +2 -2
  31. package/atris/team/launcher/MEMBER.md +2 -2
  32. package/atris/team/mission-lead/MISSION.md +7 -7
  33. package/atris/team/opus-overnight/MISSION.md +16 -16
  34. package/atris/team/opus-overnight/STEERING.md +5 -5
  35. package/atris.md +9 -0
  36. package/bin/atris.js +16 -3
  37. package/commands/campaign.js +17 -2
  38. package/commands/card.js +19 -12
  39. package/commands/engine.js +92 -17
  40. package/commands/gm.js +30 -0
  41. package/commands/idea.js +263 -0
  42. package/commands/init.js +11 -2
  43. package/commands/land.js +39 -18
  44. package/commands/mission.js +4 -3
  45. package/commands/ninety-nine.js +288 -0
  46. package/commands/one-lap.js +46 -27
  47. package/commands/reel.js +12 -7
  48. package/commands/sync.js +10 -2
  49. package/lib/campaign-keep.js +23 -8
  50. package/lib/campaign-loop.js +24 -5
  51. package/lib/claude-boot-block.js +7 -4
  52. package/lib/engine-registry.js +139 -1
  53. package/lib/fleet.js +11 -2
  54. package/lib/gm-bet.js +338 -34
  55. package/lib/gm-phone-page.js +325 -33
  56. package/lib/gm-phone.js +5 -1
  57. package/lib/gm-season.js +14 -0
  58. package/lib/headless-chrome.js +39 -0
  59. package/lib/idea-gate.js +33 -0
  60. package/lib/idea-inbox.js +220 -0
  61. package/lib/idea-ledger.js +196 -0
  62. package/lib/idea-tick.js +384 -0
  63. package/lib/known-commands.js +1 -1
  64. package/lib/member-engine.js +1 -0
  65. package/lib/ninety-nine-sync.js +156 -0
  66. package/lib/ninety-nine.js +247 -0
  67. package/lib/opening-lines.js +94 -0
  68. package/lib/roster-markdown.js +39 -1
  69. package/lib/roster-models.js +1 -1
  70. package/lib/roster-swap.js +353 -0
  71. package/lib/spawn-env.js +29 -2
  72. package/lib/wish-audit.js +14 -0
  73. package/lib/yes-lock.js +343 -253
  74. package/lib/zen-apply.js +5 -3
  75. package/lib/zen.js +13 -1
  76. package/package.json +1 -1
  77. package/templates/business-starter/CLAUDE.md +7 -7
  78. package/templates/business-starter/MAP.md +1 -1
  79. package/templates/business-starter/TODO.md +1 -1
  80. package/templates/business-starter/atris.md +2 -2
  81. package/templates/business-starter/context/README.md +6 -6
  82. package/templates/business-starter/context/live-workspace.md +1 -1
  83. package/templates/business-starter/goals.md +1 -1
  84. package/templates/business-starter/instructions.md +1 -1
  85. package/templates/business-starter/memory.md +4 -4
  86. package/templates/business-starter/persona.md +3 -3
  87. package/templates/business-starter/policies/LESSONS.md +1 -1
  88. package/templates/business-starter/reports/README.md +1 -1
  89. package/templates/business-starter/skills/README.md +1 -1
  90. package/templates/business-starter/team/README.md +1 -1
  91. package/templates/business-starter/team/START_HERE.md +1 -1
  92. package/templates/business-starter/team/_template/SOUL.md +3 -3
  93. package/templates/business-starter/team/comms/SOUL.md +2 -2
  94. package/templates/business-starter/team/ops/SOUL.md +4 -4
  95. package/templates/business-starter/team/research/SOUL.md +3 -3
  96. package/templates/business-starter/wiki/index.md +1 -1
  97. package/templates/research-canonical/CLAUDE.md +7 -7
  98. package/templates/research-canonical/MAP.md +1 -1
  99. package/templates/research-canonical/TODO.md +1 -1
  100. package/templates/research-canonical/atris.md +2 -2
  101. package/templates/research-canonical/context/README.md +8 -8
  102. package/templates/research-canonical/context/live-workspace.md +1 -1
  103. package/templates/research-canonical/goals.md +2 -2
  104. package/templates/research-canonical/instructions.md +1 -1
  105. package/templates/research-canonical/memory.md +4 -4
  106. package/templates/research-canonical/persona.md +1 -1
  107. package/templates/research-canonical/policies/LESSONS.md +1 -1
  108. package/templates/research-canonical/reports/README.md +1 -1
  109. package/templates/research-canonical/skills/README.md +1 -1
  110. package/templates/research-canonical/team/README.md +1 -1
  111. package/templates/research-canonical/team/eval/SOUL.md +1 -1
  112. package/templates/research-canonical/team/experiment/SOUL.md +2 -2
  113. package/templates/research-canonical/team/hypothesis/SOUL.md +3 -3
  114. package/templates/research-canonical/team/literature/SOUL.md +2 -2
  115. package/templates/research-canonical/wiki/briefs/research-program.md +1 -1
  116. package/templates/research-canonical/wiki/index.md +1 -1
  117. package/utils/auth.js +1 -0
@@ -72,18 +72,54 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0;
72
72
  .line.bad { color: var(--text-2); }
73
73
  .foot { position: absolute; left: 0; right: 0; bottom: max(20px, env(safe-area-inset-bottom)); text-align: center; }
74
74
  .quiet { color: var(--text-2); font-size: 17px; text-decoration: underline; text-decoration-color: var(--line); text-underline-offset: 0.25em; min-height: 44px; }
75
- /* the team: one row per member, who, what, the status, and the time */
75
+ /* the team: one row per member: who, the role, the rating, the model, and the status */
76
76
  .team { position: fixed; inset: 0; z-index: 20; overflow-y: auto; background: var(--bg); padding: max(24px, env(safe-area-inset-top)) 24px max(24px, env(safe-area-inset-bottom)); }
77
77
  .team .inner { max-width: 34rem; margin: 0 auto; display: flex; flex-direction: column; gap: 12px; }
78
78
  .team .head { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }
79
79
  .team h1 { margin: 0; font-size: clamp(23px, 6.2vw, 30px); line-height: 1.12; font-weight: 700; letter-spacing: -0.03em; }
80
- .team .row { width: 100%; text-align: left; display: grid; grid-template-columns: 1fr auto; gap: 4px 12px; padding: 12px 14px; border: 1px solid var(--line); border-radius: 14px; color: var(--text); }
81
- .team .who { font-size: 20px; font-weight: 700; }
82
- .team .st { font-size: 17px; color: var(--text-2); text-align: right; }
83
- .team .st.working { color: var(--text); }
84
- .team .st.waiting-for-your-yes { color: var(--amber); }
85
- .team .on { grid-column: 1 / -1; font-size: 18px; line-height: 1.35; }
86
- .team .time { grid-column: 1 / -1; font-size: 17px; color: var(--text-2); }
80
+ .team .inner.wide { max-width: 64rem; }
81
+ /* the table: a line between every column and every row. at phone width the
82
+ box scrolls sideways and the name column stays put; no column ever gets
83
+ narrower than its words */
84
+ .team .grid { overflow-x: auto; -webkit-overflow-scrolling: touch; overscroll-behavior-x: contain; border: 1px solid var(--line); border-radius: 14px; }
85
+ .team table { width: 100%; min-width: 44rem; border-collapse: separate; border-spacing: 0; font-size: 17px; line-height: 1.35; }
86
+ .team th, .team td { padding: 10px 14px; text-align: left; vertical-align: top; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); }
87
+ .team th:last-child, .team td:last-child { border-right: 0; }
88
+ .team tbody tr:last-child td { border-bottom: 0; }
89
+ .team th { color: var(--text-2); font-weight: 400; white-space: nowrap; }
90
+ .team .row { cursor: pointer; }
91
+ @media (hover: hover) { .team .row:hover td { background: #1c1816; } }
92
+ .team .who { position: sticky; left: 0; z-index: 1; background: var(--bg); white-space: nowrap; }
93
+ .team .who button { font-size: 18px; font-weight: 700; text-align: left; }
94
+ .team .role { min-width: 9rem; color: var(--text-2); }
95
+ .team .ovr { white-space: nowrap; text-align: right; font-variant-numeric: tabular-nums; }
96
+ .team .eng { white-space: nowrap; }
97
+ .team td.eng button { text-align: left; text-decoration: underline; text-decoration-color: var(--line); text-underline-offset: 0.25em; min-height: 32px; }
98
+ /* the engine card: the line, who it is for, then every model on this machine by tool */
99
+ .team .done { margin: 0; color: var(--text); font-size: 18px; line-height: 1.4; }
100
+ .team .scopes { display: flex; flex-wrap: wrap; gap: 8px; }
101
+ .team .scopes button { min-height: 44px; padding: 0 16px; border-radius: 22px; border: 1px solid var(--line); color: var(--text-2); font-size: 17px; }
102
+ .team .scopes button.chosen { border-color: var(--amber); background: rgba(255, 140, 66, 0.1); color: var(--text); }
103
+ .team .models { display: flex; flex-direction: column; gap: 8px; }
104
+ .team .models h2 { margin: 10px 0 0; color: var(--text-2); font-size: 17px; font-weight: 400; }
105
+ .team .models .choice { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }
106
+ .team .models .choice .w { color: var(--text-2); font-size: 17px; white-space: nowrap; }
107
+ .team .models .choice[disabled] { cursor: default; }
108
+ .team td.st { min-width: 16rem; color: var(--text-2); }
109
+ .team td.st.working { color: var(--text); }
110
+ .team td.st.waiting-for-your-yes { color: var(--amber); }
111
+ /* the job deck: one running job at a time, in one spot, with its place in
112
+ the deck. the card is a .bet card, so a flip is the main card's own turn */
113
+ .deckspot { position: relative; }
114
+ .deckcard { display: flex; flex-direction: column; gap: 12px; touch-action: pan-y; }
115
+ .deckcard .count, .deckcard .label, .deckcard .time { font-size: 17px; color: var(--text-2); }
116
+ .deckcard .label.needs-you { color: var(--amber); }
117
+ .deckcard .why, .deckcard .act, .deckcard .mission { margin: 0; }
118
+ .jobs-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; overscroll-behavior-x: contain; border: 1px solid var(--line); border-radius: 14px; }
119
+ .team table.jobs { min-width: 56rem; }
120
+ .jobs td.work { min-width: 14rem; }
121
+ .jobs td.done, .jobs td.member, .jobs td.engine, .jobs td.status { white-space: nowrap; }
122
+ .deckbar { display: flex; justify-content: space-between; gap: 12px; }
87
123
  .sayline { margin: 10px 0 0; display: flex; flex-direction: column; align-items: center; gap: 8px; }
88
124
  .sayline .thread, .sayline .reply { width: 100%; text-align: left; margin: 0; }
89
125
  .unsent { margin: 0; text-align: center; }
@@ -662,15 +698,22 @@ body.has-card .foot { position: static; margin-top: 28px; }
662
698
  setTimeout(function () { tapOrb(false); }, AFTER_MS);
663
699
  }
664
700
 
701
+ // A second tap, armed on this one button: one card and one command. A tap
702
+ // on another card, or a card drawn again after a flip, starts over, and
703
+ // the armed tap goes quiet after five seconds. Once sent, it takes no more.
665
704
  function armedTap(go, label, armedLabel, run) {
705
+ var armed = false;
706
+ var quiet = null;
666
707
  go.addEventListener('click', function () {
667
708
  buzz();
668
- if (!bet.armed) {
669
- bet.armed = true; go.textContent = armedLabel; go.className = 'pill armed';
670
- setTimeout(function () { if (bet.armed) { bet.armed = false; go.textContent = label; go.className = 'pill'; } }, 5000);
709
+ if (!armed) {
710
+ armed = true; go.textContent = armedLabel; go.className = 'pill armed';
711
+ quiet = setTimeout(function () { armed = false; go.textContent = label; go.className = 'pill'; }, 5000);
671
712
  return;
672
713
  }
673
- bet.armed = false;
714
+ clearTimeout(quiet);
715
+ armed = false;
716
+ go.disabled = true;
674
717
  run();
675
718
  });
676
719
  }
@@ -681,6 +724,17 @@ body.has-card .foot { position: static; margin-top: 28px; }
681
724
  return s && !/[.!?]$/.test(s) ? s + '.' : s;
682
725
  }
683
726
 
727
+ // the yes on a yes card: the first tap arms it, the second sends it.
728
+ // done(line, reply) gets the line to show after it.
729
+ function yesPill(post, done) {
730
+ var yes = el('button', 'pill', 'yes');
731
+ yes.type = 'button';
732
+ armedTap(yes, 'yes', 'tap again to say yes', function () {
733
+ post().then(function (r) { done(r.ack || r.message || 'sent', r); });
734
+ });
735
+ return yes;
736
+ }
737
+
684
738
  // a yes the owner owes, or a finished business move: what is going on,
685
739
  // then why it matters, both written by the server
686
740
  function drawJobCard(s) {
@@ -691,12 +745,7 @@ body.has-card .foot { position: static; margin-top: 28px; }
691
745
  if (j.why) box.appendChild(el('p', 'why', j.why));
692
746
  var act = el('div', 'act');
693
747
  if (s.kind === 'yes') {
694
- var yes = el('button', 'pill', 'yes');
695
- yes.type = 'button';
696
- armedTap(yes, 'yes', 'tap again to say yes', function () {
697
- api('/bet/yes', { confirm: true }).then(function (r) { moveOn(r.ack || r.message || 'sent'); });
698
- });
699
- act.appendChild(yes);
748
+ act.appendChild(yesPill(function () { return api('/bet/yes', { confirm: true }); }, moveOn));
700
749
  } else if (s.kind === 'wait') {
701
750
  // one calm line: the idea turns in on its own once it is written
702
751
  watchWait();
@@ -1301,7 +1350,14 @@ body.has-card .foot { position: static; margin-top: 28px; }
1301
1350
  function crossfade(draw) {
1302
1351
  var box = document.getElementById('bet');
1303
1352
  box.classList.remove('holding');
1304
- if (reduced) { box.style.minHeight = ''; draw(); bet.busy = false; measured(); return; }
1353
+ turnInPlace(box, draw, function () { bet.busy = false; measured(); });
1354
+ }
1355
+
1356
+ // one card turns into the next in its own spot: a still copy of the old
1357
+ // one fades over it while the new one drifts in, and the box eases to
1358
+ // the new height. after() runs once it is at rest.
1359
+ function turnInPlace(box, draw, after) {
1360
+ if (reduced) { box.style.minHeight = ''; draw(); after(); return; }
1305
1361
  var from = box.offsetHeight;
1306
1362
  box.style.height = from + 'px';
1307
1363
  box.style.minHeight = '';
@@ -1331,8 +1387,7 @@ body.has-card .foot { position: static; margin-top: 28px; }
1331
1387
  ghost.remove();
1332
1388
  box.classList.remove('turning');
1333
1389
  box.style.height = '';
1334
- bet.busy = false;
1335
- measured();
1390
+ after();
1336
1391
  }, 80 + TURN_IN_MS + 20);
1337
1392
  }
1338
1393
 
@@ -1433,10 +1488,17 @@ body.has-card .foot { position: static; margin-top: 28px; }
1433
1488
  // the way back from what is on top: the team closes, a talk card goes back to the team
1434
1489
  var teamBack = null;
1435
1490
 
1491
+ // every redraw of the team box makes a new view; an answer that comes
1492
+ // back for an older view is dropped (a closed box is hidden, and the next
1493
+ // open is a new view)
1494
+ var teamView = 0;
1495
+
1436
1496
  function closeTeam() { teamBox.hidden = true; }
1437
1497
 
1438
1498
  function teamHead(title) {
1499
+ teamView += 1;
1439
1500
  teamInner.textContent = '';
1501
+ teamInner.className = 'inner';
1440
1502
  teamBack = closeTeam;
1441
1503
  var head = el('div', 'head');
1442
1504
  head.appendChild(el('h1', null, title));
@@ -1450,29 +1512,123 @@ body.has-card .foot { position: static; margin-top: 28px; }
1450
1512
  function openTeam() {
1451
1513
  teamBox.hidden = false;
1452
1514
  teamHead('the team');
1453
- api('/bet/team').then(function (s) { if (s.http === 200) drawTeam(s.rows || []); });
1515
+ var view = teamView;
1516
+ api('/bet/team').then(function (s) { if (s.http === 200 && view === teamView) drawTeam(s.rows || []); });
1454
1517
  }
1455
1518
 
1456
- function drawTeam(rows) {
1519
+ // one row per member, five columns; a tap anywhere on a row opens its talk
1520
+ // card, and the name is a button so the keyboard reaches it too
1521
+ var TEAM_COLUMNS = ['member', 'role', 'ovr', 'engine', 'status'];
1522
+ var TEAM_CELLS = ['who', 'role', 'ovr', 'eng', 'st'];
1523
+
1524
+ // note is the one plain line a swap just said, shown over the table
1525
+ function drawTeam(rows, note) {
1457
1526
  teamHead('the team');
1458
- if (!rows.length) { teamInner.appendChild(el('p', 'why', 'nobody has had work in the last day.')); return; }
1527
+ if (note) teamInner.appendChild(el('p', 'done', note));
1528
+ if (!rows.length) { teamInner.appendChild(el('p', 'why', 'nobody is on the team yet.')); return; }
1529
+ teamInner.className = 'inner wide';
1530
+ deckEntry(rows);
1531
+ var grid = el('div', 'grid');
1532
+ var table = el('table', 'roster');
1533
+ var head = el('tr');
1534
+ TEAM_COLUMNS.forEach(function (name, i) { head.appendChild(el('th', TEAM_CELLS[i], name)).scope = 'col'; });
1535
+ table.appendChild(el('thead')).appendChild(head);
1536
+ var body = table.appendChild(el('tbody'));
1459
1537
  rows.forEach(function (r) {
1460
- var row = el('button', 'row');
1461
- row.type = 'button';
1462
- row.appendChild(el('span', 'who', r.member));
1463
- row.appendChild(el('span', 'st ' + r.status.replace(/ /g, '-'), r.status));
1464
- row.appendChild(el('span', 'on', r.on));
1465
- row.appendChild(el('span', 'time', r.time));
1466
- row.addEventListener('click', function () { drawTalk(r); });
1467
- teamInner.appendChild(row);
1538
+ var tr = el('tr', 'row');
1539
+ var who = el('td', 'who');
1540
+ var open = who.appendChild(el('button', null, r.member));
1541
+ open.type = 'button';
1542
+ open.setAttribute('aria-label', 'talk to ' + r.member);
1543
+ tr.appendChild(who);
1544
+ tr.appendChild(el('td', 'role', r.role));
1545
+ tr.appendChild(el('td', 'ovr', r.ovr));
1546
+ // the engine cell is its own button: a tap opens the engine card, not the talk card
1547
+ var eng = tr.appendChild(el('td', 'eng'));
1548
+ if (r.engine === 'person') eng.textContent = r.engine;
1549
+ else {
1550
+ var swap = eng.appendChild(el('button', null, r.engine));
1551
+ swap.type = 'button';
1552
+ swap.setAttribute('aria-label', 'change what ' + r.member + ' runs on');
1553
+ swap.addEventListener('click', function (e) { e.stopPropagation(); drawEngine(r); });
1554
+ }
1555
+ tr.appendChild(el('td', 'st ' + r.status.replace(/ /g, '-'), r.sentence));
1556
+ tr.addEventListener('click', function () { drawTalk(r); });
1557
+ body.appendChild(tr);
1468
1558
  });
1559
+ grid.appendChild(table);
1560
+ teamInner.appendChild(grid);
1561
+ }
1562
+
1563
+ // the engine card: what the member runs on, who a swap is for, then every
1564
+ // model this machine offers by tool, the current one on top. One tap swaps
1565
+ // (the tap is the approval) and the team comes back with the new engine in
1566
+ // its row and one line saying what changed. Esc goes back to the team.
1567
+ function drawEngine(r) {
1568
+ teamHead(r.member);
1569
+ teamBack = openTeam;
1570
+ // this card's view and member: an answer for any other is not drawn, and
1571
+ // every swap button posts this member, never whoever is on screen later
1572
+ var view = teamView;
1573
+ var member = r.member;
1574
+ var mine = function (s) { return view === teamView && (!s || !s.member || s.member === member); };
1575
+ var said = teamInner.appendChild(el('p', 'why', member + ' runs on ' + r.engine + '.'));
1576
+ var back = el('button', 'quiet', 'back to the team');
1577
+ back.type = 'button';
1578
+ back.addEventListener('click', openTeam);
1579
+ var busy = false;
1580
+ api('/bet/engine', { member: member }).then(function (s) {
1581
+ if (!mine(s)) return;
1582
+ if (s.http !== 200 || !s.ok) { said.textContent = s.message || 'could not read the models on this machine.'; teamInner.appendChild(back); return; }
1583
+ said.textContent = s.line;
1584
+ var scope = 'member';
1585
+ var scopes = el('div', 'scopes');
1586
+ scopes.setAttribute('role', 'group');
1587
+ scopes.setAttribute('aria-label', 'who the swap is for');
1588
+ (s.scopes || []).forEach(function (sc) {
1589
+ var b = scopes.appendChild(el('button', sc.id === scope ? 'chosen' : null, sc.label));
1590
+ b.type = 'button';
1591
+ b.setAttribute('aria-pressed', sc.id === scope ? 'true' : 'false');
1592
+ b.addEventListener('click', function () {
1593
+ scope = sc.id;
1594
+ Array.prototype.forEach.call(scopes.children, function (x) { var on = x === b; x.classList.toggle('chosen', on); x.setAttribute('aria-pressed', on ? 'true' : 'false'); });
1595
+ });
1596
+ });
1597
+ if ((s.scopes || []).length > 1) teamInner.appendChild(scopes);
1598
+ (s.groups || []).forEach(function (g) {
1599
+ var box = teamInner.appendChild(el('section', 'models'));
1600
+ box.appendChild(el('h2', null, g.tool));
1601
+ g.models.forEach(function (m) {
1602
+ var b = box.appendChild(el('button', 'choice' + (m.current ? ' chosen' : '')));
1603
+ b.type = 'button';
1604
+ b.appendChild(el('span', 't', m.label));
1605
+ var w = m.current ? 'now' : m.record;
1606
+ if (w) b.appendChild(el('span', 'w', w));
1607
+ if (m.current) { b.disabled = true; return; }
1608
+ b.setAttribute('data-member', member);
1609
+ b.addEventListener('click', function () {
1610
+ if (busy || !mine()) return;
1611
+ busy = true;
1612
+ b.classList.add('chosen');
1613
+ api('/bet/swap', { member: b.getAttribute('data-member'), engine: m.engine, model: m.model, scope: scope }).then(function (out) {
1614
+ busy = false;
1615
+ if (!mine()) return;
1616
+ if (out.http === 200 && out.ok) { drawTeam(out.rows || [], out.ack); return; }
1617
+ b.classList.remove('chosen');
1618
+ said.textContent = out.message || 'that did not change. try again.';
1619
+ }, function () { busy = false; if (!mine()) return; b.classList.remove('chosen'); said.textContent = 'that did not reach the mac. try again.'; });
1620
+ });
1621
+ });
1622
+ });
1623
+ teamInner.appendChild(back);
1624
+ }, function () { if (!mine()) return; said.textContent = 'that did not reach the mac. try again.'; teamInner.appendChild(back); });
1469
1625
  }
1470
1626
 
1471
1627
  // a talk card: what the member is on, and a word to steer them
1472
1628
  function drawTalk(r) {
1473
1629
  teamHead(r.member);
1474
1630
  teamBack = openTeam;
1475
- teamInner.appendChild(el('p', 'why', r.status + ': ' + r.on + '. ' + r.time + '.'));
1631
+ teamInner.appendChild(el('p', 'why', r.sentence + '.'));
1476
1632
  var talkBox = convo(function (body) { body.member = r.member; return api('/bet/steer', body); }, function (ack) {
1477
1633
  talkBox.replaceWith(el('p', 'mission', ack));
1478
1634
  }, 'tell ' + r.member + ' something');
@@ -1483,6 +1639,142 @@ body.has-card .foot { position: static; margin-top: 28px; }
1483
1639
  teamInner.appendChild(back);
1484
1640
  }
1485
1641
 
1642
+ // ---- the job deck: every running job is one card, the ones that need
1643
+ // the owner first. The arrows or a sideways swipe flip one card at a
1644
+ // time, and it turns in its own spot the way the main card does. Esc
1645
+ // goes back to talking. A card that needs the owner is that job's yes
1646
+ // card, and under the table one box talks to the job's member and keeps
1647
+ // the turns, even across flips.
1648
+ var deckCards = [];
1649
+ var deckColumns = [];
1650
+ var deckAt = 0;
1651
+ var deckTurning = false;
1652
+ var deckTalk = {};
1653
+ var DECK_LIVE = ['waiting for your yes', 'working', 'waiting to start'];
1654
+
1655
+ // the way in from the team page, there only while a job is running
1656
+ function deckEntry(rows) {
1657
+ if (!rows.some(function (r) { return DECK_LIVE.indexOf(r.status) >= 0; })) return;
1658
+ var open = el('button', 'quiet', 'the jobs, one at a time');
1659
+ open.type = 'button';
1660
+ open.addEventListener('click', openDeck);
1661
+ teamInner.appendChild(open);
1662
+ }
1663
+
1664
+ function openDeck() {
1665
+ teamBox.hidden = false;
1666
+ teamHead('the jobs');
1667
+ var waiting = teamInner.appendChild(el('div', 'deckspot'));
1668
+ api('/bet/jobs').then(function (s) {
1669
+ // the owner moved on while it loaded: nothing is drawn over it
1670
+ if (s.http !== 200 || !waiting.isConnected || teamBox.hidden) return;
1671
+ deckCards = s.cards || [];
1672
+ deckColumns = s.columns || [];
1673
+ if (!deckCards.length) { openTeam(); return; }
1674
+ deckAt = Math.min(deckAt, deckCards.length - 1);
1675
+ drawDeck();
1676
+ });
1677
+ }
1678
+
1679
+ function drawDeck() {
1680
+ teamHead('the jobs');
1681
+ teamInner.className = 'inner wide';
1682
+ var spot = teamInner.appendChild(el('div', 'deckspot'));
1683
+ // a card like the main one, so it turns the same way
1684
+ var card = spot.appendChild(el('article', 'bet'));
1685
+ card.classList.add('up', 'deckcard');
1686
+ fillDeckCard(card);
1687
+ var startX = null, startY = null;
1688
+ card.addEventListener('touchstart', function (e) { startX = e.touches[0].clientX; startY = e.touches[0].clientY; }, { passive: true });
1689
+ card.addEventListener('touchend', function (e) {
1690
+ if (startX === null) return;
1691
+ var dx = e.changedTouches[0].clientX - startX, dy = e.changedTouches[0].clientY - startY;
1692
+ startX = null;
1693
+ if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy)) flipDeck(dx < 0 ? 1 : -1);
1694
+ });
1695
+ var bar = el('div', 'deckbar');
1696
+ if (deckCards.length > 1) {
1697
+ var prev = bar.appendChild(el('button', 'quiet', 'previous'));
1698
+ prev.type = 'button';
1699
+ prev.addEventListener('click', function () { flipDeck(-1); });
1700
+ }
1701
+ var whole = bar.appendChild(el('button', 'quiet', 'the whole team'));
1702
+ whole.type = 'button';
1703
+ whole.addEventListener('click', openTeam);
1704
+ if (deckCards.length > 1) {
1705
+ var next = bar.appendChild(el('button', 'quiet', 'next'));
1706
+ next.type = 'button';
1707
+ next.addEventListener('click', function () { flipDeck(1); });
1708
+ }
1709
+ teamInner.appendChild(bar);
1710
+ }
1711
+
1712
+ // one job: its place in the deck, the yes card when it needs the owner,
1713
+ // the divided table, how long, and the talk box for its member
1714
+ function fillDeckCard(card) {
1715
+ var c = deckCards[deckAt];
1716
+ card.textContent = '';
1717
+ card.appendChild(el('span', 'count', 'job ' + (deckAt + 1) + ' of ' + deckCards.length));
1718
+ card.appendChild(el('span', 'label ' + c.label.replace(/ /g, '-'), c.label));
1719
+ if (c.yes) {
1720
+ card.appendChild(el('h1', null, c.yes.what));
1721
+ if (c.yes.why) card.appendChild(el('p', 'why', c.yes.why));
1722
+ var act = card.appendChild(el('div', 'act'));
1723
+ act.appendChild(yesPill(function () { return api('/bet/yes', { confirm: true, key: c.yes.key, job: c.job_id }); }, function (said, r) {
1724
+ if (r.ok || r.reason === 'gone') { c.yes = null; c.said = said; act.replaceWith(el('p', 'mission', said)); return; }
1725
+ c.unsent = said;
1726
+ fillDeckCard(card);
1727
+ }));
1728
+ if (c.unsent) act.appendChild(el('p', 'links unsent', c.unsent));
1729
+ } else if (c.said) card.appendChild(el('p', 'mission', c.said));
1730
+ var table = el('table', 'jobs');
1731
+ var head = el('tr');
1732
+ deckColumns.forEach(function (k) { head.appendChild(el('th', k, k)).scope = 'col'; });
1733
+ table.appendChild(el('thead')).appendChild(head);
1734
+ var body = table.appendChild(el('tbody'));
1735
+ c.rows.forEach(function (r) {
1736
+ var tr = body.appendChild(el('tr'));
1737
+ deckColumns.forEach(function (k) { tr.appendChild(el('td', k, r[k] || '')); });
1738
+ });
1739
+ card.appendChild(el('div', 'jobs-wrap')).appendChild(table);
1740
+ if (c.time) card.appendChild(el('span', 'time', c.time));
1741
+ card.appendChild(deckTalk[c.member] || (deckTalk[c.member] = talkBox(c.member)));
1742
+ }
1743
+
1744
+ // one talk box per member, made once and kept, so the turns and any
1745
+ // words being typed are still there after a flip; what came of a word
1746
+ // stays above a fresh box
1747
+ function talkBox(member) {
1748
+ var holder = el('div', 'jobtalk');
1749
+ var fresh = function () {
1750
+ var box = convo(function (body) { body.member = member; return api('/bet/steer', body); }, function (ack) {
1751
+ holder.insertBefore(el('p', 'mission', ack), box);
1752
+ box.replaceWith(fresh());
1753
+ }, 'talk to this job');
1754
+ return box;
1755
+ };
1756
+ holder.appendChild(fresh());
1757
+ return holder;
1758
+ }
1759
+
1760
+ function flipDeck(step) {
1761
+ var card = teamInner.querySelector('.deckcard');
1762
+ if (!card || deckTurning || deckCards.length < 2) return;
1763
+ deckAt = (deckAt + step + deckCards.length) % deckCards.length;
1764
+ deckTurning = true;
1765
+ turnInPlace(card, function () { fillDeckCard(card); }, function () { deckTurning = false; });
1766
+ }
1767
+
1768
+ // the arrows flip the deck, unless the owner is typing in a box
1769
+ document.addEventListener('keydown', function (e) {
1770
+ var step = e.key === 'ArrowLeft' ? -1 : e.key === 'ArrowRight' ? 1 : 0;
1771
+ if (!step || e.isComposing || teamBox.hidden || !teamInner.querySelector('.deckcard')) return;
1772
+ var field = document.activeElement;
1773
+ if (field && (field.tagName === 'INPUT' || field.tagName === 'TEXTAREA')) return;
1774
+ e.preventDefault();
1775
+ flipDeck(step);
1776
+ });
1777
+
1486
1778
  document.getElementById('working').addEventListener('click', openTeam);
1487
1779
 
1488
1780
  // Esc on the team page steps back one layer, the way its own buttons do:
package/lib/gm-phone.js CHANGED
@@ -518,6 +518,9 @@ function createPhoneHandler(deps) {
518
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
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
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 || '') });
521
524
  else if (p === '/bet/do') {
522
525
  if (body.confirm !== true) return { status: 409, body: { ok: false, reason: 'needs_confirm', confirm_ms: CONFIRM_MS } };
523
526
  out = await bet.doIt();
@@ -526,7 +529,7 @@ function createPhoneHandler(deps) {
526
529
  } else if (p === '/bet/yes') {
527
530
  // a yes unblocks agents, so it needs a second tap
528
531
  if (body.confirm !== true) return { status: 409, body: { ok: false, reason: 'needs_confirm', confirm_ms: CONFIRM_MS } };
529
- out = await bet.answerYes();
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) : '' } : {});
530
533
  } else return { status: 404, body: { ok: false, reason: 'not_found' } };
531
534
  return { status: out.ok ? 200 : 409, body: out };
532
535
  }
@@ -578,6 +581,7 @@ function createPhoneHandler(deps) {
578
581
  if (req.method === 'GET' && check.path === '/state') return send(res, 200, state());
579
582
  if (req.method === 'GET' && check.path === '/bet') return bet ? send(res, 200, bet.warm()) : send(res, 404, { ok: false, reason: 'not_found' });
580
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' });
581
585
  if (req.method === 'POST' && (check.path === '/action' || check.path === '/undo' || (bet && check.path.startsWith('/bet/')))) {
582
586
  if (String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase() !== 'application/json') return send(res, 415, { ok: false, reason: 'json_only' });
583
587
  let body;
package/lib/gm-season.js CHANGED
@@ -58,6 +58,19 @@ function ovr(outcomes) {
58
58
  return Math.round(60 + 39 * landed / finished);
59
59
  }
60
60
 
61
+ // Each member's rating over one window of plays, the board's own numbers:
62
+ // ovr is 60 to 99, or null for a rookie under 3 finished. A member with no
63
+ // plays in the window is not in the map.
64
+ function memberRatings(outcomes = []) {
65
+ const groups = new Map();
66
+ for (const o of outcomes || []) {
67
+ if (!o || !o.member) continue;
68
+ if (!groups.has(o.member)) groups.set(o.member, []);
69
+ groups.get(o.member).push(o);
70
+ }
71
+ return new Map([...groups].map(([name, list]) => [name, { ovr: ovr(list), finished: tally(list).finished }]));
72
+ }
73
+
61
74
  // Last n finished jobs, oldest on the left, newest on the right.
62
75
  function form(outcomes, n = 5) {
63
76
  return [...(outcomes || [])]
@@ -1146,6 +1159,7 @@ module.exports = {
1146
1159
  form,
1147
1160
  lastPlays,
1148
1161
  loadSeason,
1162
+ memberRatings,
1149
1163
  outcomesFromEvents,
1150
1164
  ovr,
1151
1165
  ownerMood,
@@ -0,0 +1,39 @@
1
+ 'use strict';
2
+
3
+ // One place for how this CLI launches headless Chrome. Every spot that starts
4
+ // Chrome builds its args here so a new launch can't forget the rules:
5
+ //
6
+ // --use-mock-keychain Chrome never touches the macOS keychain, so nobody
7
+ // --password-store=basic gets a "Chrome Safe Storage" prompt
8
+ // --user-data-dir a throwaway profile under the system temp folder,
9
+ // never the person's real Chrome profile
10
+ //
11
+ // Make the profile with makeProfileDir, build args with headlessArgs, and
12
+ // remove the profile with removeProfileDir when the launch is done.
13
+
14
+ const fs = require('fs');
15
+ const os = require('os');
16
+ const path = require('path');
17
+
18
+ const KEYCHAIN_FLAGS = Object.freeze(['--use-mock-keychain', '--password-store=basic']);
19
+
20
+ function makeProfileDir(prefix = 'atris-chrome-') {
21
+ return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
22
+ }
23
+
24
+ function removeProfileDir(dir) {
25
+ try { fs.rmSync(dir, { recursive: true, force: true, maxRetries: 3 }); } catch { /* best effort */ }
26
+ }
27
+
28
+ // Args for one headless launch. `profileDir` must sit under the system temp
29
+ // folder; anything else throws, so a real profile can't be passed by mistake.
30
+ function headlessArgs(profileDir, extra = []) {
31
+ const tmpRoot = path.resolve(os.tmpdir());
32
+ const dir = profileDir ? path.resolve(String(profileDir)) : '';
33
+ if (!dir || !dir.startsWith(tmpRoot + path.sep)) {
34
+ throw new Error('headless chrome needs a throwaway profile under the temp folder (makeProfileDir)');
35
+ }
36
+ return ['--headless=new', ...KEYCHAIN_FLAGS, `--user-data-dir=${dir}`, ...extra];
37
+ }
38
+
39
+ module.exports = { KEYCHAIN_FLAGS, makeProfileDir, removeProfileDir, headlessArgs };
@@ -0,0 +1,33 @@
1
+ 'use strict';
2
+
3
+ // The two gates every idea passes before it lands, kept apart from the tick
4
+ // so the loop cannot loosen them: a built change that edits this file is
5
+ // refused before landing (see JUDGE_PATHS in lib/idea-inbox.js).
6
+
7
+ const FORBIDDEN_SHIP_FLAGS = Object.freeze(['--force', '--force-with-lease', '--no-verify', '-f']);
8
+
9
+ // The builder and the reviewer must be two different engines, and the
10
+ // reviewer must have passed the change. One-lap enforces this already; the
11
+ // tick checks it again so a change can never land on its builder's word.
12
+ function secondReviewerSignedOff(lap) {
13
+ const validator = lap && lap.result && lap.result.validator_result;
14
+ return Boolean(validator
15
+ && validator.passed === true
16
+ && validator.independent === true
17
+ && validator.engine
18
+ && validator.executor_engine
19
+ && validator.engine !== validator.executor_engine);
20
+ }
21
+
22
+ // Landing never force-pushes and never skips hooks.
23
+ function assertSafeShipArgs(args) {
24
+ for (const flag of FORBIDDEN_SHIP_FLAGS) {
25
+ if (args.includes(flag)) throw new Error(`refusing to land with ${flag}`);
26
+ }
27
+ return args;
28
+ }
29
+
30
+ module.exports = {
31
+ assertSafeShipArgs,
32
+ secondReviewerSignedOff,
33
+ };