atris 3.74.9 → 3.74.11

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.
@@ -377,7 +377,7 @@ function normalizeQuestions(payload, problem) {
377
377
  }
378
378
 
379
379
  function fallbackQuestions(problem) {
380
- const subject = (clipLine(problem, 90) || 'this problem').replace(/[?!.]+$/, '');
380
+ const subject = clipLine((String(problem || '').trim() || 'this problem').replace(/[?!.]+$/, ''), 90);
381
381
  return [
382
382
  {
383
383
  question: `What existing code path already owns "${subject}"?`,
@@ -1902,7 +1902,7 @@ async function executeAgentSDKFast(userInput) {
1902
1902
  } catch (error) {
1903
1903
  console.error(`✗ Error: ${error.message}`);
1904
1904
  console.log('');
1905
- console.log('💡 Hosted turns use https://api.atris.ai. For local development, set ATRIS_API_BASE to your own backend URL.');
1905
+ console.log('💡 start your local backend at http://127.0.0.1:8000.');
1906
1906
  process.exit(1);
1907
1907
  }
1908
1908
  }
package/commands/zen.js CHANGED
@@ -58,14 +58,15 @@ function makeOut(calm) {
58
58
  }
59
59
 
60
60
  // Name the three biggest places so a large count reads as "mostly agent
61
- // notes in atris/" rather than an unexplained total.
61
+ // notes in atris/" rather than an unexplained total. A lone fourth place is
62
+ // named too, since "and 1 more place" takes as much room and says less.
62
63
  function teamLine(n, places = []) {
63
64
  if (!n) return 'no unsaved team notes.';
64
65
  const head = n === 1 ? '1 unsaved team note left in place' : `${n} unsaved team notes left in place`;
65
66
  if (!places.length) return `${head}.`;
66
- const named = places.slice(0, 3).map((p) => `${p.place} ${p.count}`);
67
+ const named = places.slice(0, places.length === 4 ? 4 : 3).map((p) => `${p.place} ${p.count}`);
67
68
  const rest = places.length - named.length;
68
- if (rest) named.push(`and ${rest} more ${rest === 1 ? 'place' : 'places'}`);
69
+ if (rest) named.push(`and ${rest} more places`);
69
70
  return `${head}: ${named.join(', ')}.`;
70
71
  }
71
72
 
@@ -1099,7 +1099,7 @@ function sniffedProtectedLane(task) {
1099
1099
  function deniedTagOf(task) {
1100
1100
  const tag = String((task && task.tag) || '').trim().toLowerCase();
1101
1101
  return [...DENIED_TAGS].find((d) =>
1102
- tag === d || tag.split(/[^a-z0-9]+/).some((w) => w === d || w.replace(/s$/, '') === d)) || null;
1102
+ tag === d || tag.split(/[^a-z0-9]+/).some((w) => w === d || w.replace(/s$/, '') === d || w === d.replace(/s$/, ''))) || null;
1103
1103
  }
1104
1104
 
1105
1105
  function protectedLaneOf(task) {
@@ -1197,8 +1197,7 @@ function evaluateAutoAccept(task, options = {}) {
1197
1197
  // exact-match cousins: match denied lanes on whole words with a plural
1198
1198
  // strip, so a tag variant never slips money/deploy work past the human.
1199
1199
  const tag = String(task.tag || '').trim().toLowerCase();
1200
- const deniedTag = [...DENIED_TAGS].find((d) =>
1201
- tag === d || tag.split(/[^a-z0-9]+/).some((w) => w === d || w.replace(/s$/, '') === d));
1200
+ const deniedTag = deniedTagOf(task);
1202
1201
  if (deniedTag) return { eligible: false, ref, reason: `denied_tag_${deniedTag}` };
1203
1202
 
1204
1203
  // A protected lane declared in prose is not a protection, it is a note.
package/lib/autoland.js CHANGED
@@ -187,13 +187,14 @@ function cronMarker(root) {
187
187
  }
188
188
 
189
189
  function buildCronLine(root) {
190
+ const quote = (value) => `'${value.replace(/'/g, "'\\''")}'`;
190
191
  const node = process.execPath;
191
192
  const bin = path.join(root, 'bin', 'atris.js');
192
193
  const cliBin = fs.existsSync(bin) ? bin : 'atris';
193
194
  const logDir = path.join(stateHome(), '.atris', 'overnight');
194
195
  const logFile = path.join(logDir, `autoland-${path.basename(root)}.log`);
195
- const invoke = cliBin.endsWith('.js') ? `${node} ${cliBin}` : cliBin;
196
- return `14 * * * * cd ${root} && ${invoke} autoland tick >> ${logFile} 2>&1 # ${cronMarker(root)}`;
196
+ const invoke = cliBin.endsWith('.js') ? `${quote(node)} ${quote(cliBin)}` : cliBin;
197
+ return `14 * * * * cd ${quote(root)} && ${invoke} autoland tick >> ${quote(logFile)} 2>&1 # ${cronMarker(root)}`;
197
198
  }
198
199
 
199
200
  function readCrontab() {
@@ -499,7 +500,7 @@ function dejargon(line, options = {}) {
499
500
  .replace(/--([a-z][a-z-]*)/g, (_match, flag) => flag.replace(/-/g, ' '))
500
501
  .replace(/\b[a-z0-9]+(?:_[a-z0-9]+)+\b/gi, match => match.replace(/_/g, ' '))
501
502
  .replace(/\b[a-z]+(?:[A-Z][A-Za-z0-9]*)+\b/g, match => match.replace(/([a-z0-9])([A-Z])/g, '$1 $2'));
502
- if (options.preserveTickets !== true) value = value.replace(/\bCLI-\d+\b/g, '');
503
+ if (options.preserveTickets !== true) value = value.replace(/\(\s*CLI-\d+\s*\)|\bCLI-\d+\b/g, '');
503
504
  return value.replace(/\s+/g, ' ').trim();
504
505
  }
505
506
 
@@ -50,6 +50,14 @@ function readBrief(root, campaignId) {
50
50
  }
51
51
  }
52
52
 
53
+ function objectiveWithBrief(objective, brief) {
54
+ if (!brief) return objective;
55
+ // Mission objectives also become titles and log lines, so keep them on one line.
56
+ const line = objective.replace(/\s+/g, ' ').trim();
57
+ const briefLine = brief.replace(/\s+/g, ' ').trim();
58
+ return line.includes(briefLine) ? line : `${line} ${briefLine}`;
59
+ }
60
+
53
61
  function rankCampaignMembers(view, rsiSummary = null, events = null) {
54
62
  const seats = Array.isArray(view && view.seats) ? view.seats.map((s) => s.member).filter(Boolean) : [];
55
63
  const candidates = [...new Set([view && view.owner, ...seats].filter(Boolean))];
@@ -180,7 +188,7 @@ async function askOwner(root, view, prompt) {
180
188
 
181
189
  // Run one atris command and resolve with its exit code and output.
182
190
  // A live step an engine prints while a mission runs: "<member> <hh:mm> <what it is doing>".
183
- const ACTIVITY_LINE = /^\s*[a-z0-9][a-z0-9-]*\s+\d{1,2}:\d{2}\s{2,}\S/i;
191
+ const ACTIVITY_LINE = /^\s*[a-z0-9][a-z0-9._-]*\s+\d{1,2}:\d{2}\s{2,}\S/i;
184
192
 
185
193
  function runCli(args, { cwd, env = process.env, onLine = null } = {}) {
186
194
  return new Promise((resolve) => {
@@ -258,7 +266,7 @@ function makeLoop(root, options = {}, deps = {}) {
258
266
  proposals.forEach((proposal, index) => {
259
267
  const missionId = proposalId(proposal.objective);
260
268
  const key = claimKey(proposal.objective);
261
- if (brief && !proposal.objective.includes(brief)) proposal.objective = `${proposal.objective}\n\n${brief}`;
269
+ proposal.objective = objectiveWithBrief(proposal.objective, brief);
262
270
  append(id, 'mission_proposed', { mission_id: missionId, member: proposal.member, objective: proposal.objective, verify: proposal.verify, estimate_usd: proposal.estimate_usd || undefined }, proposal.member);
263
271
  append(id, 'mission_claimed', { mission_id: missionId, claim_key: key }, proposal.member);
264
272
  if (index === 0) append(id, 'step', { mission_id: missionId, ...plan }, current.owner);
@@ -282,7 +290,7 @@ function makeLoop(root, options = {}, deps = {}) {
282
290
  const current = view(id);
283
291
  const reserve = work.estimate_usd || defaultReserve(current);
284
292
  const brief = (deps.brief ? deps.brief(id) : readBrief(root, id)).trim();
285
- const objective = brief && !work.objective.includes(brief) ? `${work.objective}\n\n${brief}` : work.objective;
293
+ const objective = objectiveWithBrief(work.objective, brief);
286
294
  const started = await cli([
287
295
  'mission', 'start', objective, '--mission-id', work.mission_id, '--owner', work.member,
288
296
  '--runner', runner, '--verify', work.verify || current.judge, '--campaign', id, '--reserve', String(reserve),
@@ -299,7 +307,7 @@ function makeLoop(root, options = {}, deps = {}) {
299
307
  append(id, 'mission_vetoed', { mission_id: work.mission_id, reason: why.slice(0, 200) }, current.owner);
300
308
  return { mission_id: work.mission_id, started: false };
301
309
  }
302
- log(` ${work.mission_id} started for ${work.member}: ${work.objective}`);
310
+ log(` ${work.mission_id} started for ${work.member}: ${objective}`);
303
311
  // The engine's live steps ("researcher 06:47 editing stable.mjs") pass through to
304
312
  // the campaign's own output, so a screen watching the run can show the work.
305
313
  const stepsBefore = (view(id).missions.find((m) => m.id === work.mission_id) || {}).steps || 0;
package/lib/card.js CHANGED
@@ -114,7 +114,7 @@ function buildCard(spec = {}, opts = {}) {
114
114
  </style></head>
115
115
  <body><div class="card">${main}${foot}</div></body></html>`;
116
116
 
117
- return { html, width: size.w, height: size.h, kind, theme: spec.theme || 'atris', size: spec.size || 'og' };
117
+ return { html, width: size.w, height: size.h, kind, theme: themes[spec.theme] ? spec.theme : 'atris', size: spec.size || 'og' };
118
118
  }
119
119
 
120
120
  module.exports = { buildCard, SIZES, KINDS };
@@ -310,7 +310,7 @@ function scanChatLogs(options = {}) {
310
310
  scope: options.global === true ? 'global' : 'workspace',
311
311
  window_hours: maxAgeHours,
312
312
  sources: {
313
- ax_dirs: axLogDirs(cwd),
313
+ ax_dirs: axLogDirs(cwd, { global: options.global === true }),
314
314
  cursor_roots: cursorTranscriptRoots(cwd),
315
315
  },
316
316
  summary: {
@@ -1058,6 +1058,7 @@ function speakFirstMinute({
1058
1058
  log(JSON.stringify(freshMinuteJson(folderName(root), visible, { root }), null, 2));
1059
1059
  return 2;
1060
1060
  }
1061
+ if (asJson) return speakKeepWorkingMinute({ root, asJson, log });
1061
1062
  const screen = buildFirstMinute({
1062
1063
  root,
1063
1064
  fresh: isFresh,
package/lib/gm-bet.js CHANGED
@@ -2108,7 +2108,7 @@ const WRITER_ANGLES = [
2108
2108
  const SWARM_SAY = {
2109
2109
  drafting: n => (n === 1 ? 'drafting an idea' : `drafting ${COUNT_WORDS[n] || n} ideas at once`),
2110
2110
  checking: k => `the ${ORDINALS[k - 1] || 'next'} draft is in. checking it`,
2111
- redraft: 'one draft missed a rule. redrafting it',
2111
+ redraft: 'one draft did not fit. redrafting it',
2112
2112
  shorten: 'one draft ran long. shortening it',
2113
2113
  heldUp: 'found one that holds up',
2114
2114
  notHeld: 'that one did not hold up. still working',
@@ -3289,10 +3289,10 @@ function createBetDesk(deps) {
3289
3289
  // the number for the card on screen, saved beside the ledger
3290
3290
  function report({ value } = {}) {
3291
3291
  const item = onJob('report');
3292
- if (!item) return { ok: false, reason: 'gone', message: 'there is no number asked for on screen' };
3292
+ if (!item) return { ok: false, reason: 'gone', message: 'there is no number asked for on screen.' };
3293
3293
  const raw = String(value == null ? '' : value).replace(/,/g, '').trim();
3294
3294
  const v = Number(raw);
3295
- if (!raw || !Number.isFinite(v)) return { ok: false, reason: 'bad_number', message: 'that is not a number' };
3295
+ if (!raw || !Number.isFinite(v)) return { ok: false, reason: 'bad_number', message: 'that is not a number.' };
3296
3296
  appendLedger(scoreboardFile, { workspace: deps.workspaceRoot, kind: 'report', value: v, at: iso() });
3297
3297
  log({ card: 'bet', choice: 'report', command: null, ran: 'in process', exit_code: 0 });
3298
3298
  state.status = 'cleared';
@@ -3387,7 +3387,7 @@ function createBetDesk(deps) {
3387
3387
  function pick({ id } = {}) {
3388
3388
  const cur = onJob('choice');
3389
3389
  const item = cur && cur.items.find(i => i.id === id);
3390
- if (!item) return { ok: false, reason: 'gone', message: 'there is nothing to pick on screen' };
3390
+ if (!item) return { ok: false, reason: 'gone', message: 'there is nothing to pick on screen.' };
3391
3391
  for (const other of cur.items) if (other !== item) record(other.id, { status: 'passed', note: 'not picked' });
3392
3392
  log({ card: 'bet', choice: 'pick', command: null, ran: 'in process', exit_code: 0, bet_id: item.id });
3393
3393
  state.current = item;
@@ -3732,15 +3732,16 @@ function createBetDesk(deps) {
3732
3732
  const isBet = () => state.status === 'ready' && state.current && (!state.current.kind || state.current.kind === 'bet');
3733
3733
 
3734
3734
  function skip() {
3735
- if (!isBet()) return { ok: false, reason: 'gone', message: 'there is no idea on screen to skip' };
3735
+ if (!isBet()) return { ok: false, reason: 'gone', message: 'there is no idea on screen to skip.' };
3736
3736
  record(state.current.id, { status: 'skipped' });
3737
3737
  log({ card: 'bet', choice: 'skip', command: null, ran: 'in process', exit_code: 0, bet_id: state.current.id });
3738
3738
  state.status = 'skipped';
3739
- return { ok: true, ack: 'not now. all clear.' };
3739
+ // the page says what to do next under the orb, so this only says what happened
3740
+ return { ok: true, ack: 'set aside.' };
3740
3741
  }
3741
3742
 
3742
3743
  async function doIt() {
3743
- if (!isBet()) return { ok: false, reason: 'gone', message: 'there is no idea on screen to start' };
3744
+ if (!isBet()) return { ok: false, reason: 'gone', message: 'there is no idea on screen to start.' };
3744
3745
  const { id, bet } = state.current;
3745
3746
  const pick = pickOwner(bet, deps.workspaceRoot);
3746
3747
  const startArgs = missionStartArgs(bet, pick, id, deps.workspaceRoot);
@@ -3766,7 +3767,7 @@ function createBetDesk(deps) {
3766
3767
  let line;
3767
3768
  // what the owner reads; the command and exit code stay in the log
3768
3769
  if (res.code === 0 && ran.action === 'mission_run_detached') line = `${owner} has it and is starting now.`;
3769
- else if (ran.reason === 'human_asks_open') line = `${owner} has it, and is waiting on your yes before starting.`;
3770
+ else if (ran.reason === 'human_asks_open') line = `${owner} has it, and is waiting for your yes before starting.`;
3770
3771
  else line = `${owner} has it, but has not started yet. it can be started from the mac.`;
3771
3772
  state.status = 'started';
3772
3773
  state.mission = { id: missionId, owner, line, commands: [display(startArgs), display(runArgs)] };
@@ -3780,7 +3781,7 @@ function createBetDesk(deps) {
3780
3781
  // nice: a finished job was seen; nothing else happens
3781
3782
  function nice() {
3782
3783
  const item = onJob('done');
3783
- if (!item) return { ok: false, reason: 'gone', message: 'there is no finished job on screen' };
3784
+ if (!item) return { ok: false, reason: 'gone', message: 'there is no finished job on screen.' };
3784
3785
  record(item.job.bet_id, { done_seen_at: iso() });
3785
3786
  log({ card: 'bet', choice: 'nice', command: null, ran: 'in process', exit_code: 0, bet_id: item.job.bet_id });
3786
3787
  state.status = 'cleared';
@@ -3793,18 +3794,18 @@ function createBetDesk(deps) {
3793
3794
  // and its answer is saved against nothing (answer ids repeat across questions)
3794
3795
  function answer({ choice, text, card } = {}) {
3795
3796
  const item = onJob('question');
3796
- if (!item) return { ok: false, reason: 'gone', message: 'there is no question on screen' };
3797
- 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' };
3797
+ if (!item) return { ok: false, reason: 'gone', message: 'there is no question on screen.' };
3798
+ if (card !== undefined && card !== item.id) return { ok: false, reason: 'gone', message: 'that card changed, so nothing was saved. here is the one on screen now.' };
3798
3799
  const picked = item.question.choices.find(c => c.id === choice);
3799
- if (!picked) return { ok: false, reason: 'bad_choice', message: 'that is not one of the answers' };
3800
+ if (!picked) return { ok: false, reason: 'bad_choice', message: 'that is not one of the answers.' };
3800
3801
  const q = item.question;
3801
3802
  if (q.key === 'scoreboard') {
3802
3803
  let name;
3803
3804
  let up;
3804
3805
  if (picked.other) {
3805
3806
  name = String(text || '').trim();
3806
- if (!name) return { ok: false, reason: 'empty', message: 'say the number in a few words' };
3807
- if (name.length > 60) return { ok: false, reason: 'too_long', message: 'keep it under 60 characters' };
3807
+ if (!name) return { ok: false, reason: 'empty', message: 'name the number in a few words.' };
3808
+ if (name.length > 60) return { ok: false, reason: 'too_long', message: 'keep it under 60 characters.' };
3808
3809
  up = !LOWER_IS_BETTER.test(name);
3809
3810
  } else {
3810
3811
  const pick = q.numbers[Number(picked.id.slice(1)) - 1];
@@ -3899,7 +3900,7 @@ function createBetDesk(deps) {
3899
3900
  if (job.bet_id) record(job.bet_id, { yes_answered: { mission_id: job.mission_id, step: job.lock_step, at: iso() } });
3900
3901
  clear();
3901
3902
  pollJobs();
3902
- return { ok: true, ack: `${job.owner}: got your yes` };
3903
+ return { ok: true, ack: `told ${job.owner} yes.` };
3903
3904
  }
3904
3905
  const listed = await deps.runCli(['decide', '--json']);
3905
3906
  const open = (parseJsonOut(listed.stdout) || {}).decisions || [];
@@ -3913,13 +3914,13 @@ function createBetDesk(deps) {
3913
3914
  const args = ['decide', String(match.number), 'yes', '--json'];
3914
3915
  const res = await deps.runCli(args);
3915
3916
  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 });
3916
- if (res.code !== 0) return { ok: false, reason: 'answer_failed', message: 'your yes did not go through; try again' };
3917
+ if (res.code !== 0) return { ok: false, reason: 'answer_failed', message: 'your yes did not go through. try again.' };
3917
3918
  state.seenYes.add(job.key);
3918
3919
  state.answeredYes.add(job.key);
3919
3920
  if (job.bet_id) record(job.bet_id, { yes_answered: { mission_id: job.mission_id, ask_index: job.ask_index, at: iso() } });
3920
3921
  clear();
3921
3922
  pollJobs();
3922
- return { ok: true, ack: `${job.owner}: got your yes` };
3923
+ return { ok: true, ack: `told ${job.owner} yes.` };
3923
3924
  }
3924
3925
 
3925
3926
  // no: the one request on the card is told no, and the job goes on without
@@ -3955,7 +3956,7 @@ function createBetDesk(deps) {
3955
3956
  const args = ['decide', String(match.number), 'no', '--json'];
3956
3957
  const res = await deps.runCli(args);
3957
3958
  log({ card: 'bet', choice: 'no', command: display(args), exit_code: res.code, bet_id: job.bet_id, mission_id: job.mission_id });
3958
- if (res.code !== 0) return { ok: false, reason: 'answer_failed', message: 'your no did not go through; try again' };
3959
+ if (res.code !== 0) return { ok: false, reason: 'answer_failed', message: 'your no did not go through. try again.' };
3959
3960
  if (job.bet_id) record(job.bet_id, { no_answered: { mission_id: job.mission_id, ask_index: job.ask_index, at: iso() } });
3960
3961
  return done(told);
3961
3962
  }
@@ -3966,7 +3967,7 @@ function createBetDesk(deps) {
3966
3967
  const j = item.job;
3967
3968
  const expect = item.kind === 'yes' ? j.expect : j.bet && j.bet.expect;
3968
3969
  const why = expect ? ` ${sentence(expect)}` : '';
3969
- if (item.kind === 'yes') return { what: plainAsk(j.ask), why: `${j.owner} is waiting on your yes for this.${why}` };
3970
+ if (item.kind === 'yes') return { what: plainAsk(j.ask), why: `${j.owner} is waiting for your yes.${why}` };
3970
3971
  return { what: j.title, why: `this is done.${why}` };
3971
3972
  }
3972
3973
 
@@ -4074,7 +4075,7 @@ function createBetDesk(deps) {
4074
4075
  function cardForReader(v) {
4075
4076
  if (v.kind === 'question') return { kind: 'question', title: v.question.ask, detail: v.question.line, buttons: v.question.choices.map(c => c.label) };
4076
4077
  if (v.kind === 'bet' && v.bet.concern) return { kind: 'idea', title: v.bet.title, detail: [v.bet.why_now, v.bet.expect, v.bet.concern].join(' '), buttons: ['go with it', 'sharpen it', 'something else'] };
4077
- 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'] };
4078
+ if (v.kind === 'bet') return { kind: 'idea', title: v.bet.title, detail: [v.bet.why_now, v.bet.expect].join(' '), buttons: ['do it', 'sharpen it', 'not now'] };
4078
4079
  if (v.kind === 'choice') return { kind: 'choice', title: 'which one next?', detail: v.choice.options.map(o => o.title).join(' / '), buttons: [] };
4079
4080
  if (v.kind === 'report') return { kind: 'number', title: v.report.what, detail: v.report.why, buttons: ['save', 'not now'] };
4080
4081
  return { kind: v.kind, title: v.job ? v.job.what : '', detail: v.job ? v.job.why : '', buttons: [] };
@@ -4108,12 +4109,12 @@ function createBetDesk(deps) {
4108
4109
  // next idea fits it; on a yes or a finished job, a word to that member.
4109
4110
  async function say({ text, confirm = false, note = false, card } = {}) {
4110
4111
  const said = String(text || '').trim();
4111
- if (!said) return { ok: false, reason: 'empty', message: 'say something first' };
4112
- if (said.length > SAY_MAX) return { ok: false, reason: 'too_long', message: `keep it under ${SAY_MAX} characters` };
4112
+ if (!said) return { ok: false, reason: 'empty', message: 'say something first.' };
4113
+ if (said.length > SAY_MAX) return { ok: false, reason: 'too_long', message: `keep it under ${SAY_MAX} characters.` };
4113
4114
  let cur = state.status === 'ready' ? state.current : null;
4114
- if (!cur) return { ok: false, reason: 'gone', message: 'there is no card on screen' };
4115
+ if (!cur) return { ok: false, reason: 'gone', message: 'there is no card on screen.' };
4115
4116
  // words said on a card the page no longer shows act on nothing
4116
- 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' };
4117
+ if (card !== undefined && card !== cur.id) return { ok: false, reason: 'gone', message: 'that card changed, so nothing was done. here is the one on screen now.' };
4117
4118
  // on the calm line there is nothing to answer: the words are a note for the idea being written, read by no model
4118
4119
  if (cur.kind === 'wait') return noteOnWait(cur, said);
4119
4120
  let kind = cur.kind || 'bet';
@@ -4128,7 +4129,7 @@ function createBetDesk(deps) {
4128
4129
  // said on the try-again card that took its place, never dropped
4129
4130
  const replaced = kind === 'wait' && state.status === 'ready' && state.current && state.current.kind === 'missed' && state.current.after === cur.id;
4130
4131
  if (replaced) { cur = state.current; kind = 'missed'; }
4131
- if (state.current !== cur || state.status !== 'ready') return { ok: false, reason: 'gone', message: 'that card changed, so nothing was done' };
4132
+ if (state.current !== cur || state.status !== 'ready') return { ok: false, reason: 'gone', message: 'that card changed, so nothing was done. here is the one on screen now.' };
4132
4133
  if (turn.stay) return turn.stay;
4133
4134
  read = turn.act;
4134
4135
  }
@@ -4208,10 +4209,10 @@ function createBetDesk(deps) {
4208
4209
  // answered from what the table knows; an instruction steers them.
4209
4210
  async function talk({ member, text, confirm = false, note = false } = {}) {
4210
4211
  const name = String(member || '');
4211
- if (!(team || []).some(m => m.name === name)) return { ok: false, reason: 'gone', message: 'there is no such member on the team' };
4212
+ if (!(team || []).some(m => m.name === name)) return { ok: false, reason: 'gone', message: 'there is no such member on the team.' };
4212
4213
  const said = String(text || '').trim();
4213
- if (!said) return { ok: false, reason: 'empty', message: 'say something first' };
4214
- if (said.length > SAY_MAX) return { ok: false, reason: 'too_long', message: `keep it under ${SAY_MAX} characters` };
4214
+ if (!said) return { ok: false, reason: 'empty', message: 'say something first.' };
4215
+ if (said.length > SAY_MAX) return { ok: false, reason: 'too_long', message: `keep it under ${SAY_MAX} characters.` };
4215
4216
  const t = threadFor(`member:${name}`);
4216
4217
  if (!confirm && !note) {
4217
4218
  const row = statusTable().rows.find(r => r.member === name);
@@ -4247,13 +4248,13 @@ function createBetDesk(deps) {
4247
4248
  if (!OPEN_MISSION.has(status)) return null;
4248
4249
  const asks = (Array.isArray(m.human_asks) ? m.human_asks : []).some(a => a && !(typeof a === 'object' && a.answered_at))
4249
4250
  || require('./yes-lock').askedYesLocks(lockDir, m.id).length > 0;
4250
- if (asks) return { moved, row: { on, status: 'waiting for your yes', time: `waiting ${spanWords(age)}` } };
4251
+ if (asks) return { moved, row: { on, status: 'waiting for your yes', time: `asked ${spanWords(age)} ago` } };
4251
4252
  if (driverAlive(m) || age <= MOVING_MS) {
4252
4253
  const wall = Number(m.max_wall_seconds) || Number(m.budget_contract && m.budget_contract.requested_seconds) || 0;
4253
4254
  const left = wall ? created + wall * 1000 - now() : 0;
4254
4255
  return { moved, row: { on, status: 'working', time: left > 0 ? leftWords(left) : `at it for ${spanWords(now() - created)}` } };
4255
4256
  }
4256
- if (!m.last_tick_at) return { moved, row: { on, status: 'waiting to start', time: `waiting ${spanWords(now() - created)}` } };
4257
+ if (!m.last_tick_at) return { moved, row: { on, status: 'waiting to start', time: `added ${spanWords(now() - created)} ago` } };
4257
4258
  return { moved, row: { on, status: 'stopped', time: `stopped ${spanWords(age)} ago` } };
4258
4259
  }
4259
4260
 
@@ -4281,7 +4282,7 @@ function createBetDesk(deps) {
4281
4282
  const at = Date.parse((hnd && (hnd.closed_at || hnd.created_at)) || '') || 0;
4282
4283
  if (!hnd || !hnd.to || !at || now() - at > RECENT_MS) continue;
4283
4284
  const on = clip(String(hnd.summary || '').replace(/^\[[^\]]*\]\s*/, ''), 100);
4284
- if (hnd.status === 'open') put(hnd.to, { on, status: 'waiting to start', time: `waiting ${spanWords(now() - at)}` }, at);
4285
+ if (hnd.status === 'open') put(hnd.to, { on, status: 'waiting to start', time: `added ${spanWords(now() - at)} ago` }, at);
4285
4286
  else put(hnd.to, { on, status: 'idle', time: `free for ${spanWords(now() - at)}` }, at);
4286
4287
  }
4287
4288
  const rows = [...best.values()]
@@ -4336,7 +4337,7 @@ function createBetDesk(deps) {
4336
4337
  // the owner's tap is the approval, so there is no yes card. scope member
4337
4338
  // gives only this member the model, scope job swaps the lead of its job.
4338
4339
  function onTeam(name) { return (team || []).some(m => m.name === name); }
4339
- const NOT_ON_TEAM = { ok: false, reason: 'gone', message: 'there is no such member on the team' };
4340
+ const NOT_ON_TEAM = { ok: false, reason: 'gone', message: 'there is no such member on the team.' };
4340
4341
  function engineCard({ member } = {}) {
4341
4342
  const name = String(member || '');
4342
4343
  if (!onTeam(name)) return NOT_ON_TEAM;
@@ -4414,7 +4415,7 @@ function createBetDesk(deps) {
4414
4415
  const at = Date.parse((hnd && hnd.created_at) || '') || 0;
4415
4416
  if (!hnd || hnd.status !== 'open' || !names.has(hnd.to) || !at || now() - at > RECENT_MS) continue;
4416
4417
  const on = clip(String(hnd.summary || '').replace(/^\[[^\]]*\]\s*/, ''), 100);
4417
- 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 });
4418
+ jobs.push({ id: `handoff:${hnd.id || at}`, mission: null, member: hnd.to, row: { on, status: 'waiting to start', time: `added ${spanWords(now() - at)} ago` }, moved: at });
4418
4419
  }
4419
4420
  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));
4420
4421
  // a yes skipped with not now waits at the back of the ones that need the owner
@@ -4459,10 +4460,10 @@ function createBetDesk(deps) {
4459
4460
 
4460
4461
  async function steer({ member, text, confirm = false, note = false } = {}) {
4461
4462
  const name = String(member || '');
4462
- if (!(team || []).some(m => m.name === name)) return { ok: false, reason: 'gone', message: 'there is no such member on the team' };
4463
+ if (!(team || []).some(m => m.name === name)) return { ok: false, reason: 'gone', message: 'there is no such member on the team.' };
4463
4464
  const said = String(text || '').trim();
4464
- if (!said) return { ok: false, reason: 'empty', message: 'say something first' };
4465
- if (said.length > SAY_MAX) return { ok: false, reason: 'too_long', message: `keep it under ${SAY_MAX} characters` };
4465
+ if (!said) return { ok: false, reason: 'empty', message: 'say something first.' };
4466
+ if (said.length > SAY_MAX) return { ok: false, reason: 'too_long', message: `keep it under ${SAY_MAX} characters.` };
4466
4467
  if (note) {
4467
4468
  steeringNote(name, said);
4468
4469
  log({ card: 'bet', choice: 'steer_note', command: null, ran: 'in process', exit_code: 0, member: name, chars: said.length });
@@ -4481,7 +4482,7 @@ function createBetDesk(deps) {
4481
4482
  if (out.action === 'member_run_not_started' && out.reason) return { ok: false, reason: 'not_started', message: `${name} did not start: ${String(out.reason).replace(/[.\s]+$/, '')}.` };
4482
4483
  return { ok: false, reason: 'not_started', message: `${name} did not start. try again in a minute.` };
4483
4484
  }
4484
- if (out.action === 'member_not_running' || res.code === 3) return { ok: false, reason: 'needs_confirm', message: `tap again to start ${name} on this` };
4485
+ if (out.action === 'member_not_running' || res.code === 3) return { ok: false, reason: 'needs_confirm', message: `tap again to start ${name} on this.` };
4485
4486
  return { ok: false, reason: 'steer_failed', message: 'it did not reach them. try again in a minute.' };
4486
4487
  }
4487
4488
 
@@ -4566,8 +4567,9 @@ function createBetDesk(deps) {
4566
4567
  const at = Math.min(l.at || 0, cards.length - 1);
4567
4568
  const c = { ...cards[at], said: (l.said && l.said[cards[at].job_id]) || null };
4568
4569
  const r = c.rows[0];
4569
- // the deck card is about the job: its row without the rating and the model, which live on the team table
4570
- const columns = DECK_COLUMNS.filter(k => k !== 'ovr' && k !== 'engine');
4570
+ // the deck card is about the job: its row without the rating and the model, which live on the team table,
4571
+ // and without done, which says "not yet" on every card because the deck holds only open jobs
4572
+ const columns = DECK_COLUMNS.filter(k => k !== 'ovr' && k !== 'engine' && k !== 'done');
4571
4573
  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 } });
4572
4574
  }
4573
4575
  if (l.kind === 'swap') {
@@ -4655,7 +4657,7 @@ function createBetDesk(deps) {
4655
4657
  const base = stays && under && under.kind === 'wait' && state.current && state.current.kind === 'missed' ? (underCard() || under) : under;
4656
4658
  const back = replied(out, base.id, text);
4657
4659
  if (back) return back;
4658
- if (!out.ok) return refuse(out.reason || 'gone', out.message || 'that did not send. try again');
4660
+ if (!out.ok) return refuse(out.reason || 'gone', out.message || 'that did not send. try again.');
4659
4661
  state.reply = null;
4660
4662
  next();
4661
4663
  return done200({ ack: out.ack });
@@ -4669,7 +4671,7 @@ function createBetDesk(deps) {
4669
4671
  const out = await talk({ member: name, text, ...more });
4670
4672
  const back = replied(out, under.id, text);
4671
4673
  if (back) return back;
4672
- if (!out.ok) return refuse(out.reason || 'gone', out.message || 'that did not send. try again');
4674
+ if (!out.ok) return refuse(out.reason || 'gone', out.message || 'that did not send. try again.');
4673
4675
  state.reply = null;
4674
4676
  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'); }
4675
4677
  return done200();
@@ -4711,18 +4713,18 @@ function createBetDesk(deps) {
4711
4713
  return done200(out.busy ? { busy: true } : {});
4712
4714
  }
4713
4715
  if (body.open === 'team' || body.open === 'deck') {
4714
- if (stale) return refuse('gone', 'that card changed. here is the one on screen now');
4716
+ if (stale) return refuse('gone', 'that card changed. here is the one on screen now.');
4715
4717
  open(body.open);
4716
4718
  return done200();
4717
4719
  }
4718
4720
  // an option or words always name the card they were for
4719
- if (!shown || !cardId || stale) return refuse('gone', 'that card changed, so nothing was done. here is the one on screen now');
4721
+ if (!shown || !cardId || stale) return refuse('gone', 'that card changed, so nothing was done. here is the one on screen now.');
4720
4722
  const optionId = body.option_id == null ? null : String(body.option_id);
4721
4723
  const opt = optionId ? shown.options.find(o => o.id === optionId) : null;
4722
- if (optionId && !opt) return refuse('bad_option', 'that is not on this card');
4723
- if (!opt && !shown.input) return refuse('bad_option', 'this card has no box to type in');
4724
+ if (optionId && !opt) return refuse('bad_option', 'that is not on this card.');
4725
+ if (!opt && !shown.input) return refuse('bad_option', 'this card has no box to type in.');
4724
4726
  const typed = String(body.text == null ? '' : body.text).trim();
4725
- if ((!opt || opt.type) && !typed) return refuse('empty', 'say something first');
4727
+ if ((!opt || opt.type) && !typed) return refuse('empty', 'say something first.');
4726
4728
  if (opt && opt.confirm && body.confirm !== true) return { status: 409, body: { ...envelope(), ok: false, reason: 'needs_confirm', message: opt.confirm } };
4727
4729
  const action = opt ? opt.action : shown.input.action;
4728
4730
  const b = (opt && opt.binds) || {};
@@ -4827,10 +4829,11 @@ function createBetDesk(deps) {
4827
4829
  case 'swap_to': {
4828
4830
  const out = swapEngine({ member: state.layer.name, engine: b.engine, model: b.model, scope: b.scope });
4829
4831
  if (!out.ok) return refuse(out.reason, out.message);
4832
+ // the team comes back with the line on it, so nothing says it twice
4830
4833
  open('team', { line: out.ack });
4831
- return done200({ ack: out.ack });
4834
+ return done200();
4832
4835
  }
4833
- default: return refuse('bad_option', 'that does nothing here');
4836
+ default: return refuse('bad_option', 'that does nothing here.');
4834
4837
  }
4835
4838
  }
4836
4839