atris 3.73.1 → 3.74.1

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 (51) hide show
  1. package/bin/atris.js +85 -82
  2. package/commands/aeo.js +2 -2
  3. package/commands/align.js +10 -19
  4. package/commands/auth.js +1 -1
  5. package/commands/autopilot.js +4 -8
  6. package/commands/avail.js +36 -7
  7. package/commands/computer.js +3 -1
  8. package/commands/console.js +1 -1
  9. package/commands/context-sync.js +1 -60
  10. package/commands/fleet.js +0 -1
  11. package/commands/lifecycle.js +17 -6
  12. package/commands/log-sync.js +1 -1
  13. package/commands/member.js +158 -18
  14. package/commands/mission.js +157 -69
  15. package/commands/now.js +0 -7
  16. package/commands/plugin.js +1 -1
  17. package/commands/pull.js +1 -1
  18. package/commands/push.js +0 -1
  19. package/commands/radar.js +0 -4
  20. package/commands/recap.js +25 -1
  21. package/commands/report.js +0 -4
  22. package/commands/review.js +1 -1
  23. package/commands/setup.js +6 -2
  24. package/commands/slop.js +8 -2
  25. package/commands/task.js +0 -522
  26. package/commands/terminal.js +1 -1
  27. package/commands/youtube.js +4 -6
  28. package/commands/zen.js +4 -4
  29. package/lib/agent-today.js +412 -0
  30. package/lib/autoland.js +0 -8
  31. package/lib/bench/runner.js +0 -4
  32. package/lib/chat-log-scan.js +0 -28
  33. package/lib/cli-scope.js +4 -2
  34. package/lib/engine-registry.js +0 -4
  35. package/lib/falsifier-probe.js +2 -1
  36. package/lib/file-ops.js +6 -1
  37. package/lib/first-minute.js +2 -0
  38. package/lib/gm-bet.js +125 -30
  39. package/lib/gm-phone-page.js +398 -99
  40. package/lib/gm-phone.js +30 -3
  41. package/lib/handoff-wake.js +180 -0
  42. package/lib/manifest.js +0 -10
  43. package/lib/member-switches.js +0 -11
  44. package/lib/member-up.js +18 -3
  45. package/lib/mission-protected-lane.js +38 -6
  46. package/lib/review-integrity.js +0 -4
  47. package/lib/spawn-env.js +92 -3
  48. package/lib/task-proof.js +2 -1
  49. package/lib/task-receipt.js +3 -2
  50. package/lib/yes-lock.js +633 -81
  51. package/package.json +1 -1
package/lib/gm-bet.js CHANGED
@@ -152,6 +152,8 @@ function readScoreboard(file, workspaceRoot) {
152
152
  if (!row || row.workspace !== workspaceRoot) continue;
153
153
  if (row.kind === 'pick' && row.name) board = { name: row.name, up: row.up !== false, source: row.source === 'installs' ? 'installs' : 'reported', at: row.at, reports: [] };
154
154
  else if (row.kind === 'report' && board && Number.isFinite(row.value)) board.reports.push({ value: row.value, at: row.at });
155
+ // the owner stopped tracking it: no number until one is picked again
156
+ else if (row.kind === 'clear') board = null;
155
157
  }
156
158
  return board;
157
159
  }
