atris 3.73.1 → 3.74.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/gm-bet.js CHANGED
@@ -1991,11 +1991,21 @@ const MISSION_STOP = 'every team step is done with proof, and every your-yes ste
1991
1991
 
1992
1992
  // The bet's own verify is the check; a bet made before verify existed keeps
1993
1993
  // the old formatting check.
1994
- function missionStartArgs(bet, { owner, lane }, betId = null) {
1994
+ function missionStartArgs(bet, { owner, lane }, betId = null, workspaceRoot = process.cwd()) {
1995
1995
  // the owner steps are sealed into the mission record at start
1996
1996
  const seal = require('./yes-lock').sealFor(bet.steps);
1997
1997
  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'];
1998
+ // --worktree: the job runs, and its check runs, in its own copy of the
1999
+ // code. Mission #266 ran in the shared main folder, which sat on an old
2000
+ // branch, so its check could not see the work it had merged. A folder with
2001
+ // no git has no master to protect and no copy to make, so the job runs in
2002
+ // place there, as it always did.
2003
+ const worktree = inGitCheckout(workspaceRoot) ? ['--worktree'] : [];
2004
+ 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'];
2005
+ }
2006
+
2007
+ function inGitCheckout(workspaceRoot) {
2008
+ return String(gitOut(workspaceRoot, ['rev-parse', '--is-inside-work-tree']) || '').trim() === 'true';
1999
2009
  }
2000
2010
 
2001
2011
  // the run gets the same time as the start, so the detached driver never
@@ -2041,10 +2051,22 @@ const STALL_MS = 2 * 60 * 60 * 1000;
2041
2051
 
2042
2052
  // Every mission in this workspace, read-only, through the same reader the
2043
2053
  // 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);
2054
+ // Every mission the desk follows: the main folder's, plus those in worktrees
2055
+ // ("do it" missions run in their own), newest record winning.
2056
+ function readDeskMissions(workspaceRoot) {
2057
+ const missionCmd = require(path.join(__dirname, '..', 'commands', 'mission.js'));
2058
+ const map = new Map(missionCmd.loadMissionMap(workspaceRoot));
2059
+ const at = m => Date.parse((m && (m.updated_at || m.last_tick_at || m.created_at)) || '') || 0;
2060
+ for (const rolled of missionCmd.listWorktreeRollupMissions(workspaceRoot)) {
2061
+ if (!rolled || !rolled.id) continue;
2062
+ const known = map.get(rolled.id);
2063
+ if (!known || at(rolled) >= at(known)) map.set(rolled.id, rolled);
2064
+ }
2065
+ return map;
2046
2066
  }
2047
2067
 
2068
+ const defaultReadMissions = readDeskMissions;
2069
+
2048
2070
  // A mission's last receipt, read-only. Null when there is none.
2049
2071
  function defaultReadReceipt(mission, workspaceRoot) {
2050
2072
  if (!mission || !mission.receipt_path) return null;
@@ -2223,9 +2245,23 @@ function defaultReadHandoffs(workspaceRoot) {
2223
2245
  }
2224
2246
 
2225
2247
  function currentMember(betId, owner, handoffs) {
2248
+ const hit = latestHandoff(betId, handoffs);
2249
+ return hit ? hit.to : owner;
2250
+ }
2251
+
2252
+ function latestHandoff(betId, handoffs) {
2226
2253
  const tag = handoffTag(betId);
2227
2254
  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;
2255
+ return hits.length ? hits[hits.length - 1] : null;
2256
+ }
2257
+
2258
+ // When the newest handoff for a job could not start its member (the team was
2259
+ // at its limit, no engine, asleep), say that instead of "<member> has it".
2260
+ function handoffWaiting(betId, handoffs) {
2261
+ const hit = latestHandoff(betId, handoffs);
2262
+ const wake = hit && hit.wake;
2263
+ if (!wake || wake.status !== 'not_started') return null;
2264
+ return clip(String(wake.text || `${hit.to} did not start`).split('. to start it:')[0], 160);
2229
2265
  }
2230
2266
 
2231
2267
  // a line that ends like a sentence
@@ -2286,8 +2322,8 @@ function queuedProblem(bet, team) {
2286
2322
 
2287
2323
  // ---- the desk: a queue of ready bets, one shown at a time --------------------------------
2288
2324
 
2325
+ // ideas kept ready ahead; one is written again the moment one is shown
2289
2326
  const QUEUE_TARGET = 3;
2290
- const REFILL_BELOW = 2;
2291
2327
  // one generation at a time; the cap of 3 calls in flight per session is never reached
2292
2328
  const MAX_IN_FLIGHT = 1;
2293
2329
  const SESSION_IN_FLIGHT_CAP = 3;
@@ -2348,7 +2384,6 @@ function createBetDesk(deps) {
2348
2384
  gen: null,
2349
2385
  // a sharper idea asked for, made in the background like any other
2350
2386
  pendingSharper: null,
2351
- topping: false,
2352
2387
  failures: 0,
2353
2388
  backoffUntil: 0,
2354
2389
  retry: null,
@@ -2490,7 +2525,7 @@ function createBetDesk(deps) {
2490
2525
  // still because the guard holds a step for the owner's yes: that is a yes card, not a halt
2491
2526
  const held = job.stalled ? require('./yes-lock').askedYesLocks(lockDir, mission.id) : [];
2492
2527
  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}` });
2528
+ 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
2529
  continue;
2495
2530
  }
2496
2531
  // finished with nothing failed: the roster's reviewer looks before it counts as done
@@ -2515,15 +2550,24 @@ function createBetDesk(deps) {
2515
2550
  });
2516
2551
  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
2552
  } else if (job.kind === 'working') {
2518
- working.push({ ...job, owner: currentMember(row.id, job.owner, handoffs()) });
2553
+ const waiting = handoffWaiting(row.id, handoffs());
2554
+ working.push({ ...job, owner: currentMember(row.id, job.owner, handoffs()), ...(waiting ? { waiting } : {}) });
2519
2555
  // a step the job tried and the guard held is a yes card
2520
2556
  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}` });
2557
+ 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
2558
  }
2523
2559
  } else if (job.kind === 'yes') {
2524
2560
  for (const ask of job.asks) yes.push({ ...job, ask_index: ask.index, ask: ask.text, key: `${job.mission_id}:${ask.index}` });
2525
2561
  }
