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
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,8 @@ 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; a yes only passed over is still owed
2548
+ answeredYes: new Set(),
2441
2549
  // reviews running now, by bet id
2442
2550
  reviews: new Map(),
2443
2551
  // failed jobs being sent back to the team now, by bet id
@@ -2593,7 +2701,7 @@ function createBetDesk(deps) {
2593
2701
  // own; the yes always goes to the run that was held
2594
2702
  for (const card of require('./yes-lock').handoffYesCards(lockDir)) {
2595
2703
  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' };
2704
+ 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
2705
  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
2706
  }
2599
2707
  const expectOf = new Map(readLedger(deps.ledgerFile, deps.workspaceRoot).map(r => [r.id, r.bet && r.bet.expect]));
@@ -3157,16 +3265,31 @@ function createBetDesk(deps) {
3157
3265
 
3158
3266
  // yes: answers the mission's open ask through atris decide, matched by
3159
3267
  // 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' };
3268
+ // A yes from the job deck names its key, and answers that job's yes
3269
+ // wherever it is; the card on the main screen clears only when it is the
3270
+ // same yes.
3271
+ async function answerYes({ key, job: jobId } = {}) {
3272
+ const shown = onJob('yes');
3273
+ let item = shown;
3274
+ if (key) {
3275
+ // a deck yes is bound to its job and its command: both must match
3276
+ const bound = y => y && y.key === key && y.mission_id === jobId;
3277
+ item = jobId && shown && bound(shown.job) ? shown : null;
3278
+ if (!item && jobId) {
3279
+ const found = deckCards().map(x => x.yes).find(bound);
3280
+ item = found ? { kind: 'yes', job: found } : null;
3281
+ }
3282
+ }
3283
+ if (!item) return { ok: false, reason: 'gone', message: key ? 'that question was already answered' : 'there is no question on screen' };
3163
3284
  const { job } = item;
3285
+ const clear = () => { if (shown && shown.job.key === job.key) state.status = 'cleared'; };
3164
3286
  if (job.lock_step) {
3165
3287
  // the yes releases only the command on this card (its hash), once
3166
3288
  const approved = require('./yes-lock').approveYesLock(lockDir, job.mission_id, job.lock_step, null, iso(), { hash: job.lock_hash || null });
3167
3289
  if (job.lock_hash && !approved) {
3168
3290
  state.seenYes.add(job.key);
3169
- state.status = 'cleared';
3291
+ state.answeredYes.add(job.key);
3292
+ clear();
3170
3293
  pollJobs();
3171
3294
  return { ok: true, ack: 'that request was already answered.' };
3172
3295
  }
@@ -3174,8 +3297,9 @@ function createBetDesk(deps) {
3174
3297
  const res = await deps.runCli(args);
3175
3298
  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
3299
  state.seenYes.add(job.key);
3300
+ state.answeredYes.add(job.key);
3177
3301
  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';
3302
+ clear();
3179
3303
  pollJobs();
3180
3304
  return { ok: true, ack: `${job.owner}: got your yes` };
3181
3305
  }
@@ -3184,7 +3308,8 @@ function createBetDesk(deps) {
3184
3308
  const match = open.find(d => d && d.mission_id === job.mission_id && d.ask_index === job.ask_index);
3185
3309
  if (!match) {
3186
3310
  state.seenYes.add(job.key);
3187
- state.status = 'cleared';
3311
+ state.answeredYes.add(job.key);
3312
+ clear();
3188
3313
  return { ok: true, ack: 'that question was already answered.' };
3189
3314
  }
3190
3315
  const args = ['decide', String(match.number), 'yes', '--json'];
@@ -3192,8 +3317,9 @@ function createBetDesk(deps) {
3192
3317
  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
3318
  if (res.code !== 0) return { ok: false, reason: 'answer_failed', message: 'your yes did not go through; try again' };
3194
3319
  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';
3320
+ state.answeredYes.add(job.key);
3321
+ if (job.bet_id) record(job.bet_id, { yes_answered: { mission_id: job.mission_id, ask_index: job.ask_index, at: iso() } });
3322
+ clear();
3197
3323
  pollJobs();
3198
3324
  return { ok: true, ack: `${job.owner}: got your yes` };
3199
3325
  }
@@ -3204,7 +3330,7 @@ function createBetDesk(deps) {
3204
3330
  const j = item.job;
3205
3331
  const expect = item.kind === 'yes' ? j.expect : j.bet && j.bet.expect;
3206
3332
  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}` };
3333
+ if (item.kind === 'yes') return { what: plainAsk(j.ask), why: `${j.owner} is waiting on your yes for this.${why}` };
3208
3334
  return { what: j.title, why: `this is done.${why}` };
3209
3335
  }
3210
3336
 
@@ -3269,7 +3395,7 @@ function createBetDesk(deps) {
3269
3395
  // the conversation on this card so far
3270
3396
  thread: cur && state.thread.id === cur.id ? state.thread.turns.slice() : [],
3271
3397
  // the footer count: only members really working
3272
- team_working: teamTable().rows.filter(r => r.status === 'working').length,
3398
+ team_working: statusTable().rows.filter(r => r.status === 'working').length,
3273
3399
  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
3400
  error: state.error,
3275
3401
  queue: state.queue.length,
@@ -3443,7 +3569,7 @@ function createBetDesk(deps) {
3443
3569
  if (said.length > SAY_MAX) return { ok: false, reason: 'too_long', message: `keep it under ${SAY_MAX} characters` };
3444
3570
  const t = threadFor(`member:${name}`);
3445
3571
  if (!confirm && !note) {
3446
- const row = teamTable().rows.find(r => r.member === name);
3572
+ const row = statusTable().rows.find(r => r.member === name);
3447
3573
  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
3574
  if (turn.stay) return turn.stay;
3449
3575
  }
@@ -3461,8 +3587,33 @@ function createBetDesk(deps) {
3461
3587
  const RECENT_MS = 24 * 60 * 60 * 1000;
3462
3588
  const MOVING_MS = 5 * 60 * 1000;
3463
3589
  const STATUS_ORDER = ['waiting for your yes', 'working', 'waiting to start', 'stopped', 'done', 'idle'];
3590
+ // One mission's row: what it is on, its status, and for how long, with
3591
+ // when it last moved; null when it has not moved in the last day or is
3592
+ // not a job.
3593
+ function missionRow(m, ideaTitle) {
3594
+ const moved = Math.max(...[m.last_tick_at, m.updated_at, m.created_at].map(t => Date.parse(t || '') || 0));
3595
+ const age = now() - moved;
3596
+ if (!moved || age > RECENT_MS) return null;
3597
+ const created = Date.parse(m.created_at || '') || moved;
3598
+ const on = ideaTitle.get(m.id) || missionTitle(m.objective);
3599
+ const status = String(m.status || '');
3600
+ if (status === 'complete') return { moved, row: { on, status: 'done', time: `finished ${spanWords(age)} ago` } };
3601
+ if (status === 'stopped') return { moved, row: { on, status: 'stopped', time: `stopped ${spanWords(age)} ago` } };
3602
+ if (!OPEN_MISSION.has(status)) return null;
3603
+ const asks = (Array.isArray(m.human_asks) ? m.human_asks : []).some(a => a && !(typeof a === 'object' && a.answered_at))
3604
+ || require('./yes-lock').askedYesLocks(lockDir, m.id).length > 0;
3605
+ if (asks) return { moved, row: { on, status: 'waiting for your yes', time: `waiting ${spanWords(age)}` } };
3606
+ if (driverAlive(m) || age <= MOVING_MS) {
3607
+ const wall = Number(m.max_wall_seconds) || Number(m.budget_contract && m.budget_contract.requested_seconds) || 0;
3608
+ const left = wall ? created + wall * 1000 - now() : 0;
3609
+ return { moved, row: { on, status: 'working', time: left > 0 ? leftWords(left) : `at it for ${spanWords(now() - created)}` } };
3610
+ }
3611
+ if (!m.last_tick_at) return { moved, row: { on, status: 'waiting to start', time: `waiting ${spanWords(now() - created)}` } };
3612
+ return { moved, row: { on, status: 'stopped', time: `stopped ${spanWords(age)} ago` } };
3613
+ }
3614
+
3464
3615
  let teamCache = null;
3465
- function teamTable() {
3616
+ function statusTable() {
3466
3617
  if (teamCache && now() - teamCache.at < 10000) return teamCache.table;
3467
3618
  const names = new Set((team || []).map(m => m.name));
3468
3619
  let missions;
@@ -3478,26 +3629,8 @@ function createBetDesk(deps) {
3478
3629
  };
3479
3630
  for (const m of list) {
3480
3631
  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);
3632
+ const r = missionRow(m, ideaTitle);
3633
+ if (r) put(m.owner, r.row, r.moved);
3501
3634
  }
3502
3635
  for (const hnd of (deps.readHandoffs || defaultReadHandoffs)(deps.workspaceRoot)) {
3503
3636
  const at = Date.parse((hnd && (hnd.closed_at || hnd.created_at)) || '') || 0;
@@ -3514,6 +3647,176 @@ function createBetDesk(deps) {
3514
3647
  return table;
3515
3648
  }
3516
3649
 
3650
+ // The team page: every member on the team, one row each. role is a few
3651
+ // words from its MEMBER.md. ovr is the board's own rating (atris gm
3652
+ // --board): 60 to 99 from the share of finished jobs that landed in the
3653
+ // last 30 days, "new" with 1 or 2 finished, and two dots with none
3654
+ // finished in that window (a first claim alone is not a rating) or no
3655
+ // task list to read. engine is the tool and the exact model the roster
3656
+ // gives the member (atris engine roster), two dots when no model is
3657
+ // pinned; a person on the team (type: human) never runs on one, so it
3658
+ // says person. A parked member is left out unless it has work on.
3659
+ const ROSTER_MS = 60 * 1000;
3660
+ let rosterCache = null;
3661
+ function rosterFacts() {
3662
+ if (rosterCache && now() - rosterCache.at < ROSTER_MS) return rosterCache.facts;
3663
+ const season = require('./gm-season');
3664
+ let ratings = new Map();
3665
+ try {
3666
+ const loaded = (deps.loadSeason || (root => season.loadSeason(root, { now: now(), lastSeen: null })))(deps.workspaceRoot);
3667
+ if (loaded && !loaded.error) ratings = season.memberRatings(loaded.outcomes || []);
3668
+ } catch { ratings = new Map(); }
3669
+ let resolve = deps.resolveEngine;
3670
+ if (!resolve) {
3671
+ const reg = require('./engine-registry');
3672
+ const { resolveEngineForMember } = require('./member-engine');
3673
+ let rosterState = null;
3674
+ try { rosterState = reg.readRosterState(deps.workspaceRoot); } catch { rosterState = null; }
3675
+ resolve = name => resolveEngineForMember(name, deps.workspaceRoot, { quiet: true, ...(rosterState ? { rosterState } : {}) });
3676
+ }
3677
+ const facts = new Map();
3678
+ const { memberTypeOf } = require('./member-type');
3679
+ for (const m of team || []) {
3680
+ const card = memberCardFacts(deps.workspaceRoot, m.name);
3681
+ const kind = memberTypeOf({ type: card.type });
3682
+ let engine = kind.type === 'human' ? 'person' : NO_RATING;
3683
+ if (kind.runnable) {
3684
+ let picked = null;
3685
+ try { picked = resolve(m.name); } catch { picked = null; }
3686
+ engine = engineWords(picked);
3687
+ }
3688
+ const rated = ratings.get(m.name);
3689
+ facts.set(m.name, {
3690
+ role: memberRoleWords(m.name, card),
3691
+ ovr: !rated || !rated.finished ? NO_RATING : rated.ovr == null ? 'new' : String(rated.ovr),
3692
+ engine,
3693
+ parked: card.parked,
3694
+ });
3695
+ }
3696
+ rosterCache = { at: now(), facts };
3697
+ return facts;
3698
+ }
3699
+
3700
+ function teamTable() {
3701
+ const facts = rosterFacts();
3702
+ const byName = new Map(statusTable().rows.map(r => [r.member, r]));
3703
+ const rows = [];
3704
+ for (const m of team || []) {
3705
+ const f = facts.get(m.name) || { role: NO_RATING, ovr: NO_RATING, engine: NO_RATING, parked: false };
3706
+ const st = byName.get(m.name) || { member: m.name, on: '', status: 'idle', time: '' };
3707
+ if (f.parked && !byName.has(m.name)) continue;
3708
+ 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 });
3709
+ }
3710
+ rows.sort((a, b) => STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status) || a.member.localeCompare(b.member));
3711
+ return { rows };
3712
+ }
3713
+
3714
+ // The engine card: what a member runs on now and every model this machine
3715
+ // offers that can do its work, read from local files only. A tap swaps
3716
+ // through the same code as atris engine roster set (lib/roster-swap.js);
3717
+ // the owner's tap is the approval, so there is no yes card. scope member
3718
+ // gives only this member the model, scope job swaps the lead of its job.
3719
+ function onTeam(name) { return (team || []).some(m => m.name === name); }
3720
+ const NOT_ON_TEAM = { ok: false, reason: 'gone', message: 'there is no such member on the team' };
3721
+ function engineCard({ member } = {}) {
3722
+ const name = String(member || '');
3723
+ if (!onTeam(name)) return NOT_ON_TEAM;
3724
+ try {
3725
+ return { ok: true, ...require('./roster-swap').memberEngineChoices(deps.workspaceRoot, name, { now: new Date(now()) }) };
3726
+ } catch (err) {
3727
+ return { ok: false, reason: 'unreadable', message: String((err && err.message) || err) };
3728
+ }
3729
+ }
3730
+
3731
+ function swapEngine({ member, engine, model, scope } = {}) {
3732
+ const name = String(member || '');
3733
+ if (!onTeam(name)) return NOT_ON_TEAM;
3734
+ const where = scope === 'job' ? 'job' : 'member';
3735
+ let out;
3736
+ try {
3737
+ out = require('./roster-swap').swapFromCard(deps.workspaceRoot, name, String(engine || ''), String(model || ''), where, { now: new Date(now()) });
3738
+ } catch (err) {
3739
+ const message = String((err && err.message) || err);
3740
+ log({ card: 'bet', choice: 'engine_swap', command: null, ran: 'in process', exit_code: 2, member: name, scope: where, reason: message.slice(0, 200) });
3741
+ return { ok: false, reason: 'refused', message };
3742
+ }
3743
+ rosterCache = null;
3744
+ log({ card: 'bet', choice: 'engine_swap', command: out.command, ran: 'in process', exit_code: 0, member: name, scope: where, changed: out.changed });
3745
+ return { ok: true, ack: out.line, rows: teamTable().rows };
3746
+ }
3747
+
3748
+ // ---- the job deck: one card per running job, the ones waiting on the
3749
+ // owner's yes first. Each card is one divided row: done, work, member,
3750
+ // role, ovr, engine, and status. role, ovr, and engine are the team
3751
+ // table's own cells, so the deck and the table always agree. A card that
3752
+ // needs the owner carries its yes card's two lines and the key the page
3753
+ // sends back with the yes. Every word on it is plain: no command, flag,
3754
+ // or path.
3755
+ const DECK_COLUMNS = ['done', 'work', 'member', 'role', 'ovr', 'engine', 'status'];
3756
+ const DECK_STATUS = new Set(['waiting for your yes', 'working', 'waiting to start']);
3757
+
3758
+ // The yes a running job waits on: the desk's own yes card for it when it
3759
+ // has one, else a step its guard holds, else its first open ask. The keys
3760
+ // are the desk's own, so a yes said on one is said on the other. A yes only
3761
+ // passed over on the main screen is still owed, so it stays on its card.
3762
+ function yesFor(mission) {
3763
+ const said = state.answeredYes;
3764
+ const own = (state.jobs.yes || []).find(j => j.mission_id === mission.id && !said.has(j.key));
3765
+ if (own) return own;
3766
+ const owner = mission.owner || 'the team';
3767
+ for (const lock of require('./yes-lock').askedYesLocks(lockDir, mission.id)) {
3768
+ const key = `${mission.id}:step-${lock.step}:${lock.hash || ''}`;
3769
+ 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 };
3770
+ }
3771
+ const job = jobState({ id: null }, mission, null, now());
3772
+ const ask = job.kind === 'yes' ? job.asks.find(x => !said.has(`${mission.id}:${x.index}`)) : null;
3773
+ 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;
3774
+ }
3775
+
3776
+ // One card per job, never per member: a member with two jobs has two
3777
+ // cards, each with its own table and its own yes. A handoff no run has
3778
+ // picked up yet is a job waiting to start.
3779
+ function deckCards() {
3780
+ let missions;
3781
+ try { missions = (deps.readMissions || defaultReadMissions)(deps.workspaceRoot); } catch { missions = null; }
3782
+ const names = new Set((team || []).map(m => m.name));
3783
+ const facts = new Map(teamTable().rows.map(r => [r.member, r]));
3784
+ const titles = new Map(readLedger(deps.ledgerFile, deps.workspaceRoot).filter(r => r.mission_id && r.bet).map(r => [r.mission_id, r.bet.title]));
3785
+ const jobs = [];
3786
+ for (const m of missions instanceof Map ? missions.values() : Object.values(missions || {})) {
3787
+ if (!m || !m.id || !names.has(m.owner) || !OPEN_MISSION.has(String(m.status || ''))) continue;
3788
+ const r = missionRow(m, titles);
3789
+ if (r && DECK_STATUS.has(r.row.status)) jobs.push({ id: m.id, mission: m, member: m.owner, row: r.row, moved: r.moved });
3790
+ }
3791
+ for (const hnd of (deps.readHandoffs || defaultReadHandoffs)(deps.workspaceRoot)) {
3792
+ const at = Date.parse((hnd && hnd.created_at) || '') || 0;
3793
+ if (!hnd || hnd.status !== 'open' || !names.has(hnd.to) || !at || now() - at > RECENT_MS) continue;
3794
+ const on = clip(String(hnd.summary || '').replace(/^\[[^\]]*\]\s*/, ''), 100);
3795
+ 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 });
3796
+ }
3797
+ 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));
3798
+ return jobs.map((j) => {
3799
+ const needs = j.row.status === 'waiting for your yes';
3800
+ const yes = needs && j.mission ? yesFor(j.mission) : null;
3801
+ const f = facts.get(j.member) || { role: NO_RATING, ovr: NO_RATING, engine: NO_RATING };
3802
+ const label = needs ? 'needs you' : j.row.status;
3803
+ const card = {
3804
+ job_id: j.id,
3805
+ mission_id: j.mission ? j.mission.id : null,
3806
+ member: j.member,
3807
+ label,
3808
+ time: j.row.time,
3809
+ 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 }],
3810
+ };
3811
+ if (yes) card.yes = { key: yes.key, ...publicJob({ kind: 'yes', job: yes }) };
3812
+ return { card, yes };
3813
+ });
3814
+ }
3815
+
3816
+ function jobDeck() {
3817
+ return { columns: DECK_COLUMNS, cards: deckCards().map(x => x.card) };
3818
+ }
3819
+
3517
3820
  // A word to a member from its talk card or a card on screen. The phone
3518
3821
  // never calls a steer that can start anyone: it uses --no-ignite, which
3519
3822
  // only reaches a live run and otherwise starts nothing, and then the page
@@ -3568,10 +3871,11 @@ function createBetDesk(deps) {
3568
3871
  }
3569
3872
  }
3570
3873
 
3571
- return { warm, next, pick, say, talk, report, teamTable, steer, skip, doIt, nice, answerYes, answer, pollJobs, settle, yesCards, recordYes, view, state };
3874
+ return { warm, next, pick, say, talk, report, teamTable, jobDeck, engineCard, swapEngine, steer, skip, doIt, nice, answerYes, answer, pollJobs, settle, yesCards, recordYes, view, state };
3572
3875
  }
3573
3876
 
3574
3877
  module.exports = {
3878
+ plainAsk,
3575
3879
  BACKOFF_MS,
3576
3880
  FALLBACK_QUESTION,
3577
3881
  readScoreboard,