@@ -348,6 +350,8 @@ function buildQuestionRewrite(previous, reasons) {
348
350
 
349
351
  const SAY_READ_MS = 15000;
350
352
  const SAY_KINDS = new Set(['question', 'answer', 'instruction', 'unclear']);
353
+ // on the weekly number card, what an instruction wants done with the number
354
+ const NUMBER_INTENTS = new Set(['stop', 'change', 'later', 'other']);
351
355
  const SAY_FALLBACK = 'say that another way, or tap a button.';
352
356
  // the last turns of a card's conversation the reader sees
353
357
  const THREAD_TURNS = 6;
@@ -380,6 +384,7 @@ async function readSaid({ text, card, thread = [], hint = null, workspaceRoot, b
380
384
  '- unclear: you cannot tell. reply: one short question back.',
381
385
  '- a message that ends with "?" or asks why, what, or how is a question back, never an answer.',
382
386
  '- you may add "suggestions": 0 to 3 short replies he could tap next, each under 40 characters.',
387
+ card.kind === 'number' ? '- on this number card an instruction also has "intent": "stop" when he wants to stop tracking this number, "change" when he wants a different number, "later" when he wants to be asked another time, else "other". for "other", reply says what this card can do.' : '',
383
388
  `- plain lowercase-friendly words, no em dashes, and never these words: ${OWNER_BANNED_WORDS.join(', ')}.`,
384
389
  '',
385
390
  `## the card on screen (${card.kind})`,
@@ -404,7 +409,8 @@ async function readSaid({ text, card, thread = [], hint = null, workspaceRoot, b
404
409
  const v = parsed.bet;
405
410
  if (!v || !SAY_KINDS.has(v.kind) || typeof v.reply !== 'string') return { ok: false, reason: 'the read was not the shape asked for' };
406
411
  const suggestions = Array.isArray(v.suggestions) ? v.suggestions.filter(x => typeof x === 'string' && x.trim()).map(x => x.trim()) : [];
407
- return { ok: true, kind: v.kind, reply: clip(v.reply, 280), ...(suggestions.length ? { suggestions } : {}) };
412
+ const intent = card.kind === 'number' && NUMBER_INTENTS.has(v.intent) ? v.intent : null;
413
+ return { ok: true, kind: v.kind, reply: clip(v.reply, 280), ...(intent ? { intent } : {}), ...(suggestions.length ? { suggestions } : {}) };
408
414
  }
409
415
 
410
416
  // Two or three numbers that would tell the owner this project is going well,
@@ -1991,11 +1997,21 @@ const MISSION_STOP = 'every team step is done with proof, and every your-yes ste
1991
1997
 
1992
1998
  // The bet's own verify is the check; a bet made before verify existed keeps
1993
1999
  // the old formatting check.
1994
- function missionStartArgs(bet, { owner, lane }, betId = null) {
2000
+ function missionStartArgs(bet, { owner, lane }, betId = null, workspaceRoot = process.cwd()) {
1995
2001
  // the owner steps are sealed into the mission record at start
1996
2002
  const seal = require('./yes-lock').sealFor(bet.steps);
1997
2003
  const time = jobTime(bet);
1998
- return ['mission', 'start', missionObjective(bet, { owner, betId }), '--owner', owner, '--runner', 'claude', '--lane', lane, '--verify', bet.verify || MISSION_VERIFY, '--stop', MISSION_STOP, '--max-wall', String(time.seconds), '--max-ticks', String(time.ticks), '--stop-when-done', ...(seal.length ? ['--yes-lock', JSON.stringify(seal)] : []), '--json'];
2004
+ // --worktree: the job runs, and its check runs, in its own copy of the
2005
+ // code. Mission #266 ran in the shared main folder, which sat on an old
2006
+ // branch, so its check could not see the work it had merged. A folder with
2007
+ // no git has no master to protect and no copy to make, so the job runs in
2008
+ // place there, as it always did.
2009
+ const worktree = inGitCheckout(workspaceRoot) ? ['--worktree'] : [];
2010
+ return ['mission', 'start', missionObjective(bet, { owner, betId }), '--owner', owner, '--runner', 'claude', '--lane', lane, '--verify', bet.verify || MISSION_VERIFY, '--stop', MISSION_STOP, '--max-wall', String(time.seconds), '--max-ticks', String(time.ticks), '--stop-when-done', ...worktree, ...(seal.length ? ['--yes-lock', JSON.stringify(seal)] : []), '--json'];
2011
+ }
2012
+
2013
+ function inGitCheckout(workspaceRoot) {
2014
+ return String(gitOut(workspaceRoot, ['rev-parse', '--is-inside-work-tree']) || '').trim() === 'true';
1999
2015
  }
2000
2016
 
2001
2017
  // the run gets the same time as the start, so the detached driver never
@@ -2041,10 +2057,22 @@ const STALL_MS = 2 * 60 * 60 * 1000;
2041
2057
 
2042
2058
  // Every mission in this workspace, read-only, through the same reader the
2043
2059
  // mission command uses (it only reads .atris/state/missions.jsonl).
2044
- function defaultReadMissions(workspaceRoot) {
2045
- return require(path.join(__dirname, '..', 'commands', 'mission.js')).loadMissionMap(workspaceRoot);
2060
+ // Every mission the desk follows: the main folder's, plus those in worktrees
2061
+ // ("do it" missions run in their own), newest record winning.
2062
+ function readDeskMissions(workspaceRoot) {
2063
+ const missionCmd = require(path.join(__dirname, '..', 'commands', 'mission.js'));
2064
+ const map = new Map(missionCmd.loadMissionMap(workspaceRoot));
2065
+ const at = m => Date.parse((m && (m.updated_at || m.last_tick_at || m.created_at)) || '') || 0;
2066
+ for (const rolled of missionCmd.listWorktreeRollupMissions(workspaceRoot)) {
2067
+ if (!rolled || !rolled.id) continue;
2068
+ const known = map.get(rolled.id);
2069
+ if (!known || at(rolled) >= at(known)) map.set(rolled.id, rolled);
2070
+ }
2071
+ return map;
2046
2072
  }
2047
2073
 
2074
+ const defaultReadMissions = readDeskMissions;
2075
+
2048
2076
  // A mission's last receipt, read-only. Null when there is none.
2049
2077
  function defaultReadReceipt(mission, workspaceRoot) {
2050
2078
  if (!mission || !mission.receipt_path) return null;
@@ -2223,9 +2251,23 @@ function defaultReadHandoffs(workspaceRoot) {
2223
2251
  }
2224
2252
 
2225
2253
  function currentMember(betId, owner, handoffs) {
2254
+ const hit = latestHandoff(betId, handoffs);
2255
+ return hit ? hit.to : owner;
2256
+ }
2257
+
2258
+ function latestHandoff(betId, handoffs) {
2226
2259
  const tag = handoffTag(betId);
2227
2260
  const hits = handoffs.filter(h => h && typeof h.summary === 'string' && h.summary.includes(tag) && h.to);
2228
- return hits.length ? hits[hits.length - 1].to : owner;
2261
+ return hits.length ? hits[hits.length - 1] : null;
2262
+ }
2263
+
2264
+ // When the newest handoff for a job could not start its member (the team was
2265
+ // at its limit, no engine, asleep), say that instead of "<member> has it".
2266
+ function handoffWaiting(betId, handoffs) {
2267
+ const hit = latestHandoff(betId, handoffs);
2268
+ const wake = hit && hit.wake;
2269
+ if (!wake || wake.status !== 'not_started') return null;
2270
+ return clip(String(wake.text || `${hit.to} did not start`).split('. to start it:')[0], 160);
2229
2271
  }
2230
2272
 
2231
2273
  // a line that ends like a sentence
@@ -2286,8 +2328,8 @@ function queuedProblem(bet, team) {
2286
2328
 
2287
2329
  // ---- the desk: a queue of ready bets, one shown at a time --------------------------------
2288
2330
 
2331
+ // ideas kept ready ahead; one is written again the moment one is shown
2289
2332
  const QUEUE_TARGET = 3;
2290
- const REFILL_BELOW = 2;
2291
2333
  // one generation at a time; the cap of 3 calls in flight per session is never reached
2292
2334
  const MAX_IN_FLIGHT = 1;
2293
2335
  const SESSION_IN_FLIGHT_CAP = 3;
@@ -2348,7 +2390,6 @@ function createBetDesk(deps) {
2348
2390
  gen: null,
2349
2391
  // a sharper idea asked for, made in the background like any other
2350
2392
  pendingSharper: null,
2351
- topping: false,
2352
2393
  failures: 0,
2353
2394
  backoffUntil: 0,
2354
2395
  retry: null,
@@ -2490,7 +2531,7 @@ function createBetDesk(deps) {
2490
2531
  // still because the guard holds a step for the owner's yes: that is a yes card, not a halt
2491
2532
  const held = job.stalled ? require('./yes-lock').askedYesLocks(lockDir, mission.id) : [];
2492
2533
  if (held.length) {
2493
- for (const lock of held) yes.push({ ...job, kind: 'yes', ask: lock.text, command: lock.held_command || null, lock_step: lock.step, ask_index: null, key: `${mission.id}:step-${lock.step}` });
2534
+ for (const lock of held) yes.push({ ...job, kind: 'yes', ask: lock.text, command: lock.held_command || null, lock_step: lock.step, lock_hash: lock.hash || null, ask_index: null, key: `${mission.id}:step-${lock.step}:${lock.hash || ''}` });
2494
2535
  continue;
2495
2536
  }
2496
2537
  // finished with nothing failed: the roster's reviewer looks before it counts as done
@@ -2515,15 +2556,24 @@ function createBetDesk(deps) {
2515
2556
  });
2516
2557
  log({ card: 'bet', choice: 'job_finished', command: null, ran: 'in process', exit_code: job.failed ? 1 : 0, bet_id: row.id, mission_id: mission.id });
2517
2558
  } else if (job.kind === 'working') {
2518
- working.push({ ...job, owner: currentMember(row.id, job.owner, handoffs()) });
2559
+ const waiting = handoffWaiting(row.id, handoffs());
2560
+ working.push({ ...job, owner: currentMember(row.id, job.owner, handoffs()), ...(waiting ? { waiting } : {}) });
2519
2561
  // a step the job tried and the guard held is a yes card
2520
2562
  for (const lock of require('./yes-lock').askedYesLocks(lockDir, mission.id)) {
2521
- yes.push({ ...job, kind: 'yes', ask: lock.text, command: lock.held_command || null, lock_step: lock.step, ask_index: null, key: `${mission.id}:step-${lock.step}` });
2563
+ yes.push({ ...job, kind: 'yes', ask: lock.text, command: lock.held_command || null, lock_step: lock.step, lock_hash: lock.hash || null, ask_index: null, key: `${mission.id}:step-${lock.step}:${lock.hash || ''}` });
2522
2564
  }
2523
2565
  } else if (job.kind === 'yes') {
2524
2566
  for (const ask of job.asks) yes.push({ ...job, ask_index: ask.index, ask: ask.text, key: `${job.mission_id}:${ask.index}` });
2525
2567
  }
2526
2568
  }
2569
+ // a run woken by a handoff, held from a command: the card shows on the
2570
+ // job it was handed off from when that job is on the desk, else on its
2571
+ // own; the yes always goes to the run that was held
2572
+ for (const card of require('./yes-lock').handoffYesCards(lockDir)) {
2573
+ const parent = [...working, ...yes].find(j => j.mission_id && j.mission_id === card.parent_mission_id) || null;
2574
+ 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' };
2575
+ 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 || ''}` });
2576
+ }
2527
2577
  const expectOf = new Map(readLedger(deps.ledgerFile, deps.workspaceRoot).map(r => [r.id, r.bet && r.bet.expect]));
2528
2578
  state.jobs = { working, yes: yes.map(y => ({ ...y, expect: expectOf.get(y.bet_id) || null })), at: now() };
2529
2579
  retryFailed();
@@ -2665,10 +2715,11 @@ function createBetDesk(deps) {
2665
2715
  question: { key: 'scoreboard', ask: SCOREBOARD_ASK, line: null, choices: [...list.map((c, i) => ({ id: `s${i + 1}`, label: c.name })), { id: 'other', label: 'something else', other: true }], numbers: list },
2666
2716
  };
2667
2717
  }
2668
- if (board.source !== 'reported' || state.passedQuestions.has('report')) return null;
2718
+ const today = dayKey(now());
2719
+ // passed over today (not now, or a tap past it): asked again tomorrow
2720
+ if (board.source !== 'reported' || state.passedQuestions.has(`report:${today}`)) return null;
2669
2721
  const last = board.reports[board.reports.length - 1] || null;
2670
2722
  const weekly = !last || now() - (Date.parse(last.at) || 0) >= WEEK_MS;
2671
- const today = dayKey(now());
2672
2723
  const waiting = readLedger(deps.ledgerFile, deps.workspaceRoot).some(r => r.status === 'doing' && r.bet && r.bet.target && r.bet.target.metric === 'scoreboard'
2673
2724
  && String(r.bet.check_date || '') < today && !board.reports.some(x => String(x.at).slice(0, 10) >= r.bet.check_date));
2674
2725
  if (!weekly && !waiting) return null;
@@ -2793,8 +2844,9 @@ function createBetDesk(deps) {
2793
2844
  return { ok: true, ...view() };
2794
2845
  }
2795
2846
 
2796
- // nothing ready and nothing left to ask: one plain card, shown at once
2797
- const WAIT_CARD = { what: 'the next idea is being written.', why: 'tap the orb again in a minute to see it.' };
2847
+ // nothing ready and nothing left to ask: one calm line, shown at once; the
2848
+ // page brings the idea in once it is written
2849
+ const WAIT_CARD = { what: 'writing your next idea.' };
2798
2850
  function showWait() {
2799
2851
  state.current = { kind: 'wait', id: `wait:${now()}` };
2800
2852
  state.status = 'ready';
@@ -2815,7 +2867,7 @@ function createBetDesk(deps) {
2815
2867
  // a re-tap on a job card moves on: a yes waits for a later session, a done is seen
2816
2868
  function passJob(item) {
2817
2869
  if (item.kind === 'wait') return;
2818
- if (item.kind === 'report') { state.passedQuestions.add('report'); return; }
2870
+ if (item.kind === 'report') { state.passedQuestions.add(item.id); return; }
2819
2871
  if (item.kind === 'choice') {
2820
2872
  for (const i of item.items) record(i.id, { status: 'passed', note: 'not picked' });
2821
2873
  log({ card: 'bet', choice: 'pass', command: null, ran: 'in process', exit_code: 0, kind: 'choice' });
@@ -2884,13 +2936,16 @@ function createBetDesk(deps) {
2884
2936
  // the sharper or reworked idea the owner asked for is the next shown
2885
2937
  if (sharperOf || ownerNote) state.queue.unshift(item);
2886
2938
  else state.queue.push(item);
2939
+ // the calm line stays the card on screen; the page sees the idea in
2940
+ // the queue and asks for it when the owner is not typing there
2887
2941
  return pump();
2888
2942
  });
2889
2943
  return gen;
2890
2944
  }
2891
2945
 
2892
- // Keeps the queue topped up: below 2 it refills to 3, one generation at a
2893
- // time. A sharper idea the owner asked for goes first.
2946
+ // Keeps the queue full: below 3 the next one is written, so showing one
2947
+ // starts the next at once, one generation at a time. A sharper idea the
2948
+ // owner asked for goes first.
2894
2949
  function pump() {
2895
2950
  pumpQuestions();
2896
2951
  pumpSuggestions();
@@ -2902,9 +2957,7 @@ function createBetDesk(deps) {
2902
2957
  const gen = startGeneration(asked);
2903
2958
  return gen && gen.done;
2904
2959
  }
2905
- if (state.queue.length < REFILL_BELOW) state.topping = true;
2906
- if (state.queue.length >= QUEUE_TARGET) state.topping = false;
2907
- if (!state.topping) return null;
2960
+ if (state.queue.length >= QUEUE_TARGET) return null;
2908
2961
  if (state.failures >= MAX_BACKGROUND_FAILURES) {
2909
2962
  if (now() < state.backoffUntil) return null;
2910
2963
  state.failures = 0;
@@ -2970,7 +3023,7 @@ function createBetDesk(deps) {
2970
3023
  if (!isBet()) return { ok: false, reason: 'gone', message: 'there is no idea on screen to start' };
2971
3024
  const { id, bet } = state.current;
2972
3025
  const pick = pickOwner(bet, deps.workspaceRoot);
2973
- const startArgs = missionStartArgs(bet, pick, id);
3026
+ const startArgs = missionStartArgs(bet, pick, id, deps.workspaceRoot);
2974
3027
  state.status = 'starting';
2975
3028
  const display = args => require('./gm-phone').displayCommand(args);
2976
3029
  let res = await deps.runCli(startArgs);
@@ -3015,10 +3068,13 @@ function createBetDesk(deps) {
3015
3068
  }
3016
3069
 
3017
3070
  // an answer to the question on screen is saved to the focus file, where
3018
- // the writer reads it for every bet after this one
3019
- function answer({ choice, text } = {}) {
3071
+ // the writer reads it for every bet after this one. card: the id of the
3072
+ // card the page shows; a page that missed the next card names the old one,
3073
+ // and its answer is saved against nothing (answer ids repeat across questions)
3074
+ function answer({ choice, text, card } = {}) {
3020
3075
  const item = onJob('question');
3021
3076
  if (!item) return { ok: false, reason: 'gone', message: 'there is no question on screen' };
3077
+ if (card !== undefined && card !== item.id) return { ok: false, reason: 'gone', message: 'that card changed, so nothing was saved. tap the orb for the new one' };
3022
3078
  const picked = item.question.choices.find(c => c.id === choice);
3023
3079
  if (!picked) return { ok: false, reason: 'bad_choice', message: 'that is not one of the answers' };
3024
3080
  const q = item.question;
@@ -3062,7 +3118,7 @@ function createBetDesk(deps) {
3062
3118
  const again = { ...row.bet, why_now: `the last run ${why}. try again. ${row.bet.why_now}` };
3063
3119
  const pick = pickOwner(again, deps.workspaceRoot);
3064
3120
  const display = args => require('./gm-phone').displayCommand(args);
3065
- const startArgs = missionStartArgs(again, pick, row.id);
3121
+ const startArgs = missionStartArgs(again, pick, row.id, deps.workspaceRoot);
3066
3122
  let res = await deps.runCli(startArgs);
3067
3123
  log({ card: 'bet', choice: 'send_back', command: display(startArgs), exit_code: res.code, bet_id: row.id });
3068
3124
  const started = parseJsonOut(res.stdout);
@@ -3084,12 +3140,19 @@ function createBetDesk(deps) {
3084
3140
  if (!item) return { ok: false, reason: 'gone', message: 'there is no question on screen' };
3085
3141
  const { job } = item;
3086
3142
  if (job.lock_step) {
3087
- require('./yes-lock').approveYesLock(lockDir, job.mission_id, job.lock_step, null, iso());
3143
+ // the yes releases only the command on this card (its hash), once
3144
+ const approved = require('./yes-lock').approveYesLock(lockDir, job.mission_id, job.lock_step, null, iso(), { hash: job.lock_hash || null });
3145
+ if (job.lock_hash && !approved) {
3146
+ state.seenYes.add(job.key);
3147
+ state.status = 'cleared';
3148
+ pollJobs();
3149
+ return { ok: true, ack: 'that request was already answered.' };
3150
+ }
3088
3151
  const args = ['mission', 'ping', job.mission_id, `the owner said yes to step ${job.lock_step}: ${job.ask}. the guard lets it through now; run it again.`, '--from', 'gm-phone'];
3089
3152
  const res = await deps.runCli(args);
3090
3153
  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 });
3091
3154
  state.seenYes.add(job.key);
3092
- record(job.bet_id, { yes_answered: { mission_id: job.mission_id, step: job.lock_step, at: iso() } });
3155
+ if (job.bet_id) record(job.bet_id, { yes_answered: { mission_id: job.mission_id, step: job.lock_step, at: iso() } });
3093
3156
  state.status = 'cleared';
3094
3157
  pollJobs();
3095
3158
  return { ok: true, ack: `${job.owner}: got your yes` };
@@ -3180,7 +3243,7 @@ function createBetDesk(deps) {
3180
3243
  } : null,
3181
3244
  // each option: its title, and one plain line of why, the result it expects
3182
3245
  choice: kind === 'choice' ? { options: cur.items.map(i => ({ id: i.id, title: i.bet.title, why: choiceWhy(i.bet.expect) })) } : null,
3183
- working: state.jobs.working.map(j => ({ title: j.title, owner: j.owner, ...(j.left ? { left: j.left } : {}) })),
3246
+ working: state.jobs.working.map(j => ({ title: j.title, owner: j.owner, ...(j.left ? { left: j.left } : {}), ...(j.waiting ? { waiting: j.waiting } : {}) })),
3184
3247
  // the conversation on this card so far
3185
3248
  thread: cur && state.thread.id === cur.id ? state.thread.turns.slice() : [],
3186
3249
  // the footer count: only members really working
@@ -3223,7 +3286,7 @@ function createBetDesk(deps) {
3223
3286
  if (v.kind === 'question') return { kind: 'question', title: v.question.ask, detail: v.question.line, buttons: v.question.choices.map(c => c.label) };
3224
3287
  if (v.kind === 'bet') return { kind: 'idea', title: v.bet.title, detail: [v.bet.why_now, v.bet.expect].join(' '), buttons: ['do it', 'sharper idea', 'not now'] };
3225
3288
  if (v.kind === 'choice') return { kind: 'choice', title: 'which one next?', detail: v.choice.options.map(o => o.title).join(' / '), buttons: [] };
3226
- if (v.kind === 'report') return { kind: 'number', title: v.report.what, detail: v.report.why, buttons: [] };
3289
+ if (v.kind === 'report') return { kind: 'number', title: v.report.what, detail: v.report.why, buttons: ['save', 'not now'] };
3227
3290
  return { kind: v.kind, title: v.job ? v.job.what : '', detail: v.job ? v.job.why : '', buttons: [] };
3228
3291
  }
3229
3292
 
@@ -3251,12 +3314,14 @@ function createBetDesk(deps) {
3251
3314
  // On a question it is the answer; on an idea, a note the writer reworks it
3252
3315
  // with (the rework is the next idea); on a choice or the plain card, the
3253
3316
  // next idea fits it; on a yes or a finished job, a word to that member.
3254
- async function say({ text, confirm = false, note = false } = {}) {
3317
+ async function say({ text, confirm = false, note = false, card } = {}) {
3255
3318
  const said = String(text || '').trim();
3256
3319
  if (!said) return { ok: false, reason: 'empty', message: 'say something first' };
3257
3320
  if (said.length > SAY_MAX) return { ok: false, reason: 'too_long', message: `keep it under ${SAY_MAX} characters` };
3258
3321
  const cur = state.status === 'ready' ? state.current : null;
3259
3322
  if (!cur) return { ok: false, reason: 'gone', message: 'there is no card on screen' };
3323
+ // words said on a card the page no longer shows act on nothing
3324
+ if (card !== undefined && card !== cur.id) return { ok: false, reason: 'gone', message: 'that card changed, so nothing was done. tap the orb for the new one' };
3260
3325
  const kind = cur.kind || 'bet';
3261
3326
  log({ card: 'bet', choice: 'say', command: null, ran: 'in process', exit_code: 0, kind, chars: said.length });
3262
3327
  const t = threadFor(cur.id);
@@ -3291,6 +3356,35 @@ function createBetDesk(deps) {
3291
3356
  pump();
3292
3357
  return { ok: true, ack: plainReply(read.reply) || 'got it. the next idea will fit that.' };
3293
3358
  }
3359
+ // Words on the weekly number card are about that number, and the
3360
+ // reader says what they want. A plain number is this week's value. Stop
3361
+ // tracking this, or pick a different number, stops tracking it, and the
3362
+ // next card asks which number, if any. Ask me tomorrow is not now. Any
3363
+ // other answer or instruction gets a reply on the card and changes nothing.
3364
+ if (kind === 'report') {
3365
+ const stay = (reply) => {
3366
+ addTurns(t, { who: 'owner', text: said }, { who: 'orb', text: reply });
3367
+ return { ok: true, stay: true, kind: 'unclear', reply, thread: t.turns.slice() };
3368
+ };
3369
+ if (read.kind === 'answer') {
3370
+ const saved = report({ value: said });
3371
+ if (saved.ok) { endThread(); return saved; }
3372
+ return stay('type this week\'s number in the box, or say stop tracking this.');
3373
+ }
3374
+ if (read.intent === 'later') {
3375
+ passJob(cur);
3376
+ state.status = 'cleared';
3377
+ endThread();
3378
+ return { ok: true, ack: 'got it. i will ask tomorrow.' };
3379
+ }
3380
+ if (read.intent !== 'stop' && read.intent !== 'change') return stay(plainReply(read.reply) || 'this card keeps one number a week. say stop tracking this, or ask me tomorrow.');
3381
+ const name = cur.board.name;
3382
+ appendLedger(scoreboardFile, { workspace: deps.workspaceRoot, kind: 'clear', name, at: iso() });
3383
+ log({ card: 'bet', choice: 'scoreboard_clear', command: null, ran: 'in process', exit_code: 0 });
3384
+ state.status = 'cleared';
3385
+ endThread();
3386
+ return { ok: true, ack: `got it. i stopped tracking ${name}.` };
3387
+ }
3294
3388
  if (kind === 'yes' || kind === 'done') {
3295
3389
  const out = await steer({ member: cur.job.owner, text: said, confirm, note });
3296
3390
  if (out.ok) endThread();
@@ -3458,6 +3552,7 @@ module.exports = {
3458
3552
  scoreboardPath,
3459
3553
  choiceWhy,
3460
3554
  JOB_POLL_MS,
3555
+ readDeskMissions,
3461
3556
  OWNER_BANNED,
3462
3557
  jobTime,
3463
3558
  generateQuestion,