2526
2562
  }
2563
+ // a run woken by a handoff, held from a command: the card shows on the
2564
+ // job it was handed off from when that job is on the desk, else on its
2565
+ // own; the yes always goes to the run that was held
2566
+ for (const card of require('./yes-lock').handoffYesCards(lockDir)) {
2567
+ const parent = [...working, ...yes].find(j => j.mission_id && j.mission_id === card.parent_mission_id) || null;
2568
+ 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' };
2569
+ 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 || ''}` });
2570
+ }
2527
2571
  const expectOf = new Map(readLedger(deps.ledgerFile, deps.workspaceRoot).map(r => [r.id, r.bet && r.bet.expect]));
2528
2572
  state.jobs = { working, yes: yes.map(y => ({ ...y, expect: expectOf.get(y.bet_id) || null })), at: now() };
2529
2573
  retryFailed();
@@ -2793,8 +2837,9 @@ function createBetDesk(deps) {
2793
2837
  return { ok: true, ...view() };
2794
2838
  }
2795
2839
 
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.' };
2840
+ // nothing ready and nothing left to ask: one calm line, shown at once; the
2841
+ // page brings the idea in once it is written
2842
+ const WAIT_CARD = { what: 'writing your next idea.' };
2798
2843
  function showWait() {
2799
2844
  state.current = { kind: 'wait', id: `wait:${now()}` };
2800
2845
  state.status = 'ready';
@@ -2884,13 +2929,16 @@ function createBetDesk(deps) {
2884
2929
  // the sharper or reworked idea the owner asked for is the next shown
2885
2930
  if (sharperOf || ownerNote) state.queue.unshift(item);
2886
2931
  else state.queue.push(item);
2932
+ // the calm line stays the card on screen; the page sees the idea in
2933
+ // the queue and asks for it when the owner is not typing there
2887
2934
  return pump();
2888
2935
  });
2889
2936
  return gen;
2890
2937
  }
2891
2938
 
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.
2939
+ // Keeps the queue full: below 3 the next one is written, so showing one
2940
+ // starts the next at once, one generation at a time. A sharper idea the
2941
+ // owner asked for goes first.
2894
2942
  function pump() {
2895
2943
  pumpQuestions();
2896
2944
  pumpSuggestions();
@@ -2902,9 +2950,7 @@ function createBetDesk(deps) {
2902
2950
  const gen = startGeneration(asked);
2903
2951
  return gen && gen.done;
2904
2952
  }
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;
2953
+ if (state.queue.length >= QUEUE_TARGET) return null;
2908
2954
  if (state.failures >= MAX_BACKGROUND_FAILURES) {
2909
2955
  if (now() < state.backoffUntil) return null;
2910
2956
  state.failures = 0;
@@ -2970,7 +3016,7 @@ function createBetDesk(deps) {
2970
3016
  if (!isBet()) return { ok: false, reason: 'gone', message: 'there is no idea on screen to start' };
2971
3017
  const { id, bet } = state.current;
2972
3018
  const pick = pickOwner(bet, deps.workspaceRoot);
2973
- const startArgs = missionStartArgs(bet, pick, id);
3019
+ const startArgs = missionStartArgs(bet, pick, id, deps.workspaceRoot);
2974
3020
  state.status = 'starting';
2975
3021
  const display = args => require('./gm-phone').displayCommand(args);
2976
3022
  let res = await deps.runCli(startArgs);
@@ -3015,10 +3061,13 @@ function createBetDesk(deps) {
3015
3061
  }
3016
3062
 
3017
3063
  // 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 } = {}) {
3064
+ // the writer reads it for every bet after this one. card: the id of the
3065
+ // card the page shows; a page that missed the next card names the old one,
3066
+ // and its answer is saved against nothing (answer ids repeat across questions)
3067
+ function answer({ choice, text, card } = {}) {
3020
3068
  const item = onJob('question');
3021
3069
  if (!item) return { ok: false, reason: 'gone', message: 'there is no question on screen' };
3070
+ 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
3071
  const picked = item.question.choices.find(c => c.id === choice);
3023
3072
  if (!picked) return { ok: false, reason: 'bad_choice', message: 'that is not one of the answers' };
3024
3073
  const q = item.question;
@@ -3062,7 +3111,7 @@ function createBetDesk(deps) {
3062
3111
  const again = { ...row.bet, why_now: `the last run ${why}. try again. ${row.bet.why_now}` };
3063
3112
  const pick = pickOwner(again, deps.workspaceRoot);
3064
3113
  const display = args => require('./gm-phone').displayCommand(args);
3065
- const startArgs = missionStartArgs(again, pick, row.id);
3114
+ const startArgs = missionStartArgs(again, pick, row.id, deps.workspaceRoot);
3066
3115
  let res = await deps.runCli(startArgs);
3067
3116
  log({ card: 'bet', choice: 'send_back', command: display(startArgs), exit_code: res.code, bet_id: row.id });
3068
3117
  const started = parseJsonOut(res.stdout);
@@ -3084,12 +3133,19 @@ function createBetDesk(deps) {
3084
3133
  if (!item) return { ok: false, reason: 'gone', message: 'there is no question on screen' };
3085
3134
  const { job } = item;
3086
3135
  if (job.lock_step) {
3087
- require('./yes-lock').approveYesLock(lockDir, job.mission_id, job.lock_step, null, iso());
3136
+ // the yes releases only the command on this card (its hash), once
3137
+ const approved = require('./yes-lock').approveYesLock(lockDir, job.mission_id, job.lock_step, null, iso(), { hash: job.lock_hash || null });
3138
+ if (job.lock_hash && !approved) {
3139
+ state.seenYes.add(job.key);
3140
+ state.status = 'cleared';
3141
+ pollJobs();
3142
+ return { ok: true, ack: 'that request was already answered.' };
3143
+ }
3088
3144
  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
3145
  const res = await deps.runCli(args);
3090
3146
  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
3147
  state.seenYes.add(job.key);
3092
- record(job.bet_id, { yes_answered: { mission_id: job.mission_id, step: job.lock_step, at: iso() } });
3148
+ if (job.bet_id) record(job.bet_id, { yes_answered: { mission_id: job.mission_id, step: job.lock_step, at: iso() } });
3093
3149
  state.status = 'cleared';
3094
3150
  pollJobs();
3095
3151
  return { ok: true, ack: `${job.owner}: got your yes` };
@@ -3180,7 +3236,7 @@ function createBetDesk(deps) {
3180
3236
  } : null,
3181
3237
  // each option: its title, and one plain line of why, the result it expects
3182
3238
  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 } : {}) })),
3239
+ working: state.jobs.working.map(j => ({ title: j.title, owner: j.owner, ...(j.left ? { left: j.left } : {}), ...(j.waiting ? { waiting: j.waiting } : {}) })),
3184
3240
  // the conversation on this card so far
3185
3241
  thread: cur && state.thread.id === cur.id ? state.thread.turns.slice() : [],
3186
3242
  // the footer count: only members really working
@@ -3251,12 +3307,14 @@ function createBetDesk(deps) {
3251
3307
  // On a question it is the answer; on an idea, a note the writer reworks it
3252
3308
  // with (the rework is the next idea); on a choice or the plain card, the
3253
3309
  // 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 } = {}) {
3310
+ async function say({ text, confirm = false, note = false, card } = {}) {
3255
3311
  const said = String(text || '').trim();
3256
3312
  if (!said) return { ok: false, reason: 'empty', message: 'say something first' };
3257
3313
  if (said.length > SAY_MAX) return { ok: false, reason: 'too_long', message: `keep it under ${SAY_MAX} characters` };
3258
3314
  const cur = state.status === 'ready' ? state.current : null;
3259
3315
  if (!cur) return { ok: false, reason: 'gone', message: 'there is no card on screen' };
3316
+ // words said on a card the page no longer shows act on nothing
3317
+ 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
3318
  const kind = cur.kind || 'bet';
3261
3319
  log({ card: 'bet', choice: 'say', command: null, ran: 'in process', exit_code: 0, kind, chars: said.length });
3262
3320
  const t = threadFor(cur.id);
@@ -3458,6 +3516,7 @@ module.exports = {
3458
3516
  scoreboardPath,
3459
3517
  choiceWhy,
3460
3518
  JOB_POLL_MS,
3519
+ readDeskMissions,
3461
3520
  OWNER_BANNED,
3462
3521
  jobTime,
3463
3522
  generateQuestion,