atris 3.74.4 → 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.
Files changed (124) 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/commands/task.js +4 -2
  50. package/commands/xp.js +68 -15
  51. package/commands/zen.js +10 -3
  52. package/lib/campaign-keep.js +23 -8
  53. package/lib/campaign-loop.js +24 -5
  54. package/lib/career-xp-lock.js +61 -0
  55. package/lib/career-xp-root.js +61 -0
  56. package/lib/claude-boot-block.js +7 -4
  57. package/lib/engine-registry.js +139 -1
  58. package/lib/fleet.js +11 -2
  59. package/lib/gm-bet.js +734 -42
  60. package/lib/gm-card.js +431 -0
  61. package/lib/gm-phone-page.js +486 -717
  62. package/lib/gm-phone.js +33 -34
  63. package/lib/gm-season.js +14 -0
  64. package/lib/headless-chrome.js +39 -0
  65. package/lib/idea-gate.js +33 -0
  66. package/lib/idea-inbox.js +220 -0
  67. package/lib/idea-ledger.js +196 -0
  68. package/lib/idea-tick.js +384 -0
  69. package/lib/known-commands.js +1 -1
  70. package/lib/member-engine.js +1 -0
  71. package/lib/ninety-nine-sync.js +156 -0
  72. package/lib/ninety-nine.js +247 -0
  73. package/lib/opening-lines.js +94 -0
  74. package/lib/roster-markdown.js +39 -1
  75. package/lib/roster-models.js +1 -1
  76. package/lib/roster-swap.js +353 -0
  77. package/lib/spawn-env.js +29 -2
  78. package/lib/task-db.js +8 -4
  79. package/lib/wish-audit.js +14 -0
  80. package/lib/yes-lock.js +365 -256
  81. package/lib/zen-apply.js +6 -3
  82. package/lib/zen.js +28 -1
  83. package/package.json +1 -1
  84. package/templates/business-starter/CLAUDE.md +7 -7
  85. package/templates/business-starter/MAP.md +1 -1
  86. package/templates/business-starter/TODO.md +1 -1
  87. package/templates/business-starter/atris.md +2 -2
  88. package/templates/business-starter/context/README.md +6 -6
  89. package/templates/business-starter/context/live-workspace.md +1 -1
  90. package/templates/business-starter/goals.md +1 -1
  91. package/templates/business-starter/instructions.md +1 -1
  92. package/templates/business-starter/memory.md +4 -4
  93. package/templates/business-starter/persona.md +3 -3
  94. package/templates/business-starter/policies/LESSONS.md +1 -1
  95. package/templates/business-starter/reports/README.md +1 -1
  96. package/templates/business-starter/skills/README.md +1 -1
  97. package/templates/business-starter/team/README.md +1 -1
  98. package/templates/business-starter/team/START_HERE.md +1 -1
  99. package/templates/business-starter/team/_template/SOUL.md +3 -3
  100. package/templates/business-starter/team/comms/SOUL.md +2 -2
  101. package/templates/business-starter/team/ops/SOUL.md +4 -4
  102. package/templates/business-starter/team/research/SOUL.md +3 -3
  103. package/templates/business-starter/wiki/index.md +1 -1
  104. package/templates/research-canonical/CLAUDE.md +7 -7
  105. package/templates/research-canonical/MAP.md +1 -1
  106. package/templates/research-canonical/TODO.md +1 -1
  107. package/templates/research-canonical/atris.md +2 -2
  108. package/templates/research-canonical/context/README.md +8 -8
  109. package/templates/research-canonical/context/live-workspace.md +1 -1
  110. package/templates/research-canonical/goals.md +2 -2
  111. package/templates/research-canonical/instructions.md +1 -1
  112. package/templates/research-canonical/memory.md +4 -4
  113. package/templates/research-canonical/persona.md +1 -1
  114. package/templates/research-canonical/policies/LESSONS.md +1 -1
  115. package/templates/research-canonical/reports/README.md +1 -1
  116. package/templates/research-canonical/skills/README.md +1 -1
  117. package/templates/research-canonical/team/README.md +1 -1
  118. package/templates/research-canonical/team/eval/SOUL.md +1 -1
  119. package/templates/research-canonical/team/experiment/SOUL.md +2 -2
  120. package/templates/research-canonical/team/hypothesis/SOUL.md +3 -3
  121. package/templates/research-canonical/team/literature/SOUL.md +2 -2
  122. package/templates/research-canonical/wiki/briefs/research-program.md +1 -1
  123. package/templates/research-canonical/wiki/index.md +1 -1
  124. package/utils/auth.js +1 -0
package/lib/gm-bet.js CHANGED
@@ -787,6 +787,68 @@ function readTeam(workspaceRoot) {
787
787
  return team.length ? team : null;
788
788
  }
789
789
 
790
+ // ---- the team page's cells --------------------------------------------------------
791
+
792
+ // what a cell shows when there is nothing true to put in it
793
+ const NO_RATING = '\u00b7\u00b7';
794
+
795
+ // role, description, type, and parked from one member's MEMBER.md
796
+ // frontmatter. A card with neither role nor description gives a "role:"
797
+ // line in its body, else its first plain paragraph.
798
+ function memberCardFacts(workspaceRoot, name) {
799
+ let text = '';
800
+ try { text = fs.readFileSync(path.join(workspaceRoot, 'atris', 'team', name, 'MEMBER.md'), 'utf8'); } catch { text = ''; }
801
+ const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
802
+ const head = block ? block[1] : '';
803
+ const field = k => ((new RegExp(`^${k}:\\s*(.+)$`, 'm').exec(head) || [])[1] || '').trim().replace(/^["']|["']$/g, '');
804
+ let role = field('role');
805
+ const description = field('description');
806
+ if (!role && !description) {
807
+ const body = block ? text.slice(block[0].length) : text;
808
+ const line = /^role:\s*(.+)$/im.exec(body);
809
+ const para = body.split(/\r?\n\s*\r?\n/).map(x => x.trim()).find(x => x && !/^[#>|`-]/.test(x));
810
+ role = line ? line[1].trim() : (para || '');
811
+ }
812
+ return { role, description, type: field('type'), parked: require('./member-park').isParkedText(text) };
813
+ }
814
+
815
+ // A role in a few words: up to its first full stop or colon, title-case
816
+ // words lowercased (a name like CEO or YouTube keeps its letters), at most
817
+ // six words.
818
+ function roleWords(text) {
819
+ const first = String(text || '').split(/[.;:!?](?:\s|$)/)[0].replace(/\s+[-\u2013]\s+/g, ', ').trim();
820
+ const words = first.split(/\s+/).filter(Boolean).map(w => (/^\(?[A-Z][a-z'\u2019]*[,)]?$/.test(w) ? w.toLowerCase() : w));
821
+ if (!words.length) return NO_RATING;
822
+ return words.length > 6 ? `${words.slice(0, 6).join(' ').replace(/[,\s]+$/, '')}\u2026` : words.join(' ');
823
+ }
824
+
825
+ // The role cell for one card: its role, unless that only says the member's
826
+ // own name ("builder" for builder), then its description.
827
+ function memberRoleWords(name, card) {
828
+ const plain = w => String(w || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
829
+ const role = roleWords(card.role || card.description);
830
+ if (card.description && plain(role) === plain(name)) return roleWords(card.description);
831
+ return role;
832
+ }
833
+
834
+ // The tool and its exact model, the tool id then the roster's own label for
835
+ // the model, from a member's resolved pick. Only a pinned model counts: a
836
+ // tool alone is not a model.
837
+ function engineWords(picked) {
838
+ const id = picked && picked.engine && picked.engine.id;
839
+ const model = picked && picked.model;
840
+ if (!id || !model) return NO_RATING;
841
+ const label = String(require('./engine-registry').rosterModelLabel(model) || model).trim();
842
+ return label.toLowerCase().startsWith(`${String(id).toLowerCase()} `) ? label : `${id} ${label}`;
843
+ }
844
+
845
+ // One plain sentence: the status, what it is on, and for how long.
846
+ function statusSentence(row) {
847
+ if (!row || !row.on) return row && row.time ? `${row.status}, ${row.time}` : `${(row && row.status) || 'idle'}: no work in the last day`;
848
+ const on = /^[A-Z][a-z]/.test(row.on) ? row.on[0].toLowerCase() + row.on.slice(1) : row.on;
849
+ return `${row.status}: ${on}, ${row.time}`;
850
+ }
851
+
790
852
  // A verify command that always passes proves nothing. Binary checks only:
791
853
  // every piece of it is one of these, or it swallows failure with || true.
792
854
  const TRIVIAL_VERIFY = [
@@ -1507,6 +1569,48 @@ function stepProblem(text) {
1507
1569
  return null;
1508
1570
  }
1509
1571
 
1572
+ // What a yes is for, as the owner reads it. A plain ask stays as written. A
1573
+ // held command, a handed-off job's own or one between backticks in an ask,
1574
+ // is said in words that keep its verb and its target: create or delete,
1575
+ // which release, which pull request, which branch, which repo. One that
1576
+ // cannot be said exactly says where it goes and shows what it names, and
1577
+ // never gets a friendlier verb.
1578
+ const ASK_TOOLS = { gh: 'GitHub', git: 'the git remote', npm: 'npm', pnpm: 'npm', yarn: 'npm', npx: 'npm', atris: 'Atris', curl: 'a web service', vercel: 'Vercel' };
1579
+
1580
+ function plainAsk(text) {
1581
+ const t = String(text || '').replace(/\s+/g, ' ').trim();
1582
+ const tick = /`([^`]+)`/.exec(t);
1583
+ const handed = /^(.{3,80}?\bwants to) (\S+)(.*)$/.exec(t);
1584
+ let lead = null;
1585
+ let cmd = '';
1586
+ if (tick) cmd = tick[1].trim();
1587
+ else if (handed && ASK_TOOLS[handed[2]]) { lead = handed[1]; cmd = `${handed[2]}${handed[3]}`.trim(); }
1588
+ const words = cmd.split(' ').filter(Boolean);
1589
+ if (!words.length || !ASK_TOOLS[words[0]]) return t;
1590
+ // the guard keeps the first 120 characters: a cut command is never said in words
1591
+ const exact = cmd.length < 120 ? require('./yes-lock').describeCommand(words) : null;
1592
+ const what = exact || `run a command that changes ${ASK_TOOLS[words[0]]}: "${words.slice(1).join(' ')}"`;
1593
+ return lead ? `${lead} ${what}` : `ok to ${what}?`;
1594
+ }
1595
+
1596
+ // A job's work as plain words: code spans and flags are dropped, a path or
1597
+ // a file name reads "a file", and a command left bare goes. When too little
1598
+ // is left to read, it is the member's job.
1599
+ function plainWork(text, member) {
1600
+ const t = String(text || '');
1601
+ if (!STEP_CODE.some(re => re.test(t))) return t;
1602
+ const kept = t.replace(/`[^`]*`/g, ' ')
1603
+ .split(/\s+/)
1604
+ .filter(w => w && !/^--?[a-z]/i.test(w))
1605
+ .map(w => (/\/|\.(js|ts|tsx|sh|md|json|py|yml|yaml|txt|html|css|jsonl)\b/i.test(w) ? 'a file' : w))
1606
+ .join(' ')
1607
+ .replace(/\b(npm|npx|git|node|gh|atris)\s+[a-z-]+\b/gi, ' ')
1608
+ .replace(/\s+([,.;:])/g, '$1')
1609
+ .replace(/\s+/g, ' ')
1610
+ .trim();
1611
+ return kept.split(' ').length >= 3 && !STEP_CODE.some(re => re.test(kept)) ? kept : `a job for ${member}`;
1612
+ }
1613
+
1510
1614
  function sentenceCount(text) {
1511
1615
  return String(text || '').split(/[.!?]+(?:\s+|$)/).filter(x => x.trim()).length;
1512
1616
  }
@@ -2379,6 +2483,8 @@ const BACKOFF_MS = 10 * 60 * 1000;
2379
2483
  // suggestScoreboard(opts) -> { ok, choices }, the numbers to offer (tests stub)
2380
2484
  // readSaid(opts) -> { ok, kind, reply, suggestions }, what the owner typed (tests stub)
2381
2485
  // sayTimeoutMs how long a read may take before the plain fallback (15 s)
2486
+ // loadSeason(root) -> season, the board's 30 days of plays for each ovr (tests stub)
2487
+ // resolveEngine(name) -> { engine, model }, the member's roster pick (tests stub)
2382
2488
  function createBetDesk(deps) {
2383
2489
  const now = deps.now || Date.now;
2384
2490
  const setTimer = deps.setTimer || ((fn, ms) => { const t = setTimeout(fn, ms); if (t.unref) t.unref(); return t; });
@@ -2438,6 +2544,11 @@ function createBetDesk(deps) {
2438
2544
  jobs: { working: [], yes: [], at: 0 },
2439
2545
  // yes cards passed over this session, so a re-tap moves on
2440
2546
  seenYes: new Set(),
2547
+ // yeses said this session, by key, and nos; a yes only passed over is still owed
2548
+ answeredYes: new Set(),
2549
+ // yeses skipped with not now, in the order skipped: they wait at the back
2550
+ skippedYes: new Map(),
2551
+ skips: 0,
2441
2552
  // reviews running now, by bet id
2442
2553
  reviews: new Map(),
2443
2554
  // failed jobs being sent back to the team now, by bet id
@@ -2593,7 +2704,7 @@ function createBetDesk(deps) {
2593
2704
  // own; the yes always goes to the run that was held
2594
2705
  for (const card of require('./yes-lock').handoffYesCards(lockDir)) {
2595
2706
  const parent = [...working, ...yes].find(j => j.mission_id && j.mission_id === card.parent_mission_id) || null;
2596
- const where = parent ? { bet_id: parent.bet_id, title: parent.title, owner: parent.owner } : { bet_id: null, title: 'a handed-off job', owner: 'the handed-off job' };
2707
+ const where = parent ? { bet_id: parent.bet_id, title: parent.title, owner: parent.owner } : { bet_id: null, title: 'a handed-off job', owner: card.who && card.who !== 'a handed-off job' ? card.who : 'the handed-off job' };
2597
2708
  yes.push({ kind: 'yes', ...where, mission_id: card.mission_id, ask: card.ask, command: card.command, lock_step: card.step, lock_hash: card.hash || null, ask_index: null, key: `${card.mission_id}:step-${card.step}:${card.hash || ''}` });
2598
2709
  }
2599
2710
  const expectOf = new Map(readLedger(deps.ledgerFile, deps.workspaceRoot).map(r => [r.id, r.bet && r.bet.expect]));
@@ -2677,10 +2788,19 @@ function createBetDesk(deps) {
2677
2788
  .map(row => ({ kind: 'done', bet_id: row.id, mission_id: row.mission_id, title: row.bet.title, owner: row.owner || 'the team', bet: row.bet }));
2678
2789
  }
2679
2790
 
2791
+ // The yeses still owed, one per key: not answered yes or no this session.
2792
+ function owedYes() {
2793
+ const keys = new Set();
2794
+ return state.jobs.yes.filter(j => !state.answeredYes.has(j.key) && !keys.has(j.key) && keys.add(j.key));
2795
+ }
2796
+
2680
2797
  // The next card that is not a bet: a yes the owner owes comes first, then
2681
2798
  // a finished job. Nothing pops on its own; these only come on the next tap.
2682
- function nextJobCard() {
2683
- const ask = state.jobs.yes.find(j => !state.seenYes.has(j.key));
2799
+ // Yes cards never take over the screen: right after a yes, the next card is
2800
+ // a normal one, and only a yes not seen yet comes up on its own. The rest
2801
+ // wait behind the small need-you line, in the job deck.
2802
+ function nextJobCard({ afterYes = false } = {}) {
2803
+ const ask = afterYes ? null : owedYes().find(j => !state.seenYes.has(j.key));
2684
2804
  if (ask) return { kind: 'yes', id: `yes:${ask.key}`, job: ask };
2685
2805
  const done = doneJobs()[0];
2686
2806
  if (done) return { kind: 'done', id: `done:${done.bet_id}`, job: done };
@@ -2886,8 +3006,9 @@ function createBetDesk(deps) {
2886
3006
  log({ card: 'bet', choice: 'show', command: null, ran: 'in process', exit_code: 0, kind: 'question', question: item.question.key });
2887
3007
  }
2888
3008
 
2889
- // a re-tap on a job card moves on: a yes waits for a later session, a done is seen
2890
- function passJob(item) {
3009
+ // a re-tap on a job card moves on: a yes waits for a later session, a done is seen.
3010
+ // later: not now on a yes, which also sends it to the back of the job deck
3011
+ function passJob(item, { later = false } = {}) {
2891
3012
  if (item.kind === 'wait') return;
2892
3013
  if (item.kind === 'report') { state.passedQuestions.add(item.id); return; }
2893
3014
  if (item.kind === 'choice') {
@@ -2901,9 +3022,11 @@ function createBetDesk(deps) {
2901
3022
  log({ card: 'bet', choice: 'pass', command: null, ran: 'in process', exit_code: 0, kind: 'question', question: item.question.key });
2902
3023
  return;
2903
3024
  }
3025
+ const skipped = later && item.kind === 'yes';
2904
3026
  if (item.kind === 'yes') state.seenYes.add(item.job.key);
3027
+ if (skipped) { state.skips += 1; state.skippedYes.set(item.job.key, state.skips); }
2905
3028
  if (item.kind === 'done') record(item.job.bet_id, { done_seen_at: iso() });
2906
- log({ card: 'bet', choice: 'pass', command: null, ran: 'in process', exit_code: 0, kind: item.kind, bet_id: item.job.bet_id });
3029
+ log({ card: 'bet', choice: skipped ? 'later' : 'pass', command: null, ran: 'in process', exit_code: 0, kind: item.kind, bet_id: item.job.bet_id });
2907
3030
  }
2908
3031
 
2909
3032
  pollJobs();
@@ -2998,11 +3121,14 @@ function createBetDesk(deps) {
2998
3121
  // shows at once: a yes, a finished job, a queued bet, a question, or a
2999
3122
  // plain card that says the next idea is being written. A tap never waits.
3000
3123
  // sharper passes the bet over and asks the background for a sharper one.
3001
- function next({ sharper = false } = {}) {
3124
+ // later: not now on a yes card.
3125
+ function next({ sharper = false, later = false } = {}) {
3002
3126
  if (state.status === 'starting') return { ok: true, busy: true, ...view() };
3003
3127
  const onScreen = state.status === 'ready' && state.current ? state.current : null;
3128
+ // passed, skipped, or answered: the card after a yes is never another yes
3129
+ const afterYes = Boolean(state.current && state.current.kind === 'yes');
3004
3130
  if (onScreen && onScreen.kind && onScreen.kind !== 'bet') {
3005
- passJob(onScreen);
3131
+ passJob(onScreen, { later });
3006
3132
  } else if (onScreen) {
3007
3133
  record(onScreen.id, { status: 'passed', ...(sharper ? { note: 'asked for a sharper idea' } : {}) });
3008
3134
  log({ card: 'bet', choice: sharper ? 'sharper' : 'pass', command: null, ran: 'in process', exit_code: 0, bet_id: onScreen.id });
@@ -3012,7 +3138,7 @@ function createBetDesk(deps) {
3012
3138
  }
3013
3139
  dropStale();
3014
3140
  if (now() - state.jobs.at > 5000) pollJobs();
3015
- const job = nextJobCard();
3141
+ const job = nextJobCard({ afterYes });
3016
3142
  const ask = job ? null : askCard();
3017
3143
  if (job) showJob(job);
3018
3144
  else if (ask) showAsk(ask);
@@ -3157,16 +3283,39 @@ function createBetDesk(deps) {
3157
3283
 
3158
3284
  // yes: answers the mission's open ask through atris decide, matched by
3159
3285
  // mission and ask, never by its place in a list
3160
- async function answerYes() {
3161
- const item = onJob('yes');
3162
- if (!item) return { ok: false, reason: 'gone', message: 'there is no question on screen' };
3286
+ // A yes from the job deck names its key, and answers that job's yes
3287
+ // wherever it is; the card on the main screen clears only when it is the
3288
+ // same yes.
3289
+ // The yes or no a key names: the main screen's yes card when it is the
3290
+ // same request, else that job's card in the job deck. Without a key, the
3291
+ // yes on the main screen.
3292
+ function findYes({ key, job: jobId } = {}) {
3293
+ const shown = onJob('yes');
3294
+ let item = shown;
3295
+ if (key) {
3296
+ // a deck yes is bound to its job and its command: both must match
3297
+ const bound = y => y && y.key === key && y.mission_id === jobId;
3298
+ item = jobId && shown && bound(shown.job) ? shown : null;
3299
+ if (!item && jobId) {
3300
+ const found = deckCards().map(x => x.yes).find(bound);
3301
+ item = found ? { kind: 'yes', job: found } : null;
3302
+ }
3303
+ }
3304
+ return { shown, item };
3305
+ }
3306
+
3307
+ async function answerYes({ key, job: jobId } = {}) {
3308
+ const { shown, item } = findYes({ key, job: jobId });
3309
+ if (!item) return { ok: false, reason: 'gone', message: key ? 'that question was already answered' : 'there is no question on screen' };
3163
3310
  const { job } = item;
3311
+ const clear = () => { if (shown && shown.job.key === job.key) state.status = 'cleared'; };
3164
3312
  if (job.lock_step) {
3165
3313
  // the yes releases only the command on this card (its hash), once
3166
3314
  const approved = require('./yes-lock').approveYesLock(lockDir, job.mission_id, job.lock_step, null, iso(), { hash: job.lock_hash || null });
3167
3315
  if (job.lock_hash && !approved) {
3168
3316
  state.seenYes.add(job.key);
3169
- state.status = 'cleared';
3317
+ state.answeredYes.add(job.key);
3318
+ clear();
3170
3319
  pollJobs();
3171
3320
  return { ok: true, ack: 'that request was already answered.' };
3172
3321
  }
@@ -3174,8 +3323,9 @@ function createBetDesk(deps) {
3174
3323
  const res = await deps.runCli(args);
3175
3324
  log({ card: 'bet', choice: 'yes', command: require('./gm-phone').displayCommand(args), exit_code: res.code, bet_id: job.bet_id, mission_id: job.mission_id, step: job.lock_step });
3176
3325
  state.seenYes.add(job.key);
3326
+ state.answeredYes.add(job.key);
3177
3327
  if (job.bet_id) record(job.bet_id, { yes_answered: { mission_id: job.mission_id, step: job.lock_step, at: iso() } });
3178
- state.status = 'cleared';
3328
+ clear();
3179
3329
  pollJobs();
3180
3330
  return { ok: true, ack: `${job.owner}: got your yes` };
3181
3331
  }
@@ -3184,7 +3334,8 @@ function createBetDesk(deps) {
3184
3334
  const match = open.find(d => d && d.mission_id === job.mission_id && d.ask_index === job.ask_index);
3185
3335
  if (!match) {
3186
3336
  state.seenYes.add(job.key);
3187
- state.status = 'cleared';
3337
+ state.answeredYes.add(job.key);
3338
+ clear();
3188
3339
  return { ok: true, ack: 'that question was already answered.' };
3189
3340
  }
3190
3341
  const args = ['decide', String(match.number), 'yes', '--json'];
@@ -3192,19 +3343,58 @@ function createBetDesk(deps) {
3192
3343
  log({ card: 'bet', choice: 'yes', command: require('./gm-phone').displayCommand(args), exit_code: res.code, bet_id: job.bet_id, mission_id: job.mission_id });
3193
3344
  if (res.code !== 0) return { ok: false, reason: 'answer_failed', message: 'your yes did not go through; try again' };
3194
3345
  state.seenYes.add(job.key);
3195
- record(job.bet_id, { yes_answered: { mission_id: job.mission_id, ask_index: job.ask_index, at: iso() } });
3196
- state.status = 'cleared';
3346
+ state.answeredYes.add(job.key);
3347
+ if (job.bet_id) record(job.bet_id, { yes_answered: { mission_id: job.mission_id, ask_index: job.ask_index, at: iso() } });
3348
+ clear();
3197
3349
  pollJobs();
3198
3350
  return { ok: true, ack: `${job.owner}: got your yes` };
3199
3351
  }
3200
3352
 
3353
+ // no: the one request on the card is told no, and the job goes on without
3354
+ // it. A held command is denied for good (asking again only hears the no)
3355
+ // and the job is pinged; a plain ask is answered no through atris decide,
3356
+ // matched by mission and ask, the way a yes is.
3357
+ async function answerNo({ key, job: jobId } = {}) {
3358
+ const { shown, item } = findYes({ key, job: jobId });
3359
+ if (!item) return { ok: false, reason: 'gone', message: key ? 'that question was already answered' : 'there is no question on screen' };
3360
+ const { job } = item;
3361
+ const display = args => require('./gm-phone').displayCommand(args);
3362
+ const told = `told ${job.owner} no. it goes on without it.`;
3363
+ const done = (ack) => {
3364
+ state.seenYes.add(job.key);
3365
+ state.answeredYes.add(job.key);
3366
+ if (shown && shown.job.key === job.key) state.status = 'cleared';
3367
+ pollJobs();
3368
+ return { ok: true, ack };
3369
+ };
3370
+ if (job.lock_step) {
3371
+ const denied = require('./yes-lock').denyYesLock(lockDir, job.mission_id, job.lock_step, { hash: job.lock_hash || null, at: iso() });
3372
+ if (!denied) return done('that request was already answered.');
3373
+ const args = ['mission', 'ping', job.mission_id, `the owner said no to step ${job.lock_step}: ${job.ask}. do not run it; leave that step undone and go on with the rest.`, '--from', 'gm-phone'];
3374
+ const res = await deps.runCli(args);
3375
+ log({ card: 'bet', choice: 'no', command: display(args), exit_code: res.code, bet_id: job.bet_id, mission_id: job.mission_id, step: job.lock_step });
3376
+ if (job.bet_id) record(job.bet_id, { no_answered: { mission_id: job.mission_id, step: job.lock_step, at: iso() } });
3377
+ return done(told);
3378
+ }
3379
+ const listed = await deps.runCli(['decide', '--json']);
3380
+ const open = (parseJsonOut(listed.stdout) || {}).decisions || [];
3381
+ const match = open.find(d => d && d.mission_id === job.mission_id && d.ask_index === job.ask_index);
3382
+ if (!match) return done('that question was already answered.');
3383
+ const args = ['decide', String(match.number), 'no', '--json'];
3384
+ const res = await deps.runCli(args);
3385
+ log({ card: 'bet', choice: 'no', command: display(args), exit_code: res.code, bet_id: job.bet_id, mission_id: job.mission_id });
3386
+ if (res.code !== 0) return { ok: false, reason: 'answer_failed', message: 'your no did not go through; try again' };
3387
+ if (job.bet_id) record(job.bet_id, { no_answered: { mission_id: job.mission_id, ask_index: job.ask_index, at: iso() } });
3388
+ return done(told);
3389
+ }
3390
+
3201
3391
  // Two sentences for the owner: what is going on, and why it matters to
3202
3392
  // the business. Nothing about how the work was checked.
3203
3393
  function publicJob(item) {
3204
3394
  const j = item.job;
3205
3395
  const expect = item.kind === 'yes' ? j.expect : j.bet && j.bet.expect;
3206
3396
  const why = expect ? ` ${sentence(expect)}` : '';
3207
- if (item.kind === 'yes') return { what: j.ask, why: `${j.owner} is waiting on your yes for this.${why}` };
3397
+ if (item.kind === 'yes') return { what: plainAsk(j.ask), why: `${j.owner} is waiting on your yes for this.${why}` };
3208
3398
  return { what: j.title, why: `this is done.${why}` };
3209
3399
  }
3210
3400
 
@@ -3269,7 +3459,9 @@ function createBetDesk(deps) {
3269
3459
  // the conversation on this card so far
3270
3460
  thread: cur && state.thread.id === cur.id ? state.thread.turns.slice() : [],
3271
3461
  // the footer count: only members really working
3272
- team_working: teamTable().rows.filter(r => r.status === 'working').length,
3462
+ team_working: statusTable().rows.filter(r => r.status === 'working').length,
3463
+ // the yeses still owed past the one on screen: the small need-you line
3464
+ needs_you: owedYes().filter(j => !(kind === 'yes' && cur.job && cur.job.key === j.key)).length,
3273
3465
  events: events.map(e => ({ i: e.i, kind: e.kind, line: e.line, ...(e.key ? { key: e.key } : {}), ...(e.n != null ? { n: e.n } : {}) })),
3274
3466
  error: state.error,
3275
3467
  queue: state.queue.length,
@@ -3443,7 +3635,7 @@ function createBetDesk(deps) {
3443
3635
  if (said.length > SAY_MAX) return { ok: false, reason: 'too_long', message: `keep it under ${SAY_MAX} characters` };
3444
3636
  const t = threadFor(`member:${name}`);
3445
3637
  if (!confirm && !note) {
3446
- const row = teamTable().rows.find(r => r.member === name);
3638
+ const row = statusTable().rows.find(r => r.member === name);
3447
3639
  const turn = await readTurn(t, said, { kind: 'member', title: name, detail: row ? `${row.status}: ${row.on}. ${row.time}.` : 'no work in the last day', buttons: [] });
3448
3640
  if (turn.stay) return turn.stay;
3449
3641
  }
@@ -3461,8 +3653,33 @@ function createBetDesk(deps) {
3461
3653
  const RECENT_MS = 24 * 60 * 60 * 1000;
3462
3654
  const MOVING_MS = 5 * 60 * 1000;
3463
3655
  const STATUS_ORDER = ['waiting for your yes', 'working', 'waiting to start', 'stopped', 'done', 'idle'];
3656
+ // One mission's row: what it is on, its status, and for how long, with
3657
+ // when it last moved; null when it has not moved in the last day or is
3658
+ // not a job.
3659
+ function missionRow(m, ideaTitle) {
3660
+ const moved = Math.max(...[m.last_tick_at, m.updated_at, m.created_at].map(t => Date.parse(t || '') || 0));
3661
+ const age = now() - moved;
3662
+ if (!moved || age > RECENT_MS) return null;
3663
+ const created = Date.parse(m.created_at || '') || moved;
3664
+ const on = ideaTitle.get(m.id) || missionTitle(m.objective);
3665
+ const status = String(m.status || '');
3666
+ if (status === 'complete') return { moved, row: { on, status: 'done', time: `finished ${spanWords(age)} ago` } };
3667
+ if (status === 'stopped') return { moved, row: { on, status: 'stopped', time: `stopped ${spanWords(age)} ago` } };
3668
+ if (!OPEN_MISSION.has(status)) return null;
3669
+ const asks = (Array.isArray(m.human_asks) ? m.human_asks : []).some(a => a && !(typeof a === 'object' && a.answered_at))
3670
+ || require('./yes-lock').askedYesLocks(lockDir, m.id).length > 0;
3671
+ if (asks) return { moved, row: { on, status: 'waiting for your yes', time: `waiting ${spanWords(age)}` } };
3672
+ if (driverAlive(m) || age <= MOVING_MS) {
3673
+ const wall = Number(m.max_wall_seconds) || Number(m.budget_contract && m.budget_contract.requested_seconds) || 0;
3674
+ const left = wall ? created + wall * 1000 - now() : 0;
3675
+ return { moved, row: { on, status: 'working', time: left > 0 ? leftWords(left) : `at it for ${spanWords(now() - created)}` } };
3676
+ }
3677
+ if (!m.last_tick_at) return { moved, row: { on, status: 'waiting to start', time: `waiting ${spanWords(now() - created)}` } };
3678
+ return { moved, row: { on, status: 'stopped', time: `stopped ${spanWords(age)} ago` } };
3679
+ }
3680
+
3464
3681
  let teamCache = null;
3465
- function teamTable() {
3682
+ function statusTable() {
3466
3683
  if (teamCache && now() - teamCache.at < 10000) return teamCache.table;
3467
3684
  const names = new Set((team || []).map(m => m.name));
3468
3685
  let missions;
@@ -3478,26 +3695,8 @@ function createBetDesk(deps) {
3478
3695
  };
3479
3696
  for (const m of list) {
3480
3697
  if (!m || !m.owner) continue;
3481
- const moved = Math.max(...[m.last_tick_at, m.updated_at, m.created_at].map(t => Date.parse(t || '') || 0));
3482
- const age = now() - moved;
3483
- if (!moved || age > RECENT_MS) continue;
3484
- const created = Date.parse(m.created_at || '') || moved;
3485
- const on = ideaTitle.get(m.id) || missionTitle(m.objective);
3486
- const status = String(m.status || '');
3487
- if (status === 'complete') { put(m.owner, { on, status: 'done', time: `finished ${spanWords(age)} ago` }, moved); continue; }
3488
- if (status === 'stopped') { put(m.owner, { on, status: 'stopped', time: `stopped ${spanWords(age)} ago` }, moved); continue; }
3489
- if (!OPEN_MISSION.has(status)) continue;
3490
- const asks = (Array.isArray(m.human_asks) ? m.human_asks : []).some(a => a && !(typeof a === 'object' && a.answered_at))
3491
- || require('./yes-lock').askedYesLocks(lockDir, m.id).length > 0;
3492
- if (asks) { put(m.owner, { on, status: 'waiting for your yes', time: `waiting ${spanWords(age)}` }, moved); continue; }
3493
- if (driverAlive(m) || age <= MOVING_MS) {
3494
- const wall = Number(m.max_wall_seconds) || Number(m.budget_contract && m.budget_contract.requested_seconds) || 0;
3495
- const left = wall ? created + wall * 1000 - now() : 0;
3496
- put(m.owner, { on, status: 'working', time: left > 0 ? leftWords(left) : `at it for ${spanWords(now() - created)}` }, moved);
3497
- continue;
3498
- }
3499
- if (!m.last_tick_at) { put(m.owner, { on, status: 'waiting to start', time: `waiting ${spanWords(now() - created)}` }, moved); continue; }
3500
- put(m.owner, { on, status: 'stopped', time: `stopped ${spanWords(age)} ago` }, moved);
3698
+ const r = missionRow(m, ideaTitle);
3699
+ if (r) put(m.owner, r.row, r.moved);
3501
3700
  }
3502
3701
  for (const hnd of (deps.readHandoffs || defaultReadHandoffs)(deps.workspaceRoot)) {
3503
3702
  const at = Date.parse((hnd && (hnd.closed_at || hnd.created_at)) || '') || 0;
@@ -3514,6 +3713,185 @@ function createBetDesk(deps) {
3514
3713
  return table;
3515
3714
  }
3516
3715
 
3716
+ // The team page: every member on the team, one row each. role is a few
3717
+ // words from its MEMBER.md. ovr is the board's own rating (atris gm
3718
+ // --board): 60 to 99 from the share of finished jobs that landed in the
3719
+ // last 30 days, "new" with 1 or 2 finished, and two dots with none
3720
+ // finished in that window (a first claim alone is not a rating) or no
3721
+ // task list to read. engine is the tool and the exact model the roster
3722
+ // gives the member (atris engine roster), two dots when no model is
3723
+ // pinned; a person on the team (type: human) never runs on one, so it
3724
+ // says person. A parked member is left out unless it has work on.
3725
+ const ROSTER_MS = 60 * 1000;
3726
+ let rosterCache = null;
3727
+ function rosterFacts() {
3728
+ if (rosterCache && now() - rosterCache.at < ROSTER_MS) return rosterCache.facts;
3729
+ const season = require('./gm-season');
3730
+ let ratings = new Map();
3731
+ try {
3732
+ const loaded = (deps.loadSeason || (root => season.loadSeason(root, { now: now(), lastSeen: null })))(deps.workspaceRoot);
3733
+ if (loaded && !loaded.error) ratings = season.memberRatings(loaded.outcomes || []);
3734
+ } catch { ratings = new Map(); }
3735
+ let resolve = deps.resolveEngine;
3736
+ if (!resolve) {
3737
+ const reg = require('./engine-registry');
3738
+ const { resolveEngineForMember } = require('./member-engine');
3739
+ let rosterState = null;
3740
+ try { rosterState = reg.readRosterState(deps.workspaceRoot); } catch { rosterState = null; }
3741
+ resolve = name => resolveEngineForMember(name, deps.workspaceRoot, { quiet: true, ...(rosterState ? { rosterState } : {}) });
3742
+ }
3743
+ const facts = new Map();
3744
+ const { memberTypeOf } = require('./member-type');
3745
+ for (const m of team || []) {
3746
+ const card = memberCardFacts(deps.workspaceRoot, m.name);
3747
+ const kind = memberTypeOf({ type: card.type });
3748
+ let engine = kind.type === 'human' ? 'person' : NO_RATING;
3749
+ if (kind.runnable) {
3750
+ let picked = null;
3751
+ try { picked = resolve(m.name); } catch { picked = null; }
3752
+ engine = engineWords(picked);
3753
+ }
3754
+ const rated = ratings.get(m.name);
3755
+ facts.set(m.name, {
3756
+ role: memberRoleWords(m.name, card),
3757
+ ovr: !rated || !rated.finished ? NO_RATING : rated.ovr == null ? 'new' : String(rated.ovr),
3758
+ engine,
3759
+ parked: card.parked,
3760
+ });
3761
+ }
3762
+ rosterCache = { at: now(), facts };
3763
+ return facts;
3764
+ }
3765
+
3766
+ function teamTable() {
3767
+ const facts = rosterFacts();
3768
+ const byName = new Map(statusTable().rows.map(r => [r.member, r]));
3769
+ const rows = [];
3770
+ for (const m of team || []) {
3771
+ const f = facts.get(m.name) || { role: NO_RATING, ovr: NO_RATING, engine: NO_RATING, parked: false };
3772
+ const st = byName.get(m.name) || { member: m.name, on: '', status: 'idle', time: '' };
3773
+ if (f.parked && !byName.has(m.name)) continue;
3774
+ rows.push({ member: m.name, role: f.role, ovr: f.ovr, engine: f.engine, status: st.status, sentence: statusSentence(st), on: st.on, time: st.time });
3775
+ }
3776
+ rows.sort((a, b) => STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status) || a.member.localeCompare(b.member));
3777
+ return { rows };
3778
+ }
3779
+
3780
+ // The engine card: what a member runs on now and every model this machine
3781
+ // offers that can do its work, read from local files only. A tap swaps
3782
+ // through the same code as atris engine roster set (lib/roster-swap.js);
3783
+ // the owner's tap is the approval, so there is no yes card. scope member
3784
+ // gives only this member the model, scope job swaps the lead of its job.
3785
+ function onTeam(name) { return (team || []).some(m => m.name === name); }
3786
+ const NOT_ON_TEAM = { ok: false, reason: 'gone', message: 'there is no such member on the team' };
3787
+ function engineCard({ member } = {}) {
3788
+ const name = String(member || '');
3789
+ if (!onTeam(name)) return NOT_ON_TEAM;
3790
+ try {
3791
+ const read = deps.engineChoices || ((root, who, o) => require('./roster-swap').memberEngineChoices(root, who, o));
3792
+ return { ok: true, ...read(deps.workspaceRoot, name, { now: new Date(now()) }) };
3793
+ } catch (err) {
3794
+ return { ok: false, reason: 'unreadable', message: String((err && err.message) || err) };
3795
+ }
3796
+ }
3797
+
3798
+ function swapEngine({ member, engine, model, scope } = {}) {
3799
+ const name = String(member || '');
3800
+ if (!onTeam(name)) return NOT_ON_TEAM;
3801
+ const where = scope === 'job' ? 'job' : 'member';
3802
+ let out;
3803
+ try {
3804
+ const swapFn = deps.swapFromCard || ((...a) => require('./roster-swap').swapFromCard(...a));
3805
+ out = swapFn(deps.workspaceRoot, name, String(engine || ''), String(model || ''), where, { now: new Date(now()) });
3806
+ } catch (err) {
3807
+ const message = String((err && err.message) || err);
3808
+ log({ card: 'bet', choice: 'engine_swap', command: null, ran: 'in process', exit_code: 2, member: name, scope: where, reason: message.slice(0, 200) });
3809
+ return { ok: false, reason: 'refused', message };
3810
+ }
3811
+ rosterCache = null;
3812
+ log({ card: 'bet', choice: 'engine_swap', command: out.command, ran: 'in process', exit_code: 0, member: name, scope: where, changed: out.changed });
3813
+ return { ok: true, ack: out.line, rows: teamTable().rows };
3814
+ }
3815
+
3816
+ // ---- the job deck: one card per running job, the ones waiting on the
3817
+ // owner's yes first. Each card is one divided row: done, work, member,
3818
+ // role, ovr, engine, and status. role, ovr, and engine are the team
3819
+ // table's own cells, so the deck and the table always agree. A card that
3820
+ // needs the owner carries its yes card's two lines and the key the page
3821
+ // sends back with the yes. Every word on it is plain: no command, flag,
3822
+ // or path.
3823
+ const DECK_COLUMNS = ['done', 'work', 'member', 'role', 'ovr', 'engine', 'status'];
3824
+ const DECK_STATUS = new Set(['waiting for your yes', 'working', 'waiting to start']);
3825
+
3826
+ // The yes a running job waits on: the desk's own yes card for it when it
3827
+ // has one, else a step its guard holds, else its first open ask. The keys
3828
+ // are the desk's own, so a yes said on one is said on the other. A yes only
3829
+ // passed over on the main screen is still owed, so it stays on its card.
3830
+ function yesFor(mission) {
3831
+ const said = state.answeredYes;
3832
+ const owed = (state.jobs.yes || []).filter(j => j.mission_id === mission.id && !said.has(j.key));
3833
+ const own = owed.find(j => !state.skippedYes.has(j.key)) || owed[0];
3834
+ if (own) return own;
3835
+ const owner = mission.owner || 'the team';
3836
+ for (const lock of require('./yes-lock').askedYesLocks(lockDir, mission.id)) {
3837
+ const key = `${mission.id}:step-${lock.step}:${lock.hash || ''}`;
3838
+ if (!said.has(key)) return { kind: 'yes', bet_id: null, mission_id: mission.id, owner, ask: lock.text, command: lock.held_command || null, lock_step: lock.step, lock_hash: lock.hash || null, ask_index: null, key };
3839
+ }
3840
+ const job = jobState({ id: null }, mission, null, now());
3841
+ const ask = job.kind === 'yes' ? job.asks.find(x => !said.has(`${mission.id}:${x.index}`)) : null;
3842
+ return ask ? { kind: 'yes', bet_id: null, mission_id: mission.id, owner, ask: ask.text, ask_index: ask.index, key: `${mission.id}:${ask.index}` } : null;
3843
+ }
3844
+
3845
+ // One card per job, never per member: a member with two jobs has two
3846
+ // cards, each with its own table and its own yes. A handoff no run has
3847
+ // picked up yet is a job waiting to start.
3848
+ function deckCards() {
3849
+ let missions;
3850
+ try { missions = (deps.readMissions || defaultReadMissions)(deps.workspaceRoot); } catch { missions = null; }
3851
+ const names = new Set((team || []).map(m => m.name));
3852
+ const facts = new Map(teamTable().rows.map(r => [r.member, r]));
3853
+ const titles = new Map(readLedger(deps.ledgerFile, deps.workspaceRoot).filter(r => r.mission_id && r.bet).map(r => [r.mission_id, r.bet.title]));
3854
+ const jobs = [];
3855
+ for (const m of missions instanceof Map ? missions.values() : Object.values(missions || {})) {
3856
+ if (!m || !m.id || !names.has(m.owner) || !OPEN_MISSION.has(String(m.status || ''))) continue;
3857
+ const r = missionRow(m, titles);
3858
+ if (r && DECK_STATUS.has(r.row.status)) jobs.push({ id: m.id, mission: m, member: m.owner, row: r.row, moved: r.moved });
3859
+ }
3860
+ for (const hnd of (deps.readHandoffs || defaultReadHandoffs)(deps.workspaceRoot)) {
3861
+ const at = Date.parse((hnd && hnd.created_at) || '') || 0;
3862
+ if (!hnd || hnd.status !== 'open' || !names.has(hnd.to) || !at || now() - at > RECENT_MS) continue;
3863
+ const on = clip(String(hnd.summary || '').replace(/^\[[^\]]*\]\s*/, ''), 100);
3864
+ jobs.push({ id: `handoff:${hnd.id || at}`, mission: null, member: hnd.to, row: { on, status: 'waiting to start', time: `waiting ${spanWords(now() - at)}` }, moved: at });
3865
+ }
3866
+ jobs.sort((x, y) => STATUS_ORDER.indexOf(x.row.status) - STATUS_ORDER.indexOf(y.row.status) || y.moved - x.moved || x.member.localeCompare(y.member));
3867
+ // a yes skipped with not now waits at the back of the ones that need the owner
3868
+ const skipRank = x => (x.yes && state.skippedYes.get(x.yes.key)) || 0;
3869
+ const needsFirst = x => (x.card.label === 'needs you' ? 0 : 1);
3870
+ return jobs.map((j) => {
3871
+ const needs = j.row.status === 'waiting for your yes';
3872
+ const yes = needs && j.mission ? yesFor(j.mission) : null;
3873
+ const f = facts.get(j.member) || { role: NO_RATING, ovr: NO_RATING, engine: NO_RATING };
3874
+ // a job whose yes was said here is back with its member, not waiting on the owner
3875
+ const label = needs ? (yes ? 'needs you' : 'you answered') : j.row.status;
3876
+ const card = {
3877
+ job_id: j.id,
3878
+ mission_id: j.mission ? j.mission.id : null,
3879
+ member: j.member,
3880
+ label,
3881
+ time: j.row.time,
3882
+ rows: [{ done: 'not yet', work: plainWork(j.row.on, j.member), member: j.member, role: f.role, ovr: f.ovr, engine: f.engine, status: label }],
3883
+ };
3884
+ if (yes) card.yes = { key: yes.key, ...(yes.lock_hash ? { hash: yes.lock_hash } : {}), ...publicJob({ kind: 'yes', job: yes }) };
3885
+ return { card, yes };
3886
+ }).map((x, i) => ({ x, i }))
3887
+ .sort((a, b) => needsFirst(a.x) - needsFirst(b.x) || (needsFirst(a.x) ? 0 : skipRank(a.x) - skipRank(b.x)) || a.i - b.i)
3888
+ .map(p => p.x);
3889
+ }
3890
+
3891
+ function jobDeck() {
3892
+ return { columns: DECK_COLUMNS, cards: deckCards().map(x => x.card) };
3893
+ }
3894
+
3517
3895
  // A word to a member from its talk card or a card on screen. The phone
3518
3896
  // never calls a steer that can start anyone: it uses --no-ignite, which
3519
3897
  // only reaches a live run and otherwise starts nothing, and then the page
@@ -3568,10 +3946,324 @@ function createBetDesk(deps) {
3568
3946
  }
3569
3947
  }
3570
3948
 
3571
- return { warm, next, pick, say, talk, report, teamTable, steer, skip, doIt, nice, answerYes, answer, pollJobs, settle, yesCards, recordYes, view, state };
3949
+ // ---- the one card on screen (lib/gm-card.js) ----
3950
+ //
3951
+ // The page draws exactly the card served here and nothing else. Over the
3952
+ // main card (the idea, question, yes, and the rest) can sit one layer: the
3953
+ // team, a member's talk card, the job deck, or a swap card. A reply to
3954
+ // words said on a card is its own card over that card. Every action names
3955
+ // the card it acts on and one option on it; anything else is refused.
3956
+ const gmCard = require('./gm-card');
3957
+ // layer: null | { kind: team|member|deck|swap, id, ... }; reply: null | { base, id, text, suggestions, confirm, thread }
3958
+ state.layer = null;
3959
+ state.reply = null;
3960
+ state.served = null;
3961
+ state.serial = 0;
3962
+ // ids made here carry this desk's own tag, so a page left open across a
3963
+ // restart never names a card this desk made
3964
+ const deskTag = crypto.randomBytes(3).toString('hex');
3965
+ const freshId = kind => `${kind}~${deskTag}${(state.serial += 1)}`;
3966
+ const betBody = b => [b.why_now, b.expect].map(sentence).join(' ');
3967
+
3968
+ // the member's facts for a card about their job
3969
+ function jobFacts(name) {
3970
+ if (!onTeam(name)) return null;
3971
+ const f = rosterFacts().get(name) || {};
3972
+ return { member: name, role: f.role || NO_RATING, ovr: f.ovr || NO_RATING, engine: f.engine || NO_RATING };
3973
+ }
3974
+
3975
+ function mainCard() {
3976
+ const cur = state.current;
3977
+ if (!cur) return null;
3978
+ const kind = cur.kind || 'bet';
3979
+ if (kind === 'bet' && (state.status === 'starting' || state.status === 'started')) {
3980
+ return gmCard.started({ id: `${cur.id}:started`, title: cur.bet.title, body: betBody(cur.bet), line: state.status === 'starting' ? 'starting now' : (state.mission && state.mission.line) });
3981
+ }
3982
+ if (state.status !== 'ready') return null;
3983
+ const v = view();
3984
+ if (kind === 'bet') return gmCard.idea({ id: cur.id, title: cur.bet.title, body: betBody(cur.bet) });
3985
+ if (kind === 'question') return gmCard.question({ id: cur.id, title: v.question.ask, body: v.question.line, choices: v.question.choices });
3986
+ if (kind === 'report') return gmCard.report({ id: cur.id, title: v.report.what, body: v.report.why });
3987
+ if (kind === 'choice') return gmCard.choice({ id: cur.id, items: v.choice.options });
3988
+ if (kind === 'wait') return gmCard.wait({ id: cur.id });
3989
+ if (kind === 'yes') {
3990
+ const j = cur.job;
3991
+ return gmCard.yes({ id: cur.id, title: v.job.what, body: v.job.why, job: jobFacts(j.owner), request: { job: j.mission_id, key: j.key, hash: j.lock_hash || null } });
3992
+ }
3993
+ if (kind === 'done') return gmCard.done({ id: cur.id, title: v.job.what, body: v.job.why, job: jobFacts(cur.job.owner) });
3994
+ return null;
3995
+ }
3996
+
3997
+ function layerCard() {
3998
+ const l = state.layer;
3999
+ if (!l) return null;
4000
+ if (l.kind === 'team') {
4001
+ const rows = teamTable().rows;
4002
+ return gmCard.team({ id: l.id, rows, line: l.line || null, deck: rows.some(r => DECK_STATUS.has(r.status)) });
4003
+ }
4004
+ if (l.kind === 'member') {
4005
+ const row = teamTable().rows.find(r => r.member === l.name);
4006
+ return gmCard.member({ id: l.id, name: l.name, sentence: row ? sentence(row.sentence) : 'nothing in the last day.', job: jobFacts(l.name), line: l.line || null });
4007
+ }
4008
+ if (l.kind === 'deck') {
4009
+ const cards = deckCards().map(x => x.card);
4010
+ if (!cards.length) return null;
4011
+ const at = Math.min(l.at || 0, cards.length - 1);
4012
+ const c = { ...cards[at], said: (l.said && l.said[cards[at].job_id]) || null };
4013
+ const r = c.rows[0];
4014
+ // the deck card is about the job: its row without the rating and the model, which live on the team table
4015
+ const columns = DECK_COLUMNS.filter(k => k !== 'ovr' && k !== 'engine');
4016
+ return gmCard.deck({ id: l.id, at, total: cards.length, card: c, columns, job: { member: c.member, role: r.role, ovr: r.ovr, engine: r.engine } });
4017
+ }
4018
+ if (l.kind === 'swap') {
4019
+ if (!l.choices) {
4020
+ const out = engineCard({ member: l.name });
4021
+ if (!out.ok) return gmCard.member({ id: l.id, name: l.name, sentence: out.message || 'could not read the models on this machine.', job: jobFacts(l.name) });
4022
+ l.choices = out;
4023
+ }
4024
+ return gmCard.swap({ id: l.id, name: l.name, choices: l.choices, scope: l.scope, all: l.all, line: l.line || null });
4025
+ }
4026
+ return null;
4027
+ }
4028
+
4029
+ // the card under a reply: the layer when one is open, else the main card
4030
+ function underCard() {
4031
+ return layerCard() || mainCard();
4032
+ }
4033
+
4034
+ function currentCard() {
4035
+ const under = underCard();
4036
+ if (!under) return null;
4037
+ const r = state.reply;
4038
+ if (r && r.base === under.id) {
4039
+ return gmCard.reply({ id: r.id, base: under, text: r.text, thread: r.thread, suggestions: r.suggestions, confirm: r.confirm });
4040
+ }
4041
+ if (r) state.reply = null;
4042
+ return under;
4043
+ }
4044
+
4045
+ function serve() {
4046
+ const card = currentCard();
4047
+ // served under a stamped id: options that change come with a new id, so a tap on the old one does nothing
4048
+ state.served = card ? gmCard.stamp(gmCard.enforce(card, problems => log({ card: 'bet', choice: 'card_problem', command: null, ran: 'in process', exit_code: 1, kind: card.kind, problems: problems.slice(0, 5) }))) : null;
4049
+ return state.served;
4050
+ }
4051
+
4052
+ // what every answer carries: the card on screen and the small lines around it
4053
+ function envelope(extra = {}) {
4054
+ const card = serve();
4055
+ const v = view();
4056
+ return {
4057
+ ok: true,
4058
+ ...extra,
4059
+ card: gmCard.wire(card),
4060
+ status: v.status,
4061
+ queue: v.queue,
4062
+ team_working: v.team_working,
4063
+ needs_you: v.needs_you,
4064
+ };
4065
+ }
4066
+
4067
+ function refuse(reason, message, status = 409) {
4068
+ return { status, body: { ...envelope(), ok: false, reason, message } };
4069
+ }
4070
+ const done200 = extra => ({ status: 200, body: envelope(extra) });
4071
+
4072
+ // the page opened, or looked again: the card on screen now. A read starts
4073
+ // no work: the queue fills when the desk is made, on its timer, and on an act.
4074
+ function card() {
4075
+ return envelope();
4076
+ }
4077
+
4078
+ // a word that came back as a reply: the reply is the next card
4079
+ function replied(out, baseId, text) {
4080
+ if (out.stay) {
4081
+ state.reply = { base: baseId, id: freshId(`${baseId}~r`), text: out.reply, suggestions: out.suggestions || [], confirm: null, thread: (out.thread || []).slice(0, -1) };
4082
+ return done200();
4083
+ }
4084
+ if (out.reason === 'needs_confirm') {
4085
+ state.reply = { base: baseId, id: freshId(`${baseId}~r`), text: out.message, suggestions: [], confirm: { text }, thread: [] };
4086
+ return done200();
4087
+ }
4088
+ return null;
4089
+ }
4090
+
4091
+ // words said on the main card, a reply on it, or its something else
4092
+ async function sayOnMain(text, more = {}) {
4093
+ const under = underCard();
4094
+ const out = await say({ text, ...more });
4095
+ const back = replied(out, under.id, text);
4096
+ if (back) return back;
4097
+ if (!out.ok) return refuse(out.reason || 'gone', out.message || 'that did not send. try again');
4098
+ state.reply = null;
4099
+ next();
4100
+ return done200({ ack: out.ack });
4101
+ }
4102
+
4103
+ // words to the member on a talk card or a deck card
4104
+ async function talkOnLayer(text, more = {}) {
4105
+ const l = state.layer;
4106
+ const under = underCard();
4107
+ const name = l.kind === 'deck' ? under.member : l.name;
4108
+ const out = await talk({ member: name, text, ...more });
4109
+ const back = replied(out, under.id, text);
4110
+ if (back) return back;
4111
+ if (!out.ok) return refuse(out.reason || 'gone', out.message || 'that did not send. try again');
4112
+ state.reply = null;
4113
+ if (l.kind === 'deck') { l.said = { ...(l.said || {}), [under.deck_job]: out.ack }; l.id = freshId('deck'); } else { l.line = out.ack; l.id = freshId('member'); }
4114
+ return done200();
4115
+ }
4116
+
4117
+ function open(kind, extra = {}) {
4118
+ state.reply = null;
4119
+ state.layer = { kind, id: freshId(kind), ...extra };
4120
+ if (kind === 'deck' && !layerCard()) state.layer = { kind: 'team', id: freshId('team') };
4121
+ }
4122
+
4123
+ // One action from the page: { card_id, option_id } or { card_id, text } for
4124
+ // the card's box, or { orb: true } for the next card, or { open: team|deck }
4125
+ // from the footer. confirm: the second tap on an option that asks for one.
4126
+ async function act(body = {}) {
4127
+ const cardId = body.card_id == null || body.card_id === '' ? null : String(body.card_id);
4128
+ const shown = state.served;
4129
+ const stale = Boolean(cardId && (!shown || cardId !== shown.id));
4130
+ // the big orb: the next card; a page that names an older card gets the one on screen, and nothing is passed
4131
+ if (body.orb === true) {
4132
+ if (stale && shown) return done200();
4133
+ state.layer = null;
4134
+ state.reply = null;
4135
+ const out = next({ sharper: false });
4136
+ return done200(out.busy ? { busy: true } : {});
4137
+ }
4138
+ if (body.open === 'team' || body.open === 'deck') {
4139
+ if (stale) return refuse('gone', 'that card changed. here is the one on screen now');
4140
+ open(body.open);
4141
+ return done200();
4142
+ }
4143
+ // an option or words always name the card they were for
4144
+ if (!shown || !cardId || stale) return refuse('gone', 'that card changed, so nothing was done. here is the one on screen now');
4145
+ const optionId = body.option_id == null ? null : String(body.option_id);
4146
+ const opt = optionId ? shown.options.find(o => o.id === optionId) : null;
4147
+ if (optionId && !opt) return refuse('bad_option', 'that is not on this card');
4148
+ if (!opt && !shown.input) return refuse('bad_option', 'this card has no box to type in');
4149
+ const typed = String(body.text == null ? '' : body.text).trim();
4150
+ if ((!opt || opt.type) && !typed) return refuse('empty', 'say something first');
4151
+ if (opt && opt.confirm && body.confirm !== true) return { status: 409, body: { ...envelope(), ok: false, reason: 'needs_confirm', message: opt.confirm } };
4152
+ const action = opt ? opt.action : shown.input.action;
4153
+ const b = (opt && opt.binds) || {};
4154
+ const onLayer = Boolean(state.layer);
4155
+ log({ card: 'bet', choice: 'act', command: null, ran: 'in process', exit_code: 0, kind: shown.kind, action, option: optionId });
4156
+ switch (action) {
4157
+ case 'do': {
4158
+ const out = await doIt();
4159
+ if (!out.ok) return refuse(out.reason, out.message);
4160
+ next();
4161
+ return done200({ ack: out.ack });
4162
+ }
4163
+ case 'sharper': next({ sharper: true }); return done200();
4164
+ case 'skip': {
4165
+ const out = skip();
4166
+ if (!out.ok) return refuse(out.reason, out.message);
4167
+ state.reply = null;
4168
+ return done200({ ack: out.ack, home: true });
4169
+ }
4170
+ case 'next': next(); return done200();
4171
+ case 'later': {
4172
+ if (onLayer && state.layer.kind === 'deck') {
4173
+ state.seenYes.add(b.key);
4174
+ state.skips += 1;
4175
+ state.skippedYes.set(b.key, state.skips);
4176
+ state.layer.id = freshId('deck');
4177
+ return done200();
4178
+ }
4179
+ state.reply = null;
4180
+ next({ later: true });
4181
+ return done200();
4182
+ }
4183
+ case 'yes':
4184
+ case 'no': {
4185
+ const out = await (action === 'yes' ? answerYes : answerNo)({ key: b.key, job: b.job });
4186
+ if (!out.ok) return refuse(out.reason, out.message);
4187
+ state.reply = null;
4188
+ if (onLayer && state.layer.kind === 'deck') {
4189
+ state.layer.said = { ...(state.layer.said || {}), [shown.deck_job]: out.ack };
4190
+ state.layer.id = freshId('deck');
4191
+ return done200();
4192
+ }
4193
+ next();
4194
+ return done200({ ack: out.ack });
4195
+ }
4196
+ case 'nice': {
4197
+ const out = nice();
4198
+ if (!out.ok) return refuse(out.reason, out.message);
4199
+ next();
4200
+ return done200({ ack: out.ack });
4201
+ }
4202
+ case 'answer': {
4203
+ const out = answer({ choice: b.choice });
4204
+ if (!out.ok) return refuse(out.reason, out.message);
4205
+ state.reply = null;
4206
+ next();
4207
+ return done200();
4208
+ }
4209
+ case 'pick': {
4210
+ const out = pick({ id: b.bet });
4211
+ if (!out.ok) return refuse(out.reason, out.message);
4212
+ return done200();
4213
+ }
4214
+ case 'report': {
4215
+ const raw = typed.replace(/,/g, '');
4216
+ if (/^-?\d+(\.\d+)?$/.test(raw)) {
4217
+ const out = report({ value: raw });
4218
+ if (!out.ok) return refuse(out.reason, out.message);
4219
+ state.reply = null;
4220
+ next();
4221
+ return done200({ ack: out.ack });
4222
+ }
4223
+ return sayOnMain(typed);
4224
+ }
4225
+ case 'say': return onLayer ? talkOnLayer(typed || b.text) : sayOnMain(typed || b.text);
4226
+ case 'talk': return talkOnLayer(typed);
4227
+ case 'start':
4228
+ case 'note': {
4229
+ const more = action === 'start' ? { confirm: true } : { note: true };
4230
+ return onLayer ? talkOnLayer(b.text, more) : sayOnMain(b.text, more);
4231
+ }
4232
+ case 'back': {
4233
+ if (state.reply) { state.reply = null; return done200(); }
4234
+ const l = state.layer;
4235
+ // a talk card, a swap card, and the job deck go back to the whole team; the team goes back to the card under it
4236
+ if (l && l.kind !== 'team') open('team');
4237
+ else state.layer = null;
4238
+ return done200();
4239
+ }
4240
+ case 'member': open('member', { name: b.member }); return done200();
4241
+ case 'swap': open('swap', { name: b.member, scope: 'member', all: false }); return done200();
4242
+ case 'deck': open('deck', { at: 0 }); return done200();
4243
+ case 'flip': {
4244
+ const n = deckCards().length;
4245
+ if (n) state.layer.at = (((state.layer.at || 0) + (Number(b.step) || 1)) % n + n) % n;
4246
+ state.layer.id = freshId('deck');
4247
+ return done200();
4248
+ }
4249
+ case 'scope': state.layer.scope = b.scope === 'job' ? 'job' : 'member'; state.layer.id = freshId('swap'); return done200();
4250
+ // more models: the same card, with every model on it
4251
+ case 'more': state.layer.all = true; return done200();
4252
+ case 'swap_to': {
4253
+ const out = swapEngine({ member: state.layer.name, engine: b.engine, model: b.model, scope: b.scope });
4254
+ if (!out.ok) return refuse(out.reason, out.message);
4255
+ open('team', { line: out.ack });
4256
+ return done200({ ack: out.ack });
4257
+ }
4258
+ default: return refuse('bad_option', 'that does nothing here');
4259
+ }
4260
+ }
4261
+
4262
+ return { warm, next, pick, say, talk, report, teamTable, jobDeck, engineCard, swapEngine, steer, skip, doIt, nice, answerYes, answerNo, answer, pollJobs, settle, yesCards, recordYes, view, card, act, state };
3572
4263
  }
3573
4264
 
3574
4265
  module.exports = {
4266
+ plainAsk,
3575
4267
  BACKOFF_MS,
3576
4268
  FALLBACK_QUESTION,
3577
4269
  readScoreboard,