atris 3.72.4 → 3.73.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
@@ -65,6 +65,7 @@ const METRIC_SOURCES = ['npm', 'install', 'download', 'git', 'master', 'landed',
65
65
  // purpose: filler jobs inflate one, and closing jobs empties the other.
66
66
  const METRICS = {
67
67
  installs: { what: 'npm installs in the 7 days that end on the check date', now: 'installs last week', up: true },
68
+ scoreboard: { what: "the owner's own number for this project, as he reports it on the phone; read from his first report on or after the check date", now: 'the last reported value', up: true },
68
69
  queue_landed: { what: 'jobs waiting on review today with real work behind them (a commit on a branch or on master that touches their own check) that land, accepted or merged, by the check date; placeholders do not count, and closing, dropping, or parking one counts zero', now: 'jobs landed from today\'s review queue', up: true },
69
70
  };
70
71
 
@@ -127,6 +128,345 @@ function readLedger(file, workspaceRoot) {
127
128
  return [...byId.values()].sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)));
128
129
  }
129
130
 
131
+ // ---- the owner's scoreboard: the one number that says this project is going well ----
132
+
133
+ // Beside the ledger, one row per change: a pick (its name, which way is good,
134
+ // and how it is read) or a report of its value. The newest pick wins; its
135
+ // reports are the ones after it.
136
+ function scoreboardPath(stateDir = defaultStateDir()) {
137
+ return path.join(stateDir, 'gm-scoreboard.jsonl');
138
+ }
139
+
140
+ function readScoreboard(file, workspaceRoot) {
141
+ let text = '';
142
+ try {
143
+ text = fs.readFileSync(file, 'utf8');
144
+ } catch {
145
+ return null;
146
+ }
147
+ let board = null;
148
+ for (const line of text.split('\n')) {
149
+ if (!line.trim()) continue;
150
+ let row;
151
+ try { row = JSON.parse(line); } catch { continue; }
152
+ if (!row || row.workspace !== workspaceRoot) continue;
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
+ else if (row.kind === 'report' && board && Number.isFinite(row.value)) board.reports.push({ value: row.value, at: row.at });
155
+ }
156
+ return board;
157
+ }
158
+
159
+ // the best of the last 8 reports, once there are 3: the bar a goal must clear
160
+ const SCOREBOARD_REPORTS = 8;
161
+ const SCOREBOARD_MIN_REPORTS = 3;
162
+
163
+ // ---- the owner's focus: answers to the question cards ----------------------------
164
+
165
+ // Lives beside the ledger, one row per answer; the newest answer per question wins.
166
+ function focusPath(stateDir = defaultStateDir()) {
167
+ return path.join(stateDir, 'gm-focus.jsonl');
168
+ }
169
+
170
+ function readFocus(file, workspaceRoot) {
171
+ const out = {};
172
+ let text = '';
173
+ try {
174
+ text = fs.readFileSync(file, 'utf8');
175
+ } catch {
176
+ return out;
177
+ }
178
+ for (const line of text.split('\n')) {
179
+ if (!line.trim()) continue;
180
+ try {
181
+ const row = JSON.parse(line);
182
+ if (row && row.key && row.workspace === workspaceRoot) out[row.key] = row;
183
+ } catch {
184
+ // a torn line is skipped
185
+ }
186
+ }
187
+ return out;
188
+ }
189
+
190
+ // An answer newer than this is still fresh, so its question is not asked again.
191
+ const FOCUS_FRESH_MS = 7 * 24 * 60 * 60 * 1000;
192
+
193
+ // Our own words, which the owner never reads: on a card, a button, the
194
+ // footer, a question, or in what the writer model says. A binary tell, so a
195
+ // word list is the right tool.
196
+ const OWNER_BANNED_WORDS = ['bet', 'bets', 'check date', 'started so far', 'the team takes on', 'mission', 'queue', 'landed', 'review', 'reviewed', 'receipt', 'skeptic', 'verify', 'target number'];
197
+ const OWNER_BANNED = /\b(bets?|check dates?|started so far|the team takes on|missions?|queues?|landed|review(?:ed|s)?|receipts?|skeptics?|verify|target numbers?)\b/i;
198
+
199
+ // When no idea is ready the desk asks the owner a question about his
200
+ // project. The questions are written ahead, in the background, from that
201
+ // project's own files and every earlier answer, like an interview. Until one
202
+ // is written, this one works for any project.
203
+ const FALLBACK_QUESTION = {
204
+ key: 'focus',
205
+ ask: 'what matters most for this project this month?',
206
+ choices: [
207
+ { id: 'using', label: 'more people using it' },
208
+ { id: 'money', label: 'making money from it' },
209
+ { id: 'better', label: 'making it better' },
210
+ ],
211
+ };
212
+ // questions kept written ahead, so a tap never waits on one
213
+ const QUESTIONS_READY = 2;
214
+ const QUESTION_ASK_MAX = 90;
215
+ const QUESTION_FACT_MAX = 160;
216
+ const QUESTION_CHOICE_MAX = 50;
217
+
218
+ function questionsPath(stateDir = defaultStateDir()) {
219
+ return path.join(stateDir, 'gm-questions.jsonl');
220
+ }
221
+
222
+ // installs said the way the owner would say them, when this project has npm numbers
223
+ function installsLine(bundle) {
224
+ const inst = bundle && bundle.sources && bundle.sources.installs;
225
+ const d = inst && inst.data;
226
+ if (!d || d.week_before == null) return null;
227
+ if (d.last_week === d.week_before) return `installs held at ${commas(d.last_week)} last week.`;
228
+ return d.last_week < d.week_before
229
+ ? `installs dropped to ${commas(d.last_week)} last week, down from ${commas(d.week_before)}.`
230
+ : `installs rose to ${commas(d.last_week)} last week, up from ${commas(d.week_before)}.`;
231
+ }
232
+
233
+ // What this project is, in its own words: the readme, the goals, the
234
+ // persona, and the top of the map and the wiki. Read-only; a missing file is
235
+ // left out.
236
+ const CONTEXT_FILES = [['README.md', 60], ['atris/goals.md', 40], ['atris/PERSONA.md', 30], ['atris/MAP.md', 30], ['atris/wiki/index.md', 30]];
237
+ function readProjectContext(workspaceRoot) {
238
+ const parts = [];
239
+ for (const [rel, lines] of CONTEXT_FILES) {
240
+ let text;
241
+ try {
242
+ text = fs.readFileSync(path.join(workspaceRoot, rel), 'utf8');
243
+ } catch {
244
+ continue;
245
+ }
246
+ const body = text.split(/\r?\n/).slice(0, lines).join('\n').trim();
247
+ if (body) parts.push(`## ${rel}\n${body}`);
248
+ }
249
+ return parts.join('\n\n');
250
+ }
251
+
252
+ // A fact line is drawn from the project's files: it has a number from them,
253
+ // or four words in a row copied from them.
254
+ function citesText(fact, source) {
255
+ const nums = t => (String(t).match(/\d[\d,]*(?:\.\d+)?/g) || []).map(n => n.replace(/,/g, ''));
256
+ const have = new Set(nums(source));
257
+ if (nums(fact).some(n => have.has(n))) return true;
258
+ const src = ` ${words(source).join(' ')} `;
259
+ const w = words(fact);
260
+ for (let i = 0; i + 4 <= w.length; i += 1) {
261
+ const gram = w.slice(i, i + 4);
262
+ if (gram.every(x => STOP_WORDS.has(x))) continue;
263
+ if (src.includes(` ${gram.join(' ')} `)) return true;
264
+ }
265
+ return false;
266
+ }
267
+
268
+ // a question about our own pipeline, not his product
269
+ const PROCESS_TALK = /\b(ideas?|the team)\b/i;
270
+
271
+ // The code checks on a written question. Our own words and lengths can be
272
+ // fixed by one rewrite; anything else drops the question.
273
+ function validateQuestion(q, { source = '', avoid = [] } = {}) {
274
+ const reasons = [];
275
+ const format = new Set();
276
+ const fmt = (r) => { reasons.push(r); format.add(r); };
277
+ if (!q || typeof q !== 'object' || Array.isArray(q)) return { ok: false, reasons: ['the answer was not one JSON object'], formatOnly: false };
278
+ const ask = typeof q.ask === 'string' ? q.ask.trim() : '';
279
+ const fact = typeof q.fact === 'string' ? q.fact.trim() : '';
280
+ const choices = Array.isArray(q.choices) ? q.choices.map(c => (typeof c === 'string' ? c.trim() : '')) : null;
281
+ if (!ask || !ask.endsWith('?')) reasons.push('ask must be one question ending with "?"');
282
+ else if (ask.length > QUESTION_ASK_MAX) fmt(`ask is ${ask.length} characters, over ${QUESTION_ASK_MAX}`);
283
+ if (ask && avoid.some(a => nearDuplicate(ask, a))) reasons.push('the ask was already asked');
284
+ // the fact is optional: a question with no fitting fact stands on its own
285
+ if (!fact) { /* no fact */ } else if (fact.length > QUESTION_FACT_MAX) fmt(`fact is ${fact.length} characters, over ${QUESTION_FACT_MAX}`);
286
+ else if (!citesText(fact, source)) reasons.push("the fact is not in this project's files");
287
+ if (!choices || choices.length < 2 || choices.length > 3 || choices.some(c => !c)) reasons.push('choices must be 2 or 3 short answers');
288
+ else {
289
+ choices.forEach((c, i) => { if (c.length > QUESTION_CHOICE_MAX) fmt(`choice ${i + 1} is ${c.length} characters, over ${QUESTION_CHOICE_MAX}`); });
290
+ if (new Set(choices.map(c => c.toLowerCase())).size !== choices.length) reasons.push('the choices must differ');
291
+ }
292
+ for (const [k, text] of [['ask', ask], ['fact', fact]]) {
293
+ if (PROCESS_TALK.test(text)) fmt(`${k} talks about our ideas or the team; ask about the product and its people`);
294
+ }
295
+ const said = [['ask', ask], ['fact', fact], ...(choices || []).map((c, i) => [`choice ${i + 1}`, c])];
296
+ for (const [k, text] of said) {
297
+ const word = ownerWord(text);
298
+ if (word) fmt(`${k} uses "${word}", a word the owner never reads; say it plainly`);
299
+ }
300
+ return { ok: reasons.length === 0, reasons, formatOnly: reasons.length > 0 && reasons.every(r => format.has(r)) };
301
+ }
302
+
303
+ function buildQuestionPrompt(bundle, context, { answers = [], avoid = [] } = {}) {
304
+ return [
305
+ 'You are interviewing the owner of this project to learn what matters to him. Below is what this project\'s own files say.',
306
+ 'Write exactly ONE question for the owner about the product or the business of this project: its people, money, what is working, what annoys him, or what he wants this month. Never ask about how the team works, its tools, or its process.',
307
+ 'It is an interview: build on his earlier answers, go one step deeper, and never repeat a question already asked.',
308
+ '',
309
+ 'Answer with strict JSON only, no prose and no code fence, in this shape:',
310
+ '{"ask": "...", "fact": "...", "choices": ["...", "...", "..."]}',
311
+ '',
312
+ 'Rules:',
313
+ `- ask: one short plain question, under ${QUESTION_ASK_MAX} characters, ending with "?".`,
314
+ `- fact: the plain reason this question matters now, one sentence he would say himself, drawn from the files below, with a number or at least four words in a row copied from them, under ${QUESTION_FACT_MAX} characters. it must lead straight to the question. if nothing in the files gives a reason for this question, set fact to "".`,
315
+ `- choices: 2 or 3 short concrete answers he can tap, each under ${QUESTION_CHOICE_MAX} characters.`,
316
+ `- never use these words: ${OWNER_BANNED_WORDS.join(', ')}.`,
317
+ '- a section marked stale is old: prefer the fresh ones, and never present a stale line as what is happening now.',
318
+ '- ask about the product and the people who use it or pay for it, for example "who do you most want using this?". never mention ideas, the team, or what happens to his answer.',
319
+ '- plain lowercase-friendly words, no hype, no em dashes.',
320
+ '',
321
+ '## his answers so far',
322
+ ...(answers.length ? answers.map(a => `- ${a.ask} ${a.label}`) : ['- none yet']),
323
+ '',
324
+ '## questions already asked (never repeat one)',
325
+ ...(avoid.length ? avoid.map(a => `- ${a}`) : ['- none yet']),
326
+ '',
327
+ '## this project\'s files',
328
+ context || '(no readme or goals found)',
329
+ '',
330
+ bundleText(bundle),
331
+ ].join('\n');
332
+ }
333
+
334
+ function buildQuestionRewrite(previous, reasons) {
335
+ return [
336
+ 'Your last question broke only these limits:',
337
+ ...reasons.map(r => `- ${r}`),
338
+ '',
339
+ 'Fix only those: shorten what is long, and swap each word named for a plain one. Keep the same question, fact, and choices otherwise.',
340
+ 'Answer with the whole JSON object again, strict JSON only, no prose and no code fence.',
341
+ '',
342
+ 'Your last question:',
343
+ clip(previous, 2000),
344
+ ].join('\n');
345
+ }
346
+
347
+ // ---- reading free text: is it a question back, an answer, an instruction? ----
348
+
349
+ const SAY_READ_MS = 15000;
350
+ const SAY_KINDS = new Set(['question', 'answer', 'instruction', 'unclear']);
351
+ const SAY_FALLBACK = 'say that another way, or tap a button.';
352
+ // the last turns of a card's conversation the reader sees
353
+ const THREAD_TURNS = 6;
354
+
355
+ // the roster's model for quick reads (its search job), on the subscription
356
+ function readerModel(workspaceRoot) {
357
+ try {
358
+ const team = require('./engine-registry').resolveJobTeam('search', workspaceRoot);
359
+ const lead = team && team.lead;
360
+ if (lead && (lead.id === 'claude' || lead.bin === 'claude') && lead.roster_model) return lead.roster_model;
361
+ } catch {
362
+ // no roster here
363
+ }
364
+ return 'haiku';
365
+ }
366
+
367
+ // One quick call: what kind of message the owner typed on the card on
368
+ // screen, and what to say back. The card, the conversation on it so far, and
369
+ // a short view of the project go in.
370
+ async function readSaid({ text, card, thread = [], hint = null, workspaceRoot, bundle = null, deps = {} } = {}) {
371
+ const context = clip((deps.readContext || readProjectContext)(workspaceRoot), 1500);
372
+ const goal = bundle && bundle.sources && bundle.sources.goal;
373
+ const prompt = [
374
+ 'You read one short message the owner typed on a card in the app that runs his project, and say what kind it is.',
375
+ 'Answer with strict JSON only, no prose and no code fence, in this shape:',
376
+ '{"kind": "question" | "answer" | "instruction" | "unclear", "reply": "..."}',
377
+ '- question: he asks something back, like "what do you mean?" or "why?". reply: one or two plain sentences that say why the card asks this and give a concrete example from this project. read the conversation so far, so a follow-up builds on it.',
378
+ '- answer: his words answer the card. reply: "".',
379
+ '- instruction: he wants something done, or has a new idea. reply: one plain sentence saying what will happen next.',
380
+ '- unclear: you cannot tell. reply: one short question back.',
381
+ '- a message that ends with "?" or asks why, what, or how is a question back, never an answer.',
382
+ '- you may add "suggestions": 0 to 3 short replies he could tap next, each under 40 characters.',
383
+ `- plain lowercase-friendly words, no em dashes, and never these words: ${OWNER_BANNED_WORDS.join(', ')}.`,
384
+ '',
385
+ `## the card on screen (${card.kind})`,
386
+ card.title || '',
387
+ card.detail || '',
388
+ card.buttons && card.buttons.length ? `buttons: ${card.buttons.join(' / ')}` : '',
389
+ '',
390
+ '## the conversation on this card so far',
391
+ ...(thread.length ? thread.map(t => `- ${t.who === 'owner' ? 'owner' : 'you'}: ${t.text}`) : ['- none yet']),
392
+ '',
393
+ `## his message: "${clip(text, 280)}"`,
394
+ hint ? `note: ${hint}` : '',
395
+ '',
396
+ '## this project',
397
+ goal && goal.horizon ? `goal: ${goal.horizon.text}` : '',
398
+ context || '(no readme or goals found)',
399
+ ].filter(l => l !== '').join('\n');
400
+ const model = (deps.readerModel || readerModel)(workspaceRoot);
401
+ const said = await (deps.runModel || runModel)(prompt, { cwd: workspaceRoot, timeoutMs: SAY_READ_MS, command: { bin: 'claude', args: p => claudeArgs(p, model) }, onProgress: () => {} });
402
+ if (!said.ok) return { ok: false, reason: said.reason };
403
+ const parsed = parseBet(said.text);
404
+ const v = parsed.bet;
405
+ if (!v || !SAY_KINDS.has(v.kind) || typeof v.reply !== 'string') return { ok: false, reason: 'the read was not the shape asked for' };
406
+ 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 } : {}) };
408
+ }
409
+
410
+ // Two or three numbers that would tell the owner this project is going well,
411
+ // suggested from its own files. Each is a short plain name and which way is good.
412
+ async function generateScoreboardChoices({ workspaceRoot, bundle = null, deps = {}, ledgerFile, today = dayKey(Date.now()) } = {}) {
413
+ const b = bundle || await readBundle(workspaceRoot, { deps, ledgerFile, today });
414
+ const context = (deps.readContext || readProjectContext)(workspaceRoot);
415
+ const prompt = [
416
+ 'You help the owner of this project pick the one number that tells him it is going well. Below is what its own files say.',
417
+ 'Suggest 2 or 3 such numbers for THIS project, the kind he would check each week. For example: a web app, "people who used it this week"; a research lab, "experiments that worked"; a fund, "returns this month"; a journal, "days you wrote".',
418
+ 'Answer with strict JSON only, no prose and no code fence: {"choices": [{"name": "...", "up": true}]}',
419
+ '- name: a short plain phrase under 40 characters. up: true when higher is better, false when lower is better.',
420
+ `- never use these words: ${OWNER_BANNED_WORDS.join(', ')}.`,
421
+ '',
422
+ "## this project's files",
423
+ context || '(no readme or goals found)',
424
+ '',
425
+ bundleText(b),
426
+ ].join('\n');
427
+ const said = await (deps.runModel || runModel)(prompt, { cwd: workspaceRoot, onProgress: () => {} });
428
+ if (!said.ok) return { ok: false, reason: said.reason };
429
+ const parsed = parseBet(said.text);
430
+ const list = parsed.bet && Array.isArray(parsed.bet.choices) ? parsed.bet.choices : [];
431
+ const choices = list
432
+ .filter(c => c && typeof c.name === 'string' && c.name.trim() && c.name.trim().length <= 40 && !ownerWord(c.name) && !PROCESS_TALK.test(c.name))
433
+ .slice(0, 3)
434
+ .map(c => ({ name: c.name.trim(), up: c.up !== false }));
435
+ return choices.length >= 2 ? { ok: true, choices } : { ok: false, reason: 'fewer than two usable numbers' };
436
+ }
437
+
438
+ // One question for the owner, written from this project's files and his
439
+ // earlier answers. A question with our own words gets one rewrite; one that
440
+ // still fails any check is dropped.
441
+ async function generateQuestion({ workspaceRoot, answers = [], avoid = [], bundle = null, deps = {}, ledgerFile, today = dayKey(Date.now()) } = {}) {
442
+ const b = bundle || await readBundle(workspaceRoot, { deps, ledgerFile, today });
443
+ const context = (deps.readContext || readProjectContext)(workspaceRoot);
444
+ const source = `${context}\n${bundleText(b)}`;
445
+ const run = deps.runModel || runModel;
446
+ const ask = async (prompt) => {
447
+ const said = await run(prompt, { cwd: workspaceRoot, onProgress: () => {} });
448
+ if (!said.ok) return { failed: said.reason };
449
+ const parsed = parseBet(said.text);
450
+ return { parsed, check: parsed.error ? { ok: false, reasons: [parsed.error], formatOnly: false } : validateQuestion(parsed.bet, { source, avoid }) };
451
+ };
452
+ let r = await ask(buildQuestionPrompt(b, context, { answers, avoid }));
453
+ if (r.failed) return { ok: false, reason: r.failed };
454
+ if (!r.check.ok && r.check.formatOnly) {
455
+ r = await ask(buildQuestionRewrite(JSON.stringify(r.parsed.bet), r.check.reasons));
456
+ if (r.failed) return { ok: false, reason: r.failed };
457
+ }
458
+ if (!r.check.ok) return { ok: false, reason: `the question was dropped: ${r.check.reasons.slice(0, 3).join('; ')}` };
459
+ const q = r.parsed.bet;
460
+ const fact = typeof q.fact === 'string' && q.fact.trim() ? q.fact.trim() : null;
461
+ return { ok: true, question: { ask: q.ask.trim(), fact, choices: q.choices.map(c => c.trim()) } };
462
+ }
463
+
464
+ // The first of our own words in a line the owner reads, or null.
465
+ function ownerWord(text) {
466
+ const m = OWNER_BANNED.exec(String(text || ''));
467
+ return m ? m[0].toLowerCase() : null;
468
+ }
469
+
130
470
  function ledgerLine(bets) {
131
471
  if (!bets.length) return 'past bets: none yet';
132
472
  const count = s => bets.filter(b => b.status === s).length;
@@ -150,6 +490,92 @@ function readNow(workspaceRoot) {
150
490
  return { fact, text: lines.join('\n') };
151
491
  }
152
492
 
493
+ // ---- what the project is about now: its goal, open missions, and journal ----------
494
+
495
+ // a source older than this is labeled stale, and the writers prefer fresher ones
496
+ const STALE_DAYS = 14;
497
+ const MISSIONS_SHOWN = 8;
498
+ const JOURNAL_LINES = 20;
499
+ const JOURNAL_CHARS = 1500;
500
+
501
+ // whole days from an ISO date (or "YYYY-MM-DD ...") to today, or null
502
+ function ageDays(when, today) {
503
+ const day = /(\d{4}-\d{2}-\d{2})/.exec(String(when || ''));
504
+ if (!day) return null;
505
+ return Math.round((Date.parse(`${today}T00:00:00Z`) - Date.parse(`${day[1]}T00:00:00Z`)) / 86400000);
506
+ }
507
+
508
+ function staleNote(age) {
509
+ return age != null && age > STALE_DAYS ? `stale: ${age} days old` : null;
510
+ }
511
+
512
+ // The goal: the endgame horizon in atris/TODO.md (what the boot shows as the
513
+ // goal) and the sentence set with atris goal, each with the day it was set.
514
+ function readGoal(workspaceRoot) {
515
+ const out = {};
516
+ try {
517
+ const todo = fs.readFileSync(path.join(workspaceRoot, 'atris', 'TODO.md'), 'utf8');
518
+ const section = (/##\s+Endgame\s*\n([\s\S]*?)(?=\n##|$)/.exec(todo) || [])[1] || '';
519
+ const horizon = (/\*\*Horizon:\*\*\s*(.+)/.exec(section) || [])[1];
520
+ const picked = (/\*\*Picked:\*\*\s*(.+)/.exec(section) || [])[1];
521
+ if (horizon && horizon.trim()) out.horizon = { text: clip(horizon, 240), at: picked ? picked.trim().slice(0, 10) : null };
522
+ } catch {
523
+ // no TODO.md here
524
+ }
525
+ try {
526
+ const board = JSON.parse(fs.readFileSync(path.join(workspaceRoot, '.atris', 'state', 'scoreboard.json'), 'utf8'));
527
+ if (board && typeof board.goal === 'string' && board.goal.trim()) out.goal = { text: clip(board.goal, 240), at: board.updated_at ? String(board.updated_at).slice(0, 10) : null };
528
+ } catch {
529
+ // no goal set
530
+ }
531
+ return out.horizon || out.goal ? out : null;
532
+ }
533
+
534
+ // a mission's objective as a plain title: its first sentence
535
+ function missionTitle(objective) {
536
+ return clip(String(objective || '').split(/\.\s/)[0].replace(/[.\s]+$/, ''), 100);
537
+ }
538
+
539
+ // Open missions, freshest first, as plain titles with who owns each.
540
+ function openMissions(missions, today) {
541
+ const list = missions instanceof Map ? [...missions.values()] : Object.values(missions || {});
542
+ return list
543
+ .filter(m => m && OPEN_MISSION.has(String(m.status || '')) && m.objective)
544
+ .map((m) => {
545
+ const moved = [m.last_tick_at, m.updated_at, m.created_at].filter(Boolean).sort().pop();
546
+ return { title: missionTitle(m.objective), owner: m.owner || null, moved, age: ageDays(moved, today), running: m.status === 'running' };
547
+ })
548
+ // a run that says running but has not moved in a day is dead, not open work
549
+ .filter(m => !(m.running && m.age != null && m.age >= 1))
550
+ .sort((a, b) => String(b.moved).localeCompare(String(a.moved)))
551
+ .slice(0, MISSIONS_SHOWN);
552
+ }
553
+
554
+ // The newest day in atris/logs, its last lines, bounded.
555
+ function readJournal(workspaceRoot) {
556
+ const dir = path.join(workspaceRoot, 'atris', 'logs');
557
+ let newest = null;
558
+ try {
559
+ for (const year of fs.readdirSync(dir)) {
560
+ if (!/^\d{4}$/.test(year)) continue;
561
+ for (const f of fs.readdirSync(path.join(dir, year))) {
562
+ if (/^\d{4}-\d{2}-\d{2}\.md$/.test(f) && (!newest || f > newest.name)) newest = { name: f, file: path.join(dir, year, f) };
563
+ }
564
+ }
565
+ } catch {
566
+ return null;
567
+ }
568
+ if (!newest) return null;
569
+ let text;
570
+ try {
571
+ text = fs.readFileSync(newest.file, 'utf8');
572
+ } catch {
573
+ return null;
574
+ }
575
+ const lines = text.split(/\r?\n/).filter(l => l.trim()).slice(-JOURNAL_LINES);
576
+ return lines.length ? { day: newest.name.slice(0, 10), text: lines.join('\n').slice(-JOURNAL_CHARS) } : null;
577
+ }
578
+
153
579
  // The weekly target and the season record, read the same way the board reads them.
154
580
  function defaultSeasonFacts(workspaceRoot) {
155
581
  const season = require('./gm-season');
@@ -488,7 +914,12 @@ function dayEndMs(day) {
488
914
  // task db. Resolves { value } or { error, transient }; transient means try
489
915
  // again later (npm has not counted the days yet, or the db is busy), not
490
916
  // transient means never.
491
- async function defaultReadMetric(metric, { workspaceRoot, start, end, target = {}, deps = {} }) {
917
+ async function defaultReadMetric(metric, { workspaceRoot, start, end, target = {}, deps = {}, ledgerFile = null }) {
918
+ if (metric === 'scoreboard') {
919
+ const board = readScoreboard(scoreboardPath(path.dirname(ledgerFile || ledgerPath())), workspaceRoot);
920
+ const hit = board && board.reports.find(r => String(r.at).slice(0, 10) >= end);
921
+ return hit ? { value: hit.value } : { error: 'the owner has not reported the number for this date yet', transient: true };
922
+ }
492
923
  if (metric === 'installs') {
493
924
  const pkg = deps.packageName !== undefined ? deps.packageName : packageName(workspaceRoot);
494
925
  if (!pkg) return { error: 'no published package here to read installs from', transient: false };
@@ -511,8 +942,9 @@ async function defaultReadMetric(metric, { workspaceRoot, start, end, target = {
511
942
  }
512
943
 
513
944
  // Won when the number reached the goal.
514
- function wonBet(metric, actual, goal) {
515
- return METRICS[metric].up ? actual >= goal : actual <= goal;
945
+ function wonBet(metric, actual, goal, target = {}) {
946
+ const up = metric === 'scoreboard' ? target.up !== false : METRICS[metric].up;
947
+ return up ? actual >= goal : actual <= goal;
516
948
  }
517
949
 
518
950
  // Scores every bet the team is doing once its check date is over: reads its
@@ -536,7 +968,7 @@ async function scoreBets(workspaceRoot, { ledgerFile = ledgerPath(), today = day
536
968
  out = { error: metric && !METRICS[metric] ? `there is no way to read "${metric}"` : 'the bet has no target number to read', transient: false };
537
969
  } else {
538
970
  try {
539
- out = await read(metric, { workspaceRoot, start, end, target: t, deps });
971
+ out = await read(metric, { workspaceRoot, start, end, target: t, deps, ledgerFile });
540
972
  } catch (err) {
541
973
  out = { error: String((err && err.message) || err), transient: true };
542
974
  }
@@ -546,8 +978,8 @@ async function scoreBets(workspaceRoot, { ledgerFile = ledgerPath(), today = day
546
978
  if (out && out.transient && late <= SCORE_GRACE_DAYS) continue;
547
979
  Object.assign(row, { status: 'unreadable', why });
548
980
  } else {
549
- const window = metric === 'installs' ? `${start} to ${end}` : `landed by the end of ${end}, of ${plural(t.job_ids.length, 'job')} waiting at the start`;
550
- Object.assign(row, { status: wonBet(metric, out.value, t.goal) ? 'won' : 'lost', actual: out.value, window });
981
+ const window = metric === 'installs' ? `${start} to ${end}` : (metric === 'scoreboard' ? `reported on or after ${end}` : `landed by the end of ${end}, of ${plural(t.job_ids.length, 'job')} waiting at the start`);
982
+ Object.assign(row, { status: wonBet(metric, out.value, t.goal, t) ? 'won' : 'lost', actual: out.value, window });
551
983
  }
552
984
  appendLedger(ledgerFile, row);
553
985
  scored.push({ ...row, title: b.bet.title });
@@ -563,7 +995,9 @@ async function scoreBets(workspaceRoot, { ledgerFile = ledgerPath(), today = day
563
995
  function bundleMetrics(bundle) {
564
996
  const s = (bundle && bundle.sources) || {};
565
997
  const out = {};
566
- if (s.installs) {
998
+ if (s.scoreboard) {
999
+ out.scoreboard = { baseline: s.scoreboard.baseline, best: s.scoreboard.best, reports: s.scoreboard.reports, up: s.scoreboard.up, name: s.scoreboard.name };
1000
+ } else if (s.installs) {
567
1001
  const best = s.installs.best || null;
568
1002
  out.installs = { baseline: s.installs.data.last_week, best: best ? best.best : null, weeks: best ? best.weeks : 0 };
569
1003
  }
@@ -587,7 +1021,7 @@ async function readBundle(workspaceRoot, { deps = {}, emit = () => {}, ledgerFil
587
1021
  } catch {
588
1022
  // scoring is a side job; a failure never blocks the next bet
589
1023
  }
590
- const bundle = { workspace: workspaceRoot, sources: {}, facts: [] };
1024
+ const bundle = { workspace: workspaceRoot, today, sources: {}, facts: [] };
591
1025
  const add = (key, fact, data) => {
592
1026
  bundle.sources[key] = { fact, ...data };
593
1027
  bundle.facts.push(fact);
@@ -596,8 +1030,11 @@ async function readBundle(workspaceRoot, { deps = {}, emit = () => {}, ledgerFil
596
1030
  const skip = (key, why) => emit({ kind: 'skip', key, line: why });
597
1031
 
598
1032
  const now = (deps.readNow || readNow)(workspaceRoot);
599
- if (now) add('now', now.fact, { text: now.text });
600
- else skip('now', 'no atris/now.md, left out');
1033
+ if (now) {
1034
+ const age = ageDays(/last updated:\s*(.+)$/im.exec(now.text || '') ? /last updated:\s*(.+)$/im.exec(now.text)[1] : null, today);
1035
+ const stale = staleNote(age);
1036
+ add('now', stale ? `${now.fact}, ${age} days ago, stale` : now.fact, { text: now.text, ...(stale ? { stale: age } : {}) });
1037
+ } else skip('now', 'no atris/now.md, left out');
601
1038
 
602
1039
  let season = null;
603
1040
  try {
@@ -707,6 +1144,42 @@ async function readBundle(workspaceRoot, { deps = {}, emit = () => {}, ledgerFil
707
1144
  ...(b.why ? { why: b.why } : {}),
708
1145
  }));
709
1146
  add('ledger', ledgerLine(bets), { bets: shown });
1147
+
1148
+ // what the project is about now: kept out of the facts list and the event
1149
+ // stream, read only when the files are there
1150
+ const goal = (deps.readGoal || readGoal)(workspaceRoot);
1151
+ if (goal) bundle.sources.goal = goal;
1152
+ let missionMap = null;
1153
+ try {
1154
+ missionMap = (deps.readMissions || defaultReadMissions)(workspaceRoot);
1155
+ } catch {
1156
+ missionMap = null;
1157
+ }
1158
+ const open = missionMap ? openMissions(missionMap, today) : [];
1159
+ if (open.length) bundle.sources.missions = { list: open };
1160
+ const journal = (deps.readJournal || readJournal)(workspaceRoot);
1161
+ if (journal) bundle.sources.journal = { ...journal, age: ageDays(journal.day, today) };
1162
+
1163
+ // the owner's own number: a reported one is the target, with the last
1164
+ // report as the baseline; an installs one keeps the npm numbers
1165
+ const board = readScoreboard(scoreboardPath(path.dirname(ledger)), workspaceRoot);
1166
+ if (board && board.source === 'reported' && board.reports.length) {
1167
+ const last = board.reports[board.reports.length - 1];
1168
+ const recent = board.reports.slice(-SCOREBOARD_REPORTS).map(r => r.value);
1169
+ const best = recent.length >= SCOREBOARD_MIN_REPORTS ? (board.up ? Math.max(...recent) : Math.min(...recent)) : null;
1170
+ add('scoreboard', `${board.name}: last reported ${commas(last.value)} on ${String(last.at).slice(0, 10)}`, { name: board.name, up: board.up, baseline: last.value, best, reports: recent.length });
1171
+ } else if (board) {
1172
+ bundle.sources.scoreboard_pick = { name: board.name, up: board.up, source: board.source };
1173
+ }
1174
+
1175
+ // what the owner said to aim at, from the question cards; kept out of the
1176
+ // facts list, so a why_now still has to quote a real number
1177
+ const focus = Object.values(readFocus(focusPath(path.dirname(ledger)), workspaceRoot))
1178
+ .sort((a, b) => String(a.at).localeCompare(String(b.at)));
1179
+ if (focus.length) {
1180
+ bundle.sources.focus = { answers: focus.map(a => ({ key: a.key, ask: a.ask, label: a.label, at: a.at })) };
1181
+ emit({ kind: 'source', key: 'focus', line: `the owner said: ${focus.map(a => a.label).join(', ')}` });
1182
+ }
710
1183
  return bundle;
711
1184
  }
712
1185
 
@@ -719,13 +1192,34 @@ function pastBetLine(b) {
719
1192
  function bundleText(bundle) {
720
1193
  const s = bundle.sources;
721
1194
  const parts = ['## facts', ...bundle.facts.map(f => `- ${f}`)];
722
- if (s.now) parts.push('', '## atris/now.md', s.now.text);
1195
+ const aged = (at, verb) => {
1196
+ const note = staleNote(ageDays(at, bundle.today));
1197
+ return at ? ` (${verb} ${at}${note ? `, ${note}` : ''})` : '';
1198
+ };
1199
+ if (s.goal) {
1200
+ parts.push('', '## the goal');
1201
+ if (s.goal.horizon) parts.push(`- horizon: ${s.goal.horizon.text}${aged(s.goal.horizon.at, 'picked')}`);
1202
+ if (s.goal.goal) parts.push(`- goal: ${s.goal.goal.text}${aged(s.goal.goal.at, 'set')}`);
1203
+ }
1204
+ if (s.now) parts.push('', `## atris/now.md${s.now.stale ? ` (stale: ${s.now.stale} days old, prefer the fresher sections)` : ''}`, s.now.text);
1205
+ if (s.missions) parts.push('', '## open missions', ...s.missions.list.map((m) => {
1206
+ const note = staleNote(m.age);
1207
+ const tail = [m.owner, note].filter(Boolean).join(', ');
1208
+ return `- ${m.title}${tail ? ` (${tail})` : ''}`;
1209
+ }));
1210
+ if (s.journal) parts.push('', `## latest journal, ${s.journal.day}${staleNote(s.journal.age) ? ` (${staleNote(s.journal.age)})` : ''}`, s.journal.text);
723
1211
  if (s.landed && s.landed.subjects.length) parts.push('', '## changes merged on master in the last 7 days', ...s.landed.subjects.map(x => `- ${x}`));
724
1212
  if (s.installs) parts.push('', `## npm installs for ${s.installs.package}`, `last week: ${s.installs.data.last_week}`, `week before: ${s.installs.data.week_before == null ? 'unknown' : s.installs.data.week_before}`, `through: ${s.installs.data.through}`);
725
1213
  if (s.release) parts.push('', '## release', `published on npm: ${s.release.version}`, `changes on master not in it: ${s.release.unpublished == null ? 'unknown' : s.release.unpublished}`);
726
- if (s.team) parts.push('', '## the team (a team step names one of these as its member)', ...s.team.members.map(m => `- ${m.name}: ${m.role}`));
1214
+ // what each member is on now: their freshest open mission
1215
+ const onNow = new Map();
1216
+ for (const m of (s.missions ? s.missions.list : [])) if (m.owner && !staleNote(m.age) && !onNow.has(m.owner)) onNow.set(m.owner, m.title);
1217
+ if (s.team) parts.push('', '## the team (a team step names one of these as its member)', ...s.team.members.map(m => (onNow.has(m.name) ? `- ${m.name}: ${m.role.replace(/[.\s]+$/, '')}. now on: ${onNow.get(m.name)}` : `- ${m.name}: ${m.role}`)));
727
1218
  if (s.briefs) for (const b of s.briefs.briefs) parts.push('', `## research brief: ${b.name}`, b.text);
728
1219
  if (s.ledger && s.ledger.bets.length) parts.push('', '## past bets', ...s.ledger.bets.map(pastBetLine));
1220
+ if (s.scoreboard) parts.push('', "## the owner's scoreboard", `- ${s.scoreboard.name} (${s.scoreboard.up ? 'higher' : 'lower'} is better); last reported ${commas(s.scoreboard.baseline)}`);
1221
+ else if (s.scoreboard_pick) parts.push('', "## the owner's scoreboard", `- ${s.scoreboard_pick.name} (${s.scoreboard_pick.up ? 'higher' : 'lower'} is better), read from ${s.scoreboard_pick.source === 'installs' ? 'npm installs' : 'his reports'}`);
1222
+ if (s.focus) parts.push('', '## what the owner asked for', ...s.focus.answers.map(a => `- ${a.ask} ${a.label} (said ${String(a.at).slice(0, 10)})`));
729
1223
  return parts.join('\n');
730
1224
  }
731
1225
 
@@ -733,6 +1227,12 @@ function bundleText(bundle) {
733
1227
 
734
1228
  // The bar a goal has to clear, in words, for the prompt and the reject reason.
735
1229
  function goalBar(metric, m) {
1230
+ if (metric === 'scoreboard') {
1231
+ const dir = m.up ? 'above' : 'below';
1232
+ return m.best == null
1233
+ ? `with fewer than ${SCOREBOARD_MIN_REPORTS} reports any honest goal ${dir} the baseline ${commas(m.baseline)} passes`
1234
+ : `the best of the last ${m.reports} reports was ${commas(m.best)}, so the goal must be ${dir} ${commas(m.best)}`;
1235
+ }
736
1236
  if (metric === 'installs') {
737
1237
  return m.best == null ? 'the goal must be above the baseline' : `the best week in the last ${bestSpan(m.weeks)} was ${commas(m.best)}, so the goal must be above ${commas(m.best)}`;
738
1238
  }
@@ -745,11 +1245,12 @@ const TEAM_ONLY_METRICS = new Set(['queue_landed']);
745
1245
  function metricChoices(bundle) {
746
1246
  const m = bundleMetrics(bundle);
747
1247
  return Object.keys(m).filter(k => !TEAM_ONLY_METRICS.has(k)).map((k) => {
1248
+ if (k === 'scoreboard') return `"scoreboard" (${m[k].name}, the owner's own number; baseline ${m[k].baseline}; ${goalBar(k, m[k])})`;
748
1249
  return `"${k}" (${METRICS[k].what}; baseline ${m[k].baseline}; ${goalBar(k, m[k])})`;
749
1250
  });
750
1251
  }
751
1252
 
752
- function buildPrompt(bundle, { today, previousTitle = null, reason = null, previousAnswer = null, blocker = null, avoidTitles = [] } = {}) {
1253
+ function buildPrompt(bundle, { today, previousTitle = null, reason = null, previousAnswer = null, blocker = null, avoidTitles = [], reworkOf = null, ownerNote = null } = {}) {
753
1254
  const choices = metricChoices(bundle);
754
1255
  const lines = [
755
1256
  'You are the strategist for a small software business. Below is everything we can read about it right now.',
@@ -775,11 +1276,18 @@ function buildPrompt(bundle, { today, previousTitle = null, reason = null, previ
775
1276
  '- check what has to be true first. if the move shows people something that is merged but not published yet (see the release fact), the release is one step for "you" inside the move, never the move itself.',
776
1277
  '- past bets marked won or lost are real results, read from the same numbers. lean toward moves like the winners and away from the losers, and when you do, say so in why_now.',
777
1278
  '- write in plain lowercase-friendly words, no hype, no em dashes.',
1279
+ '- a section marked stale is old: prefer the fresh ones, and never present a stale line as what is happening now.',
1280
+ '- never propose a trade, buying or selling stocks, shares, crypto, or any security, or moving money between accounts.',
778
1281
  ];
1282
+ lines.push(`- title, why_now, and expect are read by the owner, so they never use these words: ${OWNER_BANNED_WORDS.join(', ')}. say "idea" or a plain verb instead of bet, and say what changes for people, not how we work.`);
1283
+ if (bundle.sources && bundle.sources.focus) lines.push('- the bet must serve what the owner asked for (the last section below). when answers pull different ways, the newest wins.');
779
1284
  // a skeptic named what blocks the last idea: the blocker is the next bet,
780
1285
  // not one more step on the old idea
781
1286
  if (blocker) lines.push('', `The last idea ("${clip(blocker.title, 120)}") is blocked by: ${blocker.missing_first.replace(/[.\s]+$/, '')}. Don't patch that idea. Make clearing this blocker the bet, as big as this team can do in the window, with its own target. The blocker stops being a step and becomes the whole move. If the blocker is team plumbing (a release, a merge, a review, a fix to the build or a count), it is never the bet: keep a business move and put the plumbing in as one step.`);
782
1287
  if (previousTitle) lines.push('', `Make it sharper, more specific, and different from: ${previousTitle}`);
1288
+ // what the owner typed on a card
1289
+ if (ownerNote && reworkOf) lines.push('', `The owner read the idea "${clip(reworkOf, 160)}" and said: "${clip(ownerNote, 280)}". Rework it so it fits what they said; it may keep the same deliverable.`);
1290
+ else if (ownerNote) lines.push('', `The owner said: "${clip(ownerNote, 280)}". The next idea must fit that.`);
783
1291
  const avoid = avoidTitles.filter(t => t && t !== previousTitle);
784
1292
  if (avoid.length) lines.push('', 'It must be a different move from every one of these bets already shown or waiting:', ...avoid.map(t => `- ${t}`));
785
1293
  if (previousAnswer) lines.push('', 'Your last answer was:', clip(previousAnswer, 4000));
@@ -805,6 +1313,19 @@ function claudeArgs(prompt, model = MODEL) {
805
1313
  // Runs the model in its own process group. On timeout the whole group is
806
1314
  // killed, so nothing it started keeps running. Streams real progress (tokens
807
1315
  // thought, characters written) through onProgress.
1316
+ // Writers running now, by process group, so the server can end them when it
1317
+ // stops; otherwise a stopped server leaves them running on their own.
1318
+ const LIVE_WRITERS = new Map();
1319
+
1320
+ function stopWriters() {
1321
+ let n = 0;
1322
+ for (const [pid, kill] of LIVE_WRITERS) {
1323
+ try { kill(-pid, 'SIGTERM'); n += 1; } catch { /* already gone */ }
1324
+ }
1325
+ LIVE_WRITERS.clear();
1326
+ return n;
1327
+ }
1328
+
808
1329
  function runModel(prompt, { command = { bin: 'claude', args: claudeArgs }, cwd, timeoutMs = MODEL_TIMEOUT_MS, env = process.env, onProgress = () => {}, spawnFn = require('child_process').spawn, kill = process.kill.bind(process) } = {}) {
809
1330
  return new Promise((resolve) => {
810
1331
  const args = typeof command.args === 'function' ? command.args(prompt) : [...(command.args || []), prompt];
@@ -815,6 +1336,7 @@ function runModel(prompt, { command = { bin: 'claude', args: claudeArgs }, cwd,
815
1336
  resolve({ ok: false, reason: `could not start the model: ${err.message}` });
816
1337
  return;
817
1338
  }
1339
+ if (child && child.pid) LIVE_WRITERS.set(child.pid, kill);
818
1340
  let buf = '';
819
1341
  let size = 0;
820
1342
  let stderr = '';
@@ -864,6 +1386,7 @@ function runModel(prompt, { command = { bin: 'claude', args: claudeArgs }, cwd,
864
1386
  if (done) return;
865
1387
  done = true;
866
1388
  clearTimeout(timer);
1389
+ LIVE_WRITERS.delete(child.pid);
867
1390
  resolve(out);
868
1391
  };
869
1392
  child.on('error', err => finish({ ok: false, reason: `could not start the model: ${err.message}` }));
@@ -998,6 +1521,11 @@ function targetProblems(target, bundle, { allowTeamMetrics = false } = {}) {
998
1521
  } else if (metric === 'installs') {
999
1522
  if (known.best !== null && !(target.goal > known.best)) out.push(`target goal ${commas(target.goal)} does not beat our best installs week of the last ${bestSpan(known.weeks)}, ${commas(known.best)}; the goal must be above ${commas(known.best)}`);
1000
1523
  else if (known.best === null && !(target.goal > real)) out.push(`target goal ${commas(target.goal)} must be above the baseline ${commas(real)}`);
1524
+ } else if (metric === 'scoreboard') {
1525
+ const better = (a, b) => (known.up ? a > b : a < b);
1526
+ const dir = known.up ? 'above' : 'below';
1527
+ if (known.best != null && !better(target.goal, known.best)) out.push(`target goal ${commas(target.goal)} does not beat the best of the last ${known.reports} reports, ${commas(known.best)}; the goal must be ${dir} ${commas(known.best)}`);
1528
+ else if (known.best == null && !better(target.goal, real)) out.push(`target goal ${commas(target.goal)} must be ${dir} the baseline ${commas(real)}`);
1001
1529
  } else if (metric === 'queue_landed') {
1002
1530
  if (known.real === 0) out.push(known.waiting === 0 ? 'nothing is waiting on review, so there is no queue to land; pick another metric' : `none of the ${commas(known.waiting)} waiting jobs has real work behind it, so none can land; pick another metric`);
1003
1531
  else if (target.goal < known.least) out.push(`target goal ${commas(target.goal)} does not land half the waiting jobs with real work: ${commas(known.real)} have real work, so the goal must be at least ${commas(known.least)}`);
@@ -1006,6 +1534,9 @@ function targetProblems(target, bundle, { allowTeamMetrics = false } = {}) {
1006
1534
  return out;
1007
1535
  }
1008
1536
 
1537
+ // a trade, buying or selling a security, or moving money: never an idea
1538
+ const TRADE = /\b(buy|sell|short|trade|trading)\b[^.]{0,40}\b(stocks?|shares|securit(?:y|ies)|options|crypto|bitcoin|tokens|bonds?|etfs?|index fund|positions?)\b|\b(wire|transfer|move|send)\b[^.]{0,30}\b(money|funds|cash)\b[^.]{0,30}\b(account|brokerage|bank|wallet)\b/i;
1539
+
1009
1540
  function validateBet(bet, { bundle, today, previousTitle = null, avoidTitles = [], allowTeamMetrics = false } = {}) {
1010
1541
  const reasons = [];
1011
1542
  // format-only reasons: a field too long or with too many sentences. Only
@@ -1017,6 +1548,12 @@ function validateBet(bet, { bundle, today, previousTitle = null, avoidTitles = [
1017
1548
  for (const k of ['title', 'why_now', 'expect', 'metric_source', 'cost']) {
1018
1549
  if (!str(k)) reasons.push(`${k} is missing`);
1019
1550
  }
1551
+ for (const k of ['title', 'why_now', 'expect']) {
1552
+ const word = ownerWord(str(k));
1553
+ if (word) fmt(`${k} uses "${word}", a word the owner never reads; say it plainly`);
1554
+ }
1555
+ const allText = [str('title'), str('why_now'), str('expect'), ...(Array.isArray(bet.steps) ? bet.steps.map(st => `${st && st.text} ${st && st.detail}`) : [])].join(' . ');
1556
+ if (TRADE.test(allText)) reasons.push('it proposes a trade or moving money; ideas never do that');
1020
1557
  if (str('title')) {
1021
1558
  const p = titleProblem(str('title'));
1022
1559
  if (p && (p === 'the title is longer than 140 characters' || p === 'the title is more than one sentence')) fmt(p);
@@ -1082,6 +1619,7 @@ function addDays(today, n) {
1082
1619
 
1083
1620
  // "goal 2,400, best week in 2 months 2,319" or "goal 3 of the 6 jobs with work landed"
1084
1621
  function goalLine(target, known) {
1622
+ if (target.metric === 'scoreboard') return `goal ${commas(target.goal)}, last reported ${commas(target.baseline)}`;
1085
1623
  if (target.metric === 'queue_landed') return `goal ${commas(target.goal)} of the ${commas(target.job_ids.length)} jobs with work landed`;
1086
1624
  if (known && known.best !== null) return `goal ${commas(target.goal)}, best week in ${bestSpan(known.weeks)} ${commas(known.best)}`;
1087
1625
  return `goal ${commas(target.goal)}, ${commas(target.baseline)} last week`;
@@ -1091,6 +1629,8 @@ function cleanBet(bet, today, bundle) {
1091
1629
  const target = { metric: bet.target.metric, baseline: bet.target.baseline, goal: bet.target.goal };
1092
1630
  // the jobs waiting right now, kept with the bet: only these can count
1093
1631
  if (target.metric === 'queue_landed') target.job_ids = [...bundleMetrics(bundle).queue_landed.ids];
1632
+ // the owner's number keeps its name and which way is good, for scoring later
1633
+ if (target.metric === 'scoreboard') Object.assign(target, { name: bundleMetrics(bundle).scoreboard.name, up: bundleMetrics(bundle).scoreboard.up });
1094
1634
  return {
1095
1635
  title: bet.title.trim(),
1096
1636
  why_now: bet.why_now.trim(),
@@ -1150,6 +1690,7 @@ function buildSkepticPrompt(bet, bundle, { today } = {}) {
1150
1690
  '- moves_metric: if every step is done, would it actually move the target number (target.metric: installs are read over the 7 days that end on the check date; queue_landed counts only the waiting jobs with real work when the bet was made (target.job_ids; placeholders with no check are left out) that land, accepted or merged, by the check date, and a closed, dropped, or parked job counts zero) from the baseline to the goal? a post does not move installs if the thing it shows is not installable yet, and work nobody outside sees does not move installs at all.',
1151
1691
  '- missing_first: what must be true first and is not, and is not already a step: an unpublished feature (see the release fact), a page or command that does not exist, no audience that would see it. write "nothing" when nothing is missing.',
1152
1692
  '- would a sharp founder reading this find it obviously wrong, padded, or a waste of the week?',
1693
+ '- does it propose a trade, buying or selling stocks, shares, crypto, or any security, or moving money between accounts: fail it.',
1153
1694
  '- is the deliverable internal plumbing (a release, a review queue, merging or unsticking jobs, test speed, fixing a count or a dashboard)? that is the team\'s job and never an owner bet, so fail it. plumbing as one step inside a business move is fine.',
1154
1695
  'verdict is "pass" only when moves_metric is true, nothing is missing that the steps do not cover, it is not obviously wrong, and it is not plumbing. otherwise "fail".',
1155
1696
  'reason: one or two plain sentences under 240 characters. if the missing piece is something the team or the owner could do, name the step to add.',
@@ -1206,11 +1747,14 @@ function skepticReason(v) {
1206
1747
  // the reasons and the last answer go back, and only those fields may change.
1207
1748
  // The text is never shortened in code.
1208
1749
  function buildTrimPrompt(previousAnswer, reasons) {
1750
+ const worded = reasons.some(r => r.includes('a word the owner never reads'));
1209
1751
  return [
1210
- 'Your last answer below broke only these length or sentence limits:',
1752
+ worded ? 'Your last answer below broke only these wording or length limits:' : 'Your last answer below broke only these length or sentence limits:',
1211
1753
  ...reasons.map(r => `- ${r}`),
1212
1754
  '',
1213
- 'Shorten only those fields so they fit. Change nothing else: the same idea, the same target, the same check_in_days, the same steps in the same order with the same who and public. Keep the plain words, no em dashes.',
1755
+ worded
1756
+ ? 'Fix only those fields: shorten the long ones, and swap each word named for a plain one. Change nothing else: the same idea, the same target, the same check_in_days, the same steps in the same order with the same who and public. Keep the plain words, no em dashes.'
1757
+ : 'Shorten only those fields so they fit. Change nothing else: the same idea, the same target, the same check_in_days, the same steps in the same order with the same who and public. Keep the plain words, no em dashes.',
1214
1758
  'Answer with the whole JSON object again, strict JSON only, no prose and no code fence.',
1215
1759
  '',
1216
1760
  'Your last answer:',
@@ -1228,8 +1772,12 @@ function trimChangedMore(before, after) {
1228
1772
  return a.length !== b.length || a.some((s, i) => !b[i] || s.who !== b[i].who || s.public !== b[i].public);
1229
1773
  }
1230
1774
 
1231
- async function generateBet({ workspaceRoot, today, previousTitle = null, avoidTitles = [], emit = () => {}, deps = {}, ledgerFile, attemptsFile, allowTeamMetrics = false } = {}) {
1775
+ async function generateBet({ workspaceRoot, today, previousTitle = null, avoidTitles: avoidIn = [], reworkOf = null, ownerNote = null, emit = () => {}, onBundle = () => {}, deps = {}, ledgerFile, attemptsFile, allowTeamMetrics = false } = {}) {
1776
+ // a rework may stay close to the idea it reworks
1777
+ const avoidTitles = reworkOf ? avoidIn.filter(t => t !== reworkOf) : avoidIn;
1232
1778
  const bundle = await readBundle(workspaceRoot, { deps, emit, ledgerFile, today });
1779
+ // the desk builds its question cards from these facts while the model works
1780
+ try { onBundle(bundle); } catch { /* the desk's copy is a convenience */ }
1233
1781
  const run = deps.runModel || runModel;
1234
1782
  // tests pass runSkeptic; a stubbed runModel without one is also used for the
1235
1783
  // skeptic, so a test can never reach the real model by accident
@@ -1277,8 +1825,8 @@ async function generateBet({ workspaceRoot, today, previousTitle = null, avoidTi
1277
1825
  // a blocked idea is not shown again as a patch
1278
1826
  const avoid = [...avoidTitles, ...blocked];
1279
1827
  const prompt = strategy === 'blocker'
1280
- ? buildPrompt(bundle, { today, previousTitle, blocker, avoidTitles: avoid })
1281
- : buildPrompt(bundle, { today, previousTitle, reason, previousAnswer, avoidTitles: avoid });
1828
+ ? buildPrompt(bundle, { today, previousTitle, blocker, avoidTitles: avoid, reworkOf, ownerNote })
1829
+ : buildPrompt(bundle, { today, previousTitle, reason, previousAnswer, avoidTitles: avoid, reworkOf, ownerNote });
1282
1830
  let r = await ask(prompt, attempt, strategy, avoid);
1283
1831
  if (r.failed) return { ok: false, reason: r.failed, bundle };
1284
1832
  save(r);
@@ -1406,6 +1954,38 @@ function missionObjective(bet, { owner = null, betId = null } = {}) {
1406
1954
  ].filter(Boolean).join(' ');
1407
1955
  }
1408
1956
 
1957
+ // How long a job may run: the hours its idea says it costs, plus a quarter
1958
+ // for slack, from 1 hour to a day, at 12 rounds of work an hour. An idea
1959
+ // that names no hours gets the deep tier's 3. Passed as --max-wall and
1960
+ // --max-ticks, so a long job is never cut off at the one-hour tier, with
1961
+ // --stop-when-done, so the hours are a ceiling and a job done early stops.
1962
+ const JOB_SLACK = 1.25;
1963
+ const JOB_MIN_HOURS = 1;
1964
+ const JOB_MAX_HOURS = 24;
1965
+ const JOB_DEFAULT_HOURS = 3;
1966
+ const TICKS_PER_HOUR = 12;
1967
+
1968
+ // a number as written: 5, 2.5, .5, or 1,000, never a tail of a longer one
1969
+ const NUM = '(?<![\\d.,])((?:\\d{1,3}(?:,\\d{3})+|\\d+)(?:\\.\\d+)?|\\.\\d+)';
1970
+ const numberOf = text => Number(String(text).replace(/,/g, ''));
1971
+
1972
+ function costHours(cost) {
1973
+ const t = String(cost || '').toLowerCase();
1974
+ const range = new RegExp(`${NUM}\\s*(?:-|to)\\s*${NUM}\\s*(?:hours?|hrs?)\\b`).exec(t);
1975
+ if (range) return numberOf(range[2]);
1976
+ const hours = new RegExp(`${NUM}\\s*(?:hours?|hrs?)\\b`).exec(t);
1977
+ if (hours) return numberOf(hours[1]);
1978
+ const minutes = new RegExp(`${NUM}\\s*(?:minutes?|mins?)\\b`).exec(t);
1979
+ if (minutes) return numberOf(minutes[1]) / 60;
1980
+ return null;
1981
+ }
1982
+
1983
+ function jobTime(bet) {
1984
+ const hours = costHours(bet && bet.cost);
1985
+ const wall = hours ? Math.min(JOB_MAX_HOURS, Math.max(JOB_MIN_HOURS, hours * JOB_SLACK)) : JOB_DEFAULT_HOURS;
1986
+ return { seconds: Math.round(wall * 3600), ticks: Math.ceil(wall * TICKS_PER_HOUR) };
1987
+ }
1988
+
1409
1989
  const MISSION_VERIFY = 'git diff --check';
1410
1990
  const MISSION_STOP = 'every team step is done with proof, and every your-yes step was asked, not done';
1411
1991
 
@@ -1414,11 +1994,15 @@ const MISSION_STOP = 'every team step is done with proof, and every your-yes ste
1414
1994
  function missionStartArgs(bet, { owner, lane }, betId = null) {
1415
1995
  // the owner steps are sealed into the mission record at start
1416
1996
  const seal = require('./yes-lock').sealFor(bet.steps);
1417
- return ['mission', 'start', missionObjective(bet, { owner, betId }), '--owner', owner, '--runner', 'claude', '--lane', lane, '--verify', bet.verify || MISSION_VERIFY, '--stop', MISSION_STOP, '--budget', 'long', ...(seal.length ? ['--yes-lock', JSON.stringify(seal)] : []), '--json'];
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'];
1418
1999
  }
1419
2000
 
1420
- function missionRunArgs(missionId) {
1421
- return ['mission', 'run', missionId, '--detach', '--json'];
2001
+ // the run gets the same time as the start, so the detached driver never
2002
+ // falls back to the one-hour default
2003
+ function missionRunArgs(missionId, bet) {
2004
+ const time = jobTime(bet);
2005
+ return ['mission', 'run', missionId, '--max-wall', String(time.seconds), '--max-ticks', String(time.ticks), '--stop-when-done', '--detach', '--json'];
1422
2006
  }
1423
2007
 
1424
2008
  function yesPingArgs(missionId, step) {
@@ -1452,6 +2036,8 @@ function makeCliRunner({ cliPath, cwd, env = process.env, timeoutMs = 60000 }) {
1452
2036
 
1453
2037
  const JOB_POLL_MS = 25000;
1454
2038
  const OPEN_MISSION = new Set(['planning', 'running', 'ready', 'paused', 'blocked']);
2039
+ // an open mission that has not moved in this long is not working
2040
+ const STALL_MS = 2 * 60 * 60 * 1000;
1455
2041
 
1456
2042
  // Every mission in this workspace, read-only, through the same reader the
1457
2043
  // mission command uses (it only reads .atris/state/missions.jsonl).
@@ -1470,6 +2056,39 @@ function defaultReadReceipt(mission, workspaceRoot) {
1470
2056
  }
1471
2057
  }
1472
2058
 
2059
+ // "14 hours", "3 days": how long a stalled job has been still
2060
+ function agoWords(ms) {
2061
+ const h = Math.max(1, Math.round(ms / 3600000));
2062
+ return h < 48 ? `${h} hours` : `${Math.round(h / 24)} days`;
2063
+ }
2064
+
2065
+ // "oct 21": a day the way the owner says it
2066
+ const MONTH_NAMES = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
2067
+ function monthDay(at) {
2068
+ const d = new Date(at);
2069
+ return `${MONTH_NAMES[d.getMonth()]} ${d.getDate()}`;
2070
+ }
2071
+
2072
+ // "10 minutes", "2 hours", "3 days": a plain span of time
2073
+ function spanWords(ms) {
2074
+ const m = Math.max(0, Math.round(ms / 60000));
2075
+ if (m < 1) return 'less than a minute';
2076
+ if (m < 60) return `${m} ${m === 1 ? 'minute' : 'minutes'}`;
2077
+ const h = Math.round(m / 60);
2078
+ if (h < 48) return `${h} ${h === 1 ? 'hour' : 'hours'}`;
2079
+ return `${Math.round(h / 24)} days`;
2080
+ }
2081
+
2082
+ // "about 4 hours left", "about 20 minutes left"
2083
+ function leftWords(ms) {
2084
+ const hours = ms / 3600000;
2085
+ if (hours >= 1) { const h = Math.round(hours); return `about ${h} ${h === 1 ? 'hour' : 'hours'} left`; }
2086
+ return `about ${Math.max(5, Math.round(ms / 300000) * 5)} minutes left`;
2087
+ }
2088
+
2089
+ // a job whose run hit its wall or its last round, as the mission records it
2090
+ const OUT_OF_TIME = /wall-exceeded|max-ticks-reached/;
2091
+
1473
2092
  function ageLabel(ms) {
1474
2093
  const m = Math.max(0, Math.floor(ms / 60000));
1475
2094
  if (m < 1) return 'just started';
@@ -1480,9 +2099,10 @@ function ageLabel(ms) {
1480
2099
  }
1481
2100
 
1482
2101
  // One started bet and its mission, as the deck sees it:
1483
- // working the mission is open and asks nothing of the owner
2102
+ // working the mission is open, asks nothing of the owner, and moved in the last 2 hours
1484
2103
  // yes the mission has open asks for the owner (one card per ask)
1485
- // done the mission finished (complete) or halted (stopped)
2104
+ // done the mission finished (complete) or halted (stopped), or it is
2105
+ // open but has not moved in over 2 hours (stalled, a halt)
1486
2106
  // gone no such mission here (it is not counted as working)
1487
2107
  // done carries what changed, the proof, and the check, only as the receipt
1488
2108
  // says them; with no receipt it says so.
@@ -1495,6 +2115,9 @@ function jobState(row, mission, receipt, nowMs) {
1495
2115
  .map((ask, index) => ({ index, text: String(typeof ask === 'string' ? ask : (ask && ask.text) || '').trim(), answered: Boolean(ask && typeof ask === 'object' && ask.answered_at) }))
1496
2116
  .filter(a => a.text && !a.answered);
1497
2117
  const status = String(mission.status || '');
2118
+ const outOfTime = OUT_OF_TIME.test([mission.last_tick_reason, mission.pause_reason, mission.stop_reason].join(' '));
2119
+ // a round that left a receipt made progress
2120
+ const progress = Boolean(mission.receipt_path);
1498
2121
  if (status === 'complete' || status === 'stopped') {
1499
2122
  const result = receipt && receipt.result && typeof receipt.result === 'object' ? receipt.result : null;
1500
2123
  const landing = result && result.landing && typeof result.landing === 'object' ? result.landing : null;
@@ -1512,13 +2135,43 @@ function jobState(row, mission, receipt, nowMs) {
1512
2135
  changed,
1513
2136
  proof: receipt ? (mission.receipt_path || null) : null,
1514
2137
  checked,
1515
- // a check that failed, or a halt nothing proved, goes back to the team on request
2138
+ // a check that failed, or a halt nothing proved, goes back to the team on its own
1516
2139
  failed: checked === 'failed' || (stopped && checked !== 'passed'),
2140
+ out_of_time: stopped && outOfTime,
2141
+ progress,
1517
2142
  };
1518
2143
  }
1519
2144
  const base = { bet_id: row.id, mission_id: mission.id, title, owner, since, running_for: since ? ageLabel(nowMs - since) : null, status };
1520
2145
  if (asks.length) return { ...base, kind: 'yes', asks };
1521
- return { ...base, kind: OPEN_MISSION.has(status) ? 'working' : 'gone' };
2146
+ if (!OPEN_MISSION.has(status)) return { ...base, kind: 'gone' };
2147
+ // paused because its time or its rounds ran out: it goes on now, no two-hour wait
2148
+ if (status === 'paused' && OUT_OF_TIME.test(String(mission.pause_reason || ''))) {
2149
+ return { kind: 'done', bet_id: row.id, mission_id: mission.id, title, owner, halted: true, stop_reason: 'it ran out of time', changed: null, proof: mission.receipt_path || null, checked: 'none', failed: true, out_of_time: true, progress };
2150
+ }
2151
+ // the newest of its last tick and its last update is when it last moved
2152
+ const moved = Math.max(...[mission.last_tick_at, mission.updated_at, mission.created_at].map(t => Date.parse(t || '') || 0));
2153
+ if (moved && nowMs - moved > STALL_MS) {
2154
+ return {
2155
+ kind: 'done',
2156
+ bet_id: row.id,
2157
+ mission_id: mission.id,
2158
+ title,
2159
+ owner,
2160
+ halted: true,
2161
+ stalled: true,
2162
+ stop_reason: `stopped moving ${agoWords(nowMs - moved)} ago, nothing finished`,
2163
+ changed: null,
2164
+ // the last tick's receipt, when there is one; a stalled job never reached its final check
2165
+ proof: mission.receipt_path || null,
2166
+ checked: 'none',
2167
+ failed: true,
2168
+ out_of_time: outOfTime,
2169
+ progress,
2170
+ };
2171
+ }
2172
+ const wall = Number(mission.max_wall_seconds) || Number(mission.budget_contract && mission.budget_contract.requested_seconds) || 0;
2173
+ const leftMs = wall && since ? since + wall * 1000 - nowMs : 0;
2174
+ return { ...base, kind: 'working', ...(leftMs > 0 ? { left: leftWords(leftMs) } : {}) };
1522
2175
  }
1523
2176
 
1524
2177
  // Who reviews finished work: the lead of the roster's review job (atris
@@ -1575,18 +2228,37 @@ function currentMember(betId, owner, handoffs) {
1575
2228
  return hits.length ? hits[hits.length - 1].to : owner;
1576
2229
  }
1577
2230
 
1578
- // Plain lines for a done card: what changed, the proof, the check.
1579
- function doneLines(job) {
1580
- const lines = [];
1581
- if (job.halted) lines.push(job.stop_reason ? `it stopped: ${job.stop_reason}` : 'it stopped before it finished');
1582
- lines.push(job.changed || 'it left no receipt, so there is nothing to show for what changed');
1583
- const r = job.review;
1584
- return {
1585
- lines: lines.slice(0, 2),
1586
- proof: job.proof ? `the receipt is at ${job.proof}` : 'no receipt was saved, so there is no proof yet',
1587
- check: job.checked === 'passed' ? 'it passed its check' : (job.checked === 'failed' ? 'it did not pass its check' : 'no check ran'),
1588
- review: !r ? 'not reviewed' : (r.verdict === 'pass' ? `${r.engine} reviewed it: pass. ${r.reason}` : (r.verdict === 'fail' ? `${r.engine} reviewed it: fail. ${r.reason}` : `the review by ${r.engine || 'the reviewer'} could not run: ${r.reason}`)),
1589
- };
2231
+ // a line that ends like a sentence
2232
+ function sentence(text) {
2233
+ const t = String(text || '').trim();
2234
+ return t && !/[.!?]$/.test(t) ? `${t}.` : t;
2235
+ }
2236
+
2237
+ // One short line of why for a choice: the idea's expected result, without
2238
+ // "if it works", ending at the first comma after about 40 characters, so it
2239
+ // is a whole clause and never cut mid word.
2240
+ function choiceWhy(expect) {
2241
+ const first = String(expect || '').trim().split(/\.\s/)[0].replace(/^if it works,\s*/i, '').replace(/[.\s]+$/, '');
2242
+ const cut = first.indexOf(', ', 40);
2243
+ return sentence(cut > 0 ? first.slice(0, cut) : clip(first, 110));
2244
+ }
2245
+
2246
+ // A business move, not plumbing: its target is a number we can read and
2247
+ // not one that only measures the team.
2248
+ function isBusinessMove(bet) {
2249
+ const metric = bet && bet.target && bet.target.metric;
2250
+ return Boolean(metric && METRICS[metric] && !TEAM_ONLY_METRICS.has(metric));
2251
+ }
2252
+
2253
+ // What went wrong on the last run, in words for the team's next mission.
2254
+ function failWhy(row) {
2255
+ if (row.mission_out_of_time) return 'ran out of time before its check passed; pick up where it left off';
2256
+ if (row.mission_stalled) return row.mission_stop_reason || 'stopped moving, nothing finished';
2257
+ if (row.mission_halted) return `halted: ${row.mission_stop_reason || 'it stopped before it finished'}`;
2258
+ const r = row.review;
2259
+ if (r && r.verdict === 'fail') return `did not pass: ${String(r.reason || '').replace(/[.\s]+$/, '')}`;
2260
+ if (r && r.verdict === 'could not run') return `could not be looked over: ${String(r.reason || '').replace(/[.\s]+$/, '')}`;
2261
+ return 'did not pass its check';
1590
2262
  }
1591
2263
 
1592
2264
  // Why a bet made under older rules can no longer be shown, or null. Only the
@@ -1600,6 +2272,10 @@ function queuedProblem(bet, team) {
1600
2272
  if (TEAM_ONLY_METRICS.has(metric)) return `its target ${metric} measures team plumbing, not a business move`;
1601
2273
  const vp = verifyProblem(bet.verify);
1602
2274
  if (vp) return vp;
2275
+ for (const k of ['title', 'why_now', 'expect']) {
2276
+ const word = ownerWord(bet[k]);
2277
+ if (word) return `its ${k} uses "${word}", a word the owner never reads`;
2278
+ }
1603
2279
  if (team) {
1604
2280
  const names = new Set(team.map(m => m.name));
1605
2281
  const i = bet.steps.findIndex(st => st && st.who === 'team' && !(typeof st.member === 'string' && names.has(st.member)));
@@ -1617,8 +2293,10 @@ const MAX_IN_FLIGHT = 1;
1617
2293
  const SESSION_IN_FLIGHT_CAP = 3;
1618
2294
  const STALE_MS = 6 * 60 * 60 * 1000;
1619
2295
  const RECENT_TITLES = 20;
1620
- // two failed background generations in a row stop the prefetch until the next tap
2296
+ // two failed background generations in a row pause the prefetch, and a timer
2297
+ // tries again after 10 minutes
1621
2298
  const MAX_BACKGROUND_FAILURES = 2;
2299
+ const BACKOFF_MS = 10 * 60 * 1000;
1622
2300
 
1623
2301
  // deps:
1624
2302
  // workspaceRoot, ledgerFile, attemptsFile where
@@ -1627,10 +2305,39 @@ const MAX_BACKGROUND_FAILURES = 2;
1627
2305
  // log(row) receipt row for gm-phone-actions.jsonl
1628
2306
  // now() clock
1629
2307
  // readMissions(root), readReceipt(m, root) mission state, read-only (tests stub)
1630
- // pollMs how often to read job status (off when unset)
2308
+ // pollMs how often to read job status and top up the queue (off when unset)
2309
+ // warmOnCreate start filling the queue at once (default on)
2310
+ // setTimer(fn, ms) the backoff timer (tests stub; unref'd by default)
2311
+ // readBundle() -> Promise<bundle>, the facts for question cards when no generation reads them
2312
+ // choices 2 or more ideas ready show as one card to pick from (default on)
2313
+ // driverAlive(mission) is a process really running this mission (tests stub)
2314
+ // scoreboard ask for the owner's number and its weekly value (gm phone turns it on)
2315
+ // suggestScoreboard(opts) -> { ok, choices }, the numbers to offer (tests stub)
2316
+ // readSaid(opts) -> { ok, kind, reply, suggestions }, what the owner typed (tests stub)
2317
+ // sayTimeoutMs how long a read may take before the plain fallback (15 s)
1631
2318
  function createBetDesk(deps) {
1632
2319
  const now = deps.now || Date.now;
1633
- const generate = deps.generate || (opts => generateBet({ ...opts, deps: deps.generatorDeps || {}, ledgerFile: deps.ledgerFile, attemptsFile: deps.attemptsFile }));
2320
+ const setTimer = deps.setTimer || ((fn, ms) => { const t = setTimeout(fn, ms); if (t.unref) t.unref(); return t; });
2321
+ // is a process really running this mission: its driver's pid is alive
2322
+ const driverAlive = deps.driverAlive || ((m) => {
2323
+ try { return require(path.join(__dirname, '..', 'commands', 'mission.js')).missionDriverHealth(m, deps.workspaceRoot).alive === true; } catch { return false; }
2324
+ });
2325
+ const focusFile = focusPath(path.dirname(deps.ledgerFile));
2326
+ const questionsFile = questionsPath(path.dirname(deps.ledgerFile));
2327
+ const scoreboardFile = scoreboardPath(path.dirname(deps.ledgerFile));
2328
+ // a desk with a stubbed idea writer never reaches the real reader either
2329
+ const readFn = deps.readSaid || (deps.generate
2330
+ ? (async () => ({ ok: false, reason: 'no reader here' }))
2331
+ : (opts => readSaid({ ...opts, workspaceRoot: deps.workspaceRoot, bundle: state.bundle, deps: deps.generatorDeps || {} })));
2332
+ const suggestNumbers = deps.suggestScoreboard || (deps.generate
2333
+ ? (() => new Promise(() => {}))
2334
+ : (opts => generateScoreboardChoices({ ...opts, deps: deps.generatorDeps || {}, ledgerFile: deps.ledgerFile, today: dayKey(now()) })));
2335
+ // a desk with a stubbed idea writer never reaches the real question model
2336
+ const writeQuestion = deps.generateQuestion || (deps.generate
2337
+ ? (() => new Promise(() => {}))
2338
+ : (opts => generateQuestion({ ...opts, deps: deps.generatorDeps || {}, ledgerFile: deps.ledgerFile, today: dayKey(now()) })));
2339
+ // the real writer starts on the next turn, so its first reads never hold up a tap
2340
+ const generate = deps.generate || (opts => new Promise(resolve => setImmediate(resolve)).then(() => generateBet({ ...opts, deps: deps.generatorDeps || {}, ledgerFile: deps.ledgerFile, attemptsFile: deps.attemptsFile })));
1634
2341
  const log = deps.log || (() => {});
1635
2342
  const state = {
1636
2343
  status: 'idle',
@@ -1639,13 +2346,28 @@ function createBetDesk(deps) {
1639
2346
  inFlight: 0,
1640
2347
  maxInFlight: 0,
1641
2348
  gen: null,
1642
- waiting: null,
2349
+ // a sharper idea asked for, made in the background like any other
1643
2350
  pendingSharper: null,
1644
2351
  topping: false,
1645
2352
  failures: 0,
2353
+ backoffUntil: 0,
2354
+ retry: null,
2355
+ // the last bundle a generation read, for the question cards
2356
+ bundle: deps.bundle || null,
2357
+ // questions passed over this session, so a re-tap asks the next one
2358
+ passedQuestions: new Set(),
2359
+ // the question writer: one at a time, resting after failures like the idea writer
2360
+ qInFlight: null,
2361
+ // the numbers the model suggests for the scoreboard question
2362
+ suggestions: null,
2363
+ // the conversation on the card or member talk on screen: { id, turns }
2364
+ thread: { id: null, turns: [] },
2365
+ sInFlight: null,
2366
+ qFailures: 0,
2367
+ qBackoffUntil: 0,
2368
+ qRetry: null,
1646
2369
  run: 0,
1647
2370
  error: null,
1648
- lastFailed: null,
1649
2371
  mission: null,
1650
2372
  started: [],
1651
2373
  // the started bets' missions as of the last read: working ones, and open
@@ -1655,6 +2377,8 @@ function createBetDesk(deps) {
1655
2377
  seenYes: new Set(),
1656
2378
  // reviews running now, by bet id
1657
2379
  reviews: new Map(),
2380
+ // failed jobs being sent back to the team now, by bet id
2381
+ retrying: new Map(),
1658
2382
  };
1659
2383
 
1660
2384
  const iso = () => new Date(now()).toISOString();
@@ -1758,21 +2482,34 @@ function createBetDesk(deps) {
1758
2482
  const finished = mission && (mission.status === 'complete' || mission.status === 'stopped');
1759
2483
  const receipt = finished ? (deps.readReceipt || defaultReadReceipt)(mission, deps.workspaceRoot) : null;
1760
2484
  const job = jobState(row, mission, receipt, now());
2485
+ // its process is alive (a laptop that slept makes the times look old): working, never resent
2486
+ if (job.kind === 'done' && (job.failed || job.halted) && driverAlive(mission)) {
2487
+ working.push({ kind: 'working', bet_id: row.id, mission_id: mission.id, title: job.title, owner: currentMember(row.id, job.owner, handoffs()) });
2488
+ continue;
2489
+ }
2490
+ // still because the guard holds a step for the owner's yes: that is a yes card, not a halt
2491
+ const held = job.stalled ? require('./yes-lock').askedYesLocks(lockDir, mission.id) : [];
2492
+ 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}` });
2494
+ continue;
2495
+ }
1761
2496
  // finished with nothing failed: the roster's reviewer looks before it counts as done
1762
2497
  if (job.kind === 'done' && !job.failed && !job.halted) {
1763
- const engine = reviewerName();
1764
- startReview(row, job, engine);
1765
- working.push({ ...job, kind: 'working', owner: `${engine || 'the reviewer'} (reviewing)`, running_for: null });
2498
+ startReview(row, job, reviewerName());
2499
+ working.push({ ...job, kind: 'working', owner: currentMember(row.id, job.owner, handoffs()) });
1766
2500
  continue;
1767
2501
  }
1768
2502
  if (job.kind === 'done') {
1769
2503
  record(row.id, {
1770
2504
  mission_result: job.failed ? 'failed' : 'done',
1771
- mission_status: mission.status,
2505
+ mission_status: job.stalled ? 'stalled' : mission.status,
1772
2506
  mission_checked: job.checked,
1773
2507
  mission_changed: job.changed,
1774
2508
  mission_proof: job.proof,
1775
2509
  mission_halted: job.halted,
2510
+ mission_stalled: Boolean(job.stalled),
2511
+ mission_out_of_time: Boolean(job.out_of_time),
2512
+ mission_progress: Boolean(job.progress),
1776
2513
  mission_stop_reason: job.stop_reason,
1777
2514
  finished_seen_at: iso(),
1778
2515
  });
@@ -1787,12 +2524,53 @@ function createBetDesk(deps) {
1787
2524
  for (const ask of job.asks) yes.push({ ...job, ask_index: ask.index, ask: ask.text, key: `${job.mission_id}:${ask.index}` });
1788
2525
  }
1789
2526
  }
1790
- state.jobs = { working, yes, at: now() };
2527
+ const expectOf = new Map(readLedger(deps.ledgerFile, deps.workspaceRoot).map(r => [r.id, r.bet && r.bet.expect]));
2528
+ state.jobs = { working, yes: yes.map(y => ({ ...y, expect: expectOf.get(y.bet_id) || null })), at: now() };
2529
+ retryFailed();
1791
2530
  return state.jobs;
1792
2531
  }
1793
2532
 
2533
+ // A job that halted, stalled, or did not pass is never a card: it goes back
2534
+ // to the team once on its own, and the second time it is closed quietly
2535
+ // with why. A bet made under older rules (plumbing) is closed at once.
2536
+ function retryFailed() {
2537
+ let missionMap;
2538
+ const missionOf = (id) => {
2539
+ if (missionMap === undefined) {
2540
+ try { missionMap = (deps.readMissions || defaultReadMissions)(deps.workspaceRoot); } catch { missionMap = null; }
2541
+ }
2542
+ return missionMap instanceof Map ? missionMap.get(id) : (missionMap || {})[id];
2543
+ };
2544
+ for (const row of readLedger(deps.ledgerFile, deps.workspaceRoot)) {
2545
+ if (row.mission_result !== 'failed' || row.done_seen_at || state.retrying.has(row.id)) continue;
2546
+ // its process turned out to be alive: it is working, so put it back and never resend it
2547
+ const live = row.mission_id ? missionOf(row.mission_id) : null;
2548
+ if (live && driverAlive(live)) {
2549
+ record(row.id, { mission_result: null, mission_halted: null, mission_stalled: null, mission_out_of_time: null, mission_progress: null, mission_stop_reason: null });
2550
+ log({ card: 'bet', choice: 'still_running', command: null, ran: 'in process', exit_code: 0, bet_id: row.id, mission_id: row.mission_id });
2551
+ continue;
2552
+ }
2553
+ const why = failWhy(row);
2554
+ const problem = row.bet ? queuedProblem(row.bet, team) : 'it is not a whole bet';
2555
+ if (problem) { close(row, problem); continue; }
2556
+ // up to two more rounds while each one makes progress; one with none closes
2557
+ const rounds = Number(row.auto_sent_back) || 0;
2558
+ if (rounds >= 2) { close(row, `${why}, after two more rounds`); continue; }
2559
+ if (rounds === 1 && !row.mission_progress) { close(row, `${why}, after one try again`); continue; }
2560
+ const run = sendBackJob(row, why)
2561
+ .catch(err => close(row, `it could not go back to the team: ${clip(String((err && err.message) || err), 120)}`))
2562
+ .finally(() => state.retrying.delete(row.id));
2563
+ state.retrying.set(row.id, run);
2564
+ }
2565
+ }
2566
+
2567
+ function close(row, reason) {
2568
+ record(row.id, { mission_result: 'closed', closed_reason: reason, closed_at: iso(), done_seen_at: iso() });
2569
+ log({ card: 'bet', choice: 'close', command: null, ran: 'in process', exit_code: 0, bet_id: row.id, mission_id: row.mission_id, why: reason });
2570
+ }
2571
+
1794
2572
  // One review per finished job at a time. A pass makes it done; a fail makes
1795
- // it failed (send back is offered); a review that cannot run is tried again
2573
+ // it failed (it goes back to the team on its own); a review that cannot run is tried again
1796
2574
  // on the next read, and after REVIEW_TRIES it counts as failed with why.
1797
2575
  function startReview(row, job, engine) {
1798
2576
  if (state.reviews.has(row.id)) return;
@@ -1811,29 +2589,20 @@ function createBetDesk(deps) {
1811
2589
  const v = res.code === 0 ? reviewVerdict(res.stdout) : null;
1812
2590
  if (!v) return gaveUp(res.code === 0 ? 'the reviewer gave no pass or fail' : `the review exited with code ${res.code}`);
1813
2591
  return finish({ mission_result: v.verdict === 'pass' ? 'done' : 'failed', review: { engine, ...v }, review_tries: tries });
1814
- }).catch(err => gaveUp(String((err && err.message) || err))).finally(() => state.reviews.delete(row.id));
2592
+ }).catch(err => gaveUp(String((err && err.message) || err))).finally(() => {
2593
+ state.reviews.delete(row.id);
2594
+ // a fail goes back to the team now, not on the next read
2595
+ retryFailed();
2596
+ });
1815
2597
  state.reviews.set(row.id, run);
1816
2598
  }
1817
2599
 
1818
- // Finished jobs not yet seen, from the ledger, so they survive a restart.
2600
+ // Finished business moves not yet seen, from the ledger, so they survive a
2601
+ // restart. Plumbing and failed work never become a card.
1819
2602
  function doneJobs() {
1820
2603
  return readLedger(deps.ledgerFile, deps.workspaceRoot)
1821
- .filter(row => (row.mission_result === 'done' || row.mission_result === 'failed') && !row.done_seen_at)
1822
- .map(row => ({
1823
- kind: 'done',
1824
- bet_id: row.id,
1825
- mission_id: row.mission_id,
1826
- title: (row.bet && row.bet.title) || row.id,
1827
- owner: row.owner || 'the team',
1828
- halted: Boolean(row.mission_halted),
1829
- stop_reason: row.mission_stop_reason || null,
1830
- changed: row.mission_changed || null,
1831
- proof: row.mission_proof || null,
1832
- checked: row.mission_checked || 'none',
1833
- failed: row.mission_result === 'failed',
1834
- review: row.review || null,
1835
- bet: row.bet,
1836
- }));
2604
+ .filter(row => row.mission_result === 'done' && !row.done_seen_at && isBusinessMove(row.bet))
2605
+ .map(row => ({ kind: 'done', bet_id: row.id, mission_id: row.mission_id, title: row.bet.title, owner: row.owner || 'the team', bet: row.bet }));
1837
2606
  }
1838
2607
 
1839
2608
  // The next card that is not a bet: a yes the owner owes comes first, then
@@ -1854,20 +2623,231 @@ function createBetDesk(deps) {
1854
2623
  log({ card: 'bet', choice: 'show', command: null, ran: 'in process', exit_code: 0, kind: item.kind, bet_id: item.job.bet_id });
1855
2624
  }
1856
2625
 
2626
+ // ---- the scoreboard: the owner's own number ----
2627
+
2628
+ const SCOREBOARD_ASK = 'what number tells you this is going well?';
2629
+ // questions kept here, not in the questions file
2630
+ const LOCAL_QUESTIONS = new Set([FALLBACK_QUESTION.key, 'scoreboard']);
2631
+ const LOWER_IS_BETTER = /\b(fewer|less|lower|reduce|down|churn|bugs?|costs?|spend|refunds?|complaints?)\b/i;
2632
+ const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
2633
+
2634
+ function fallbackNumbers() {
2635
+ const list = [];
2636
+ if (state.bundle && state.bundle.sources && state.bundle.sources.installs) list.push({ name: 'installs each week', up: true });
2637
+ list.push({ name: 'people who used it this week', up: true }, { name: 'money that came in this month', up: true });
2638
+ return list.slice(0, 3);
2639
+ }
2640
+
2641
+ // the model's suggestions, written once in the background while none is saved
2642
+ function pumpSuggestions() {
2643
+ if (!deps.scoreboard || state.suggestions || state.sInFlight || readScoreboard(scoreboardFile, deps.workspaceRoot)) return;
2644
+ state.sInFlight = Promise.resolve()
2645
+ .then(() => suggestNumbers({ workspaceRoot: deps.workspaceRoot, bundle: state.bundle }))
2646
+ .catch(err => ({ ok: false, reason: String((err && err.message) || err) }))
2647
+ .then((out) => {
2648
+ if (out && out.ok && Array.isArray(out.choices)) state.suggestions = out.choices;
2649
+ else log({ card: 'bet', choice: 'scoreboard', command: null, ran: 'in process', exit_code: 1, error: clip((out && out.reason) || 'no numbers', 160) });
2650
+ });
2651
+ }
2652
+
2653
+ // The card the owner owes before any idea: which number to watch, while
2654
+ // none is saved; then, for a number nobody can read on its own, its value
2655
+ // once a week, and whenever an idea is waiting on it to be scored.
2656
+ function askCard() {
2657
+ if (!deps.scoreboard) return null;
2658
+ const board = readScoreboard(scoreboardFile, deps.workspaceRoot);
2659
+ if (!board) {
2660
+ if (state.passedQuestions.has('scoreboard')) return null;
2661
+ const list = (state.suggestions || fallbackNumbers()).slice(0, 3);
2662
+ return {
2663
+ kind: 'question',
2664
+ id: 'question:scoreboard',
2665
+ 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
+ };
2667
+ }
2668
+ if (board.source !== 'reported' || state.passedQuestions.has('report')) return null;
2669
+ const last = board.reports[board.reports.length - 1] || null;
2670
+ const weekly = !last || now() - (Date.parse(last.at) || 0) >= WEEK_MS;
2671
+ const today = dayKey(now());
2672
+ const waiting = readLedger(deps.ledgerFile, deps.workspaceRoot).some(r => r.status === 'doing' && r.bet && r.bet.target && r.bet.target.metric === 'scoreboard'
2673
+ && String(r.bet.check_date || '') < today && !board.reports.some(x => String(x.at).slice(0, 10) >= r.bet.check_date));
2674
+ if (!weekly && !waiting) return null;
2675
+ return { kind: 'report', id: `report:${today}`, board, last };
2676
+ }
2677
+
2678
+ function showAsk(item) {
2679
+ if (item.kind === 'question') { showQuestion(item); return; }
2680
+ state.current = item;
2681
+ state.status = 'ready';
2682
+ state.mission = null;
2683
+ state.error = null;
2684
+ log({ card: 'bet', choice: 'show', command: null, ran: 'in process', exit_code: 0, kind: 'report' });
2685
+ }
2686
+
2687
+ // the number for the card on screen, saved beside the ledger
2688
+ function report({ value } = {}) {
2689
+ const item = onJob('report');
2690
+ if (!item) return { ok: false, reason: 'gone', message: 'there is no number asked for on screen' };
2691
+ const raw = String(value == null ? '' : value).replace(/,/g, '').trim();
2692
+ const v = Number(raw);
2693
+ if (!raw || !Number.isFinite(v)) return { ok: false, reason: 'bad_number', message: 'that is not a number' };
2694
+ appendLedger(scoreboardFile, { workspace: deps.workspaceRoot, kind: 'report', value: v, at: iso() });
2695
+ log({ card: 'bet', choice: 'report', command: null, ran: 'in process', exit_code: 0 });
2696
+ state.status = 'cleared';
2697
+ return { ok: true, ack: `saved: ${commas(v)}.` };
2698
+ }
2699
+
2700
+ // The questions written ahead, oldest first, not yet shown.
2701
+ function readyQuestions() {
2702
+ return readLedger(questionsFile, deps.workspaceRoot).filter(r => r.status === 'ready' && r.question);
2703
+ }
2704
+
2705
+ const answerCount = () => Object.keys(readFocus(focusFile, deps.workspaceRoot)).length;
2706
+
2707
+ // Every answer so far, oldest first, for both writers.
2708
+ function answersSoFar() {
2709
+ return Object.values(readFocus(focusFile, deps.workspaceRoot))
2710
+ .sort((a, b) => String(a.at).localeCompare(String(b.at)))
2711
+ .slice(-12)
2712
+ .map(a => ({ ask: a.ask, label: a.label }));
2713
+ }
2714
+
2715
+ // Keeps QUESTIONS_READY questions written ahead, one writer at a time.
2716
+ // After MAX_BACKGROUND_FAILURES in a row it rests for BACKOFF_MS.
2717
+ function pumpQuestions() {
2718
+ if (state.qInFlight) return state.qInFlight;
2719
+ if (readyQuestions().length >= QUESTIONS_READY) return null;
2720
+ if (state.qFailures >= MAX_BACKGROUND_FAILURES) {
2721
+ if (now() < state.qBackoffUntil) return null;
2722
+ state.qFailures = 0;
2723
+ }
2724
+ const avoid = readLedger(questionsFile, deps.workspaceRoot).slice(-20).map(r => r.question && r.question.ask).filter(Boolean);
2725
+ const opts = { workspaceRoot: deps.workspaceRoot, answers: answersSoFar(), avoid, bundle: state.bundle };
2726
+ const seen = answerCount();
2727
+ const run = Promise.resolve()
2728
+ .then(() => writeQuestion(opts))
2729
+ .catch(err => ({ ok: false, reason: String((err && err.message) || err) }))
2730
+ .then((out) => {
2731
+ state.qInFlight = null;
2732
+ // written before the newest answer while another is ready: write it again, knowing the answer
2733
+ if (out && out.ok && seen < answerCount() && readyQuestions().length >= 1) {
2734
+ log({ card: 'bet', choice: 'question', command: null, ran: 'in process', exit_code: 0, stale: 'written before the newest answer' });
2735
+ return pumpQuestions();
2736
+ }
2737
+ if (out && out.ok) {
2738
+ state.qFailures = 0;
2739
+ const id = `q-${dayKey(now())}-${crypto.randomBytes(4).toString('hex')}`;
2740
+ appendLedger(questionsFile, { id, workspace: deps.workspaceRoot, created_at: iso(), status: 'ready', answers_seen: seen, question: out.question });
2741
+ log({ card: 'bet', choice: 'question', command: null, ran: 'in process', exit_code: 0, question: id });
2742
+ } else {
2743
+ state.qFailures += 1;
2744
+ log({ card: 'bet', choice: 'question', command: null, ran: 'in process', exit_code: 1, error: clip((out && out.reason) || 'no question', 200) });
2745
+ if (state.qFailures >= MAX_BACKGROUND_FAILURES) {
2746
+ state.qBackoffUntil = now() + BACKOFF_MS;
2747
+ if (!state.qRetry) state.qRetry = setTimer(() => { state.qRetry = null; pumpQuestions(); }, BACKOFF_MS);
2748
+ return null;
2749
+ }
2750
+ }
2751
+ return pumpQuestions();
2752
+ });
2753
+ state.qInFlight = run;
2754
+ return run;
2755
+ }
2756
+
2757
+ // A question when no idea is ready: the next one written ahead from this
2758
+ // project, else the one generic question if it was not answered this week;
2759
+ // null when there is none to ask.
2760
+ function nextQuestion() {
2761
+ const row = readyQuestions().find(r => !state.passedQuestions.has(r.id));
2762
+ if (row) {
2763
+ const q = row.question;
2764
+ return { kind: 'question', id: `question:${row.id}`, question: { key: row.id, ask: q.ask, line: q.fact, choices: q.choices.map((label, i) => ({ id: `a${i + 1}`, label })) } };
2765
+ }
2766
+ const F = FALLBACK_QUESTION;
2767
+ const said = readFocus(focusFile, deps.workspaceRoot)[F.key];
2768
+ if (state.passedQuestions.has(F.key) || (said && now() - (Date.parse(said.at) || 0) < FOCUS_FRESH_MS)) return null;
2769
+ return { kind: 'question', id: `question:${F.key}`, question: { key: F.key, ask: F.ask, line: installsLine(state.bundle), choices: F.choices.map(c => ({ ...c })) } };
2770
+ }
2771
+
2772
+ // Two or three ideas ready: one card to pick from. Each is shown, so each
2773
+ // is recorded as proposed; the ones not picked are passed.
2774
+ const CHOICE_MAX = 3;
2775
+ function showChoice(items) {
2776
+ state.current = { kind: 'choice', id: `choice:${items.map(i => i.id).join(',')}`, items };
2777
+ state.status = 'ready';
2778
+ state.mission = null;
2779
+ state.error = null;
2780
+ for (const item of items) record(item.id, { status: 'proposed', shown_at: iso() });
2781
+ log({ card: 'bet', choice: 'show', command: null, ran: 'in process', exit_code: 0, kind: 'choice', bet_ids: items.map(i => i.id) });
2782
+ }
2783
+
2784
+ // one tap on a choice: that idea's full card, with do it as before
2785
+ function pick({ id } = {}) {
2786
+ const cur = onJob('choice');
2787
+ const item = cur && cur.items.find(i => i.id === id);
2788
+ if (!item) return { ok: false, reason: 'gone', message: 'there is nothing to pick on screen' };
2789
+ for (const other of cur.items) if (other !== item) record(other.id, { status: 'passed', note: 'not picked' });
2790
+ log({ card: 'bet', choice: 'pick', command: null, ran: 'in process', exit_code: 0, bet_id: item.id });
2791
+ state.current = item;
2792
+ state.status = 'ready';
2793
+ return { ok: true, ...view() };
2794
+ }
2795
+
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.' };
2798
+ function showWait() {
2799
+ state.current = { kind: 'wait', id: `wait:${now()}` };
2800
+ state.status = 'ready';
2801
+ state.mission = null;
2802
+ state.error = null;
2803
+ log({ card: 'bet', choice: 'show', command: null, ran: 'in process', exit_code: 0, kind: 'wait' });
2804
+ }
2805
+
2806
+ function showQuestion(item) {
2807
+ if (!LOCAL_QUESTIONS.has(item.question.key)) appendLedger(questionsFile, { id: item.question.key, workspace: deps.workspaceRoot, status: 'shown', shown_at: iso() });
2808
+ state.current = item;
2809
+ state.status = 'ready';
2810
+ state.mission = null;
2811
+ state.error = null;
2812
+ log({ card: 'bet', choice: 'show', command: null, ran: 'in process', exit_code: 0, kind: 'question', question: item.question.key });
2813
+ }
2814
+
1857
2815
  // a re-tap on a job card moves on: a yes waits for a later session, a done is seen
1858
2816
  function passJob(item) {
2817
+ if (item.kind === 'wait') return;
2818
+ if (item.kind === 'report') { state.passedQuestions.add('report'); return; }
2819
+ if (item.kind === 'choice') {
2820
+ for (const i of item.items) record(i.id, { status: 'passed', note: 'not picked' });
2821
+ log({ card: 'bet', choice: 'pass', command: null, ran: 'in process', exit_code: 0, kind: 'choice' });
2822
+ return;
2823
+ }
2824
+ if (item.kind === 'question') {
2825
+ state.passedQuestions.add(item.question.key);
2826
+ if (!LOCAL_QUESTIONS.has(item.question.key)) appendLedger(questionsFile, { id: item.question.key, workspace: deps.workspaceRoot, status: 'passed' });
2827
+ log({ card: 'bet', choice: 'pass', command: null, ran: 'in process', exit_code: 0, kind: 'question', question: item.question.key });
2828
+ return;
2829
+ }
1859
2830
  if (item.kind === 'yes') state.seenYes.add(item.job.key);
1860
2831
  if (item.kind === 'done') record(item.job.bet_id, { done_seen_at: iso() });
1861
2832
  log({ card: 'bet', choice: 'pass', command: null, ran: 'in process', exit_code: 0, kind: item.kind, bet_id: item.job.bet_id });
1862
2833
  }
1863
2834
 
1864
2835
  pollJobs();
2836
+ // the same read also tops up the queue, so it stays full with no page open
1865
2837
  if (deps.pollMs) {
1866
- const timer = setInterval(pollJobs, deps.pollMs);
2838
+ const timer = setInterval(() => { pollJobs(); pump(); }, deps.pollMs);
1867
2839
  if (timer.unref) timer.unref();
1868
2840
  }
1869
2841
 
1870
- function startGeneration({ sharperOf = null } = {}) {
2842
+ // after MAX_BACKGROUND_FAILURES in a row the prefetch rests, and one timer
2843
+ // tries again; a tap in the meantime still gets a card at once
2844
+ function backOff() {
2845
+ state.backoffUntil = now() + BACKOFF_MS;
2846
+ if (state.retry) return;
2847
+ state.retry = setTimer(() => { state.retry = null; pump(); }, BACKOFF_MS);
2848
+ }
2849
+
2850
+ function startGeneration({ sharperOf = null, reworkOf = null, ownerNote = null } = {}) {
1871
2851
  if (state.inFlight >= MAX_IN_FLIGHT || state.inFlight >= SESSION_IN_FLIGHT_CAP) return null;
1872
2852
  state.inFlight += 1;
1873
2853
  state.maxInFlight = Math.max(state.maxInFlight, state.inFlight);
@@ -1878,7 +2858,7 @@ function createBetDesk(deps) {
1878
2858
  // started now, not on a later tick, so the in-flight count is exact
1879
2859
  let started;
1880
2860
  try {
1881
- started = Promise.resolve(generate({ workspaceRoot: deps.workspaceRoot, today: dayKey(now()), previousTitle: sharperOf, avoidTitles: avoidTitles(), emit }));
2861
+ started = Promise.resolve(generate({ workspaceRoot: deps.workspaceRoot, today: dayKey(now()), previousTitle: sharperOf, avoidTitles: avoidTitles(), ...(ownerNote ? { reworkOf, ownerNote } : {}), emit, onBundle: (b) => { state.bundle = b; } }));
1882
2862
  } catch (err) {
1883
2863
  started = Promise.reject(err);
1884
2864
  }
@@ -1887,54 +2867,48 @@ function createBetDesk(deps) {
1887
2867
  .then((out) => {
1888
2868
  state.inFlight -= 1;
1889
2869
  if (state.gen === gen) state.gen = null;
1890
- const waited = state.waiting === gen;
2870
+ if (out.bundle) state.bundle = out.bundle;
1891
2871
  if (!out.ok) {
2872
+ // a failure stays in the log; the owner never waits on it or sees it
1892
2873
  log({ card: 'bet', choice: 'generate', command: null, ran: 'in process', exit_code: 1, error: clip(out.reason, 200) });
1893
2874
  emit({ kind: 'failed', line: out.reason });
1894
- if (waited) {
1895
- state.waiting = null;
1896
- state.lastFailed = gen.events;
1897
- state.status = 'failed';
1898
- state.error = out.reason;
1899
- } else {
1900
- state.failures += 1;
1901
- }
2875
+ state.failures += 1;
2876
+ if (state.failures >= MAX_BACKGROUND_FAILURES) backOff();
1902
2877
  return pump();
1903
2878
  }
1904
2879
  state.failures = 0;
1905
2880
  const item = { id: newId(), bet: out.bet, created_at: now() };
1906
- record(item.id, { created_at: iso(), status: 'queued', bet: out.bet, previous_title: sharperOf });
2881
+ record(item.id, { created_at: iso(), status: 'queued', bet: out.bet, previous_title: sharperOf, ...(ownerNote ? { rework_of: reworkOf, owner_note: ownerNote } : {}) });
1907
2882
  log({ card: 'bet', choice: 'generate', command: null, ran: 'in process', exit_code: 0, bet_id: item.id });
1908
2883
  emit({ kind: 'done', line: 'bet ready' });
1909
- if (waited) {
1910
- state.waiting = null;
1911
- show(item);
1912
- } else {
1913
- state.queue.push(item);
1914
- }
2884
+ // the sharper or reworked idea the owner asked for is the next shown
2885
+ if (sharperOf || ownerNote) state.queue.unshift(item);
2886
+ else state.queue.push(item);
1915
2887
  return pump();
1916
2888
  });
1917
2889
  return gen;
1918
2890
  }
1919
2891
 
1920
2892
  // Keeps the queue topped up: below 2 it refills to 3, one generation at a
1921
- // time. A tap that is waiting, or a sharper idea, goes first.
2893
+ // time. A sharper idea the owner asked for goes first.
1922
2894
  function pump() {
2895
+ pumpQuestions();
2896
+ pumpSuggestions();
1923
2897
  dropStale();
1924
2898
  if (state.inFlight >= MAX_IN_FLIGHT) return state.gen && state.gen.done;
1925
- if (state.pendingSharper && state.status === 'thinking') {
1926
- const sharperOf = state.pendingSharper;
2899
+ if (state.pendingSharper) {
2900
+ const asked = state.pendingSharper;
1927
2901
  state.pendingSharper = null;
1928
- state.waiting = startGeneration({ sharperOf });
1929
- return state.waiting && state.waiting.done;
1930
- }
1931
- if (state.status === 'thinking' && !state.waiting) {
1932
- state.waiting = startGeneration();
1933
- return state.waiting && state.waiting.done;
2902
+ const gen = startGeneration(asked);
2903
+ return gen && gen.done;
1934
2904
  }
1935
2905
  if (state.queue.length < REFILL_BELOW) state.topping = true;
1936
2906
  if (state.queue.length >= QUEUE_TARGET) state.topping = false;
1937
- if (!state.topping || state.failures >= MAX_BACKGROUND_FAILURES) return null;
2907
+ if (!state.topping) return null;
2908
+ if (state.failures >= MAX_BACKGROUND_FAILURES) {
2909
+ if (now() < state.backoffUntil) return null;
2910
+ state.failures = 0;
2911
+ }
1938
2912
  const gen = startGeneration();
1939
2913
  return gen && gen.done;
1940
2914
  }
@@ -1945,49 +2919,39 @@ function createBetDesk(deps) {
1945
2919
  return view();
1946
2920
  }
1947
2921
 
1948
- // A tap on the orb: the bet on screen is passed over, the next one in the
1949
- // queue shows at once, or the page waits on the generation in flight.
2922
+ // A tap on the orb: the card on screen is passed over, and the next one
2923
+ // shows at once: a yes, a finished job, a queued bet, a question, or a
2924
+ // plain card that says the next idea is being written. A tap never waits.
2925
+ // sharper passes the bet over and asks the background for a sharper one.
1950
2926
  function next({ sharper = false } = {}) {
1951
- if (state.status === 'thinking' || state.status === 'starting') return { ok: true, busy: true, ...view() };
1952
- state.failures = 0;
2927
+ if (state.status === 'starting') return { ok: true, busy: true, ...view() };
1953
2928
  const onScreen = state.status === 'ready' && state.current ? state.current : null;
1954
2929
  if (onScreen && onScreen.kind && onScreen.kind !== 'bet') {
1955
2930
  passJob(onScreen);
1956
2931
  } else if (onScreen) {
1957
2932
  record(onScreen.id, { status: 'passed', ...(sharper ? { note: 'asked for a sharper idea' } : {}) });
1958
2933
  log({ card: 'bet', choice: sharper ? 'sharper' : 'pass', command: null, ran: 'in process', exit_code: 0, bet_id: onScreen.id });
2934
+ if (sharper) state.pendingSharper = { sharperOf: onScreen.bet.title };
1959
2935
  } else {
1960
2936
  log({ card: 'bet', choice: 'think', command: null, ran: 'in process', exit_code: 0 });
1961
2937
  }
1962
2938
  dropStale();
1963
- // a yes or a finished job comes before the next bet, never in the middle of one
1964
- if (!sharper) {
1965
- if (now() - state.jobs.at > 5000) pollJobs();
1966
- const job = nextJobCard();
1967
- if (job) {
1968
- showJob(job);
1969
- pump();
1970
- return { ok: true, ...view() };
2939
+ if (now() - state.jobs.at > 5000) pollJobs();
2940
+ const job = nextJobCard();
2941
+ const ask = job ? null : askCard();
2942
+ if (job) showJob(job);
2943
+ else if (ask) showAsk(ask);
2944
+ else if (deps.choices !== false && state.queue.length >= 2) showChoice(state.queue.splice(0, CHOICE_MAX));
2945
+ else if (state.queue.length) show(state.queue.shift());
2946
+ else {
2947
+ const question = nextQuestion();
2948
+ if (question) showQuestion(question);
2949
+ else {
2950
+ showWait();
2951
+ // the card says an idea is being written, so one is, even mid backoff
2952
+ if (!state.gen) { state.failures = 0; state.backoffUntil = 0; }
1971
2953
  }
1972
2954
  }
1973
- if (sharper) {
1974
- state.pendingSharper = onScreen && onScreen.bet ? onScreen.bet.title : null;
1975
- state.current = null;
1976
- state.status = 'thinking';
1977
- state.waiting = null;
1978
- pump();
1979
- return { ok: true, ...view() };
1980
- }
1981
- if (state.queue.length) {
1982
- show(state.queue.shift());
1983
- pump();
1984
- return { ok: true, ...view() };
1985
- }
1986
- state.current = null;
1987
- state.status = 'thinking';
1988
- state.error = null;
1989
- // the generation already running is the one this tap waits on
1990
- state.waiting = state.gen || null;
1991
2955
  pump();
1992
2956
  return { ok: true, ...view() };
1993
2957
  }
@@ -1995,7 +2959,7 @@ function createBetDesk(deps) {
1995
2959
  const isBet = () => state.status === 'ready' && state.current && (!state.current.kind || state.current.kind === 'bet');
1996
2960
 
1997
2961
  function skip() {
1998
- if (!isBet()) return { ok: false, reason: 'gone', message: 'there is no bet to skip' };
2962
+ if (!isBet()) return { ok: false, reason: 'gone', message: 'there is no idea on screen to skip' };
1999
2963
  record(state.current.id, { status: 'skipped' });
2000
2964
  log({ card: 'bet', choice: 'skip', command: null, ran: 'in process', exit_code: 0, bet_id: state.current.id });
2001
2965
  state.status = 'skipped';
@@ -2003,7 +2967,7 @@ function createBetDesk(deps) {
2003
2967
  }
2004
2968
 
2005
2969
  async function doIt() {
2006
- if (!isBet()) return { ok: false, reason: 'gone', message: 'there is no bet to start' };
2970
+ if (!isBet()) return { ok: false, reason: 'gone', message: 'there is no idea on screen to start' };
2007
2971
  const { id, bet } = state.current;
2008
2972
  const pick = pickOwner(bet, deps.workspaceRoot);
2009
2973
  const startArgs = missionStartArgs(bet, pick, id);
@@ -2016,19 +2980,21 @@ function createBetDesk(deps) {
2016
2980
  if (res.code !== 0 || !missionId) {
2017
2981
  state.status = 'ready';
2018
2982
  const why = clip((res.stderr || res.stdout || '').trim().split('\n').pop(), 160);
2019
- return { ok: false, reason: 'start_failed', message: `the mission did not start${why ? `: ${why}` : ''}` };
2983
+ log({ card: 'bet', choice: 'do', command: null, ran: 'in process', exit_code: res.code, bet_id: id, error: why || null });
2984
+ return { ok: false, reason: 'start_failed', message: 'it did not get going. try again in a minute.' };
2020
2985
  }
2021
2986
  const owner = (started.mission && started.mission.owner) || pick.owner;
2022
2987
  record(id, { status: 'doing', mission_id: missionId, owner });
2023
2988
  lockYesSteps(missionId, id, bet.steps);
2024
- const runArgs = missionRunArgs(missionId);
2989
+ const runArgs = missionRunArgs(missionId, bet);
2025
2990
  res = await deps.runCli(runArgs);
2026
2991
  log({ card: 'bet', choice: 'do', command: display(runArgs), exit_code: res.code, bet_id: id, mission_id: missionId });
2027
2992
  const ran = parseJsonOut(res.stdout) || {};
2028
2993
  let line;
2029
- if (res.code === 0 && ran.action === 'mission_run_detached') line = `${owner} has it. the mission is running now.`;
2030
- else if (ran.reason === 'human_asks_open') line = `${owner} has it, and is waiting on your yes before it starts.`;
2031
- else line = `${owner} has the mission, but it did not start running (exit code ${res.code}). run it from the mac: atris mission run ${missionId}`;
2994
+ // what the owner reads; the command and exit code stay in the log
2995
+ if (res.code === 0 && ran.action === 'mission_run_detached') line = `${owner} has it and is starting now.`;
2996
+ else if (ran.reason === 'human_asks_open') line = `${owner} has it, and is waiting on your yes before starting.`;
2997
+ else line = `${owner} has it, but has not started yet. it can be started from the mac.`;
2032
2998
  state.status = 'started';
2033
2999
  state.mission = { id: missionId, owner, line, commands: [display(startArgs), display(runArgs)] };
2034
3000
  state.started.push({ bet_id: id, mission_id: missionId, owner, steps: bet.steps, yes: {} });
@@ -2048,35 +3014,67 @@ function createBetDesk(deps) {
2048
3014
  return { ok: true, ack: 'nice.' };
2049
3015
  }
2050
3016
 
2051
- // send back: a job that did not pass goes to the team again as a new mission
2052
- // on the same bet, with what went wrong in the objective
2053
- async function sendBack() {
2054
- const item = onJob('done');
2055
- if (!item || !item.job.failed || !item.job.bet) return { ok: false, reason: 'gone', message: 'there is no failed job on screen' };
2056
- const { job } = item;
2057
- const why = doneLines(job);
2058
- const again = { ...job.bet, why_now: `the last run ended with: ${why.check}${job.halted && job.stop_reason ? `, ${job.stop_reason}` : ''}. try again. ${job.bet.why_now}` };
3017
+ // 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 } = {}) {
3020
+ const item = onJob('question');
3021
+ if (!item) return { ok: false, reason: 'gone', message: 'there is no question on screen' };
3022
+ const picked = item.question.choices.find(c => c.id === choice);
3023
+ if (!picked) return { ok: false, reason: 'bad_choice', message: 'that is not one of the answers' };
3024
+ const q = item.question;
3025
+ if (q.key === 'scoreboard') {
3026
+ let name;
3027
+ let up;
3028
+ if (picked.other) {
3029
+ name = String(text || '').trim();
3030
+ if (!name) return { ok: false, reason: 'empty', message: 'say the number in a few words' };
3031
+ if (name.length > 60) return { ok: false, reason: 'too_long', message: 'keep it under 60 characters' };
3032
+ up = !LOWER_IS_BETTER.test(name);
3033
+ } else {
3034
+ const pick = q.numbers[Number(picked.id.slice(1)) - 1];
3035
+ name = pick.name;
3036
+ up = pick.up !== false;
3037
+ }
3038
+ // installs on a workspace with npm numbers are read on their own
3039
+ const source = /\b(installs?|downloads?)\b/i.test(name) && state.bundle && state.bundle.sources && state.bundle.sources.installs ? 'installs' : 'reported';
3040
+ appendLedger(scoreboardFile, { workspace: deps.workspaceRoot, kind: 'pick', name, up, source, at: iso() });
3041
+ log({ card: 'bet', choice: 'scoreboard', command: null, ran: 'in process', exit_code: 0, source });
3042
+ state.status = 'cleared';
3043
+ return { ok: true, ack: `got it: ${name}.` };
3044
+ }
3045
+ appendLedger(focusFile, { workspace: deps.workspaceRoot, key: q.key, answer: picked.id, label: picked.label, ask: q.ask, at: iso() });
3046
+ if (q.key !== FALLBACK_QUESTION.key) appendLedger(questionsFile, { id: q.key, workspace: deps.workspaceRoot, status: 'answered' });
3047
+ log({ card: 'bet', choice: 'answer', command: null, ran: 'in process', exit_code: 0, question: q.key, answer: picked.id });
3048
+ state.status = 'cleared';
3049
+ // the next questions are written knowing this answer: any written before
3050
+ // it are retired, except the oldest, so the next tap is still instant
3051
+ const count = answerCount();
3052
+ readyQuestions().slice(1).filter(r => (Number(r.answers_seen) || 0) < count).forEach((r) => {
3053
+ appendLedger(questionsFile, { id: r.id, workspace: deps.workspaceRoot, status: 'stale' });
3054
+ });
3055
+ pumpQuestions();
3056
+ return { ok: true, ack: `got it: ${picked.label}.` };
3057
+ }
3058
+
3059
+ // Send back: a job that did not pass goes to the team again as a new
3060
+ // mission on the same bet, with what went wrong in the objective.
3061
+ async function sendBackJob(row, why) {
3062
+ const again = { ...row.bet, why_now: `the last run ${why}. try again. ${row.bet.why_now}` };
2059
3063
  const pick = pickOwner(again, deps.workspaceRoot);
2060
3064
  const display = args => require('./gm-phone').displayCommand(args);
2061
- state.status = 'starting';
2062
- const startArgs = missionStartArgs(again, pick, job.bet_id);
3065
+ const startArgs = missionStartArgs(again, pick, row.id);
2063
3066
  let res = await deps.runCli(startArgs);
2064
- log({ card: 'bet', choice: 'send_back', command: display(startArgs), exit_code: res.code, bet_id: job.bet_id });
3067
+ log({ card: 'bet', choice: 'send_back', command: display(startArgs), exit_code: res.code, bet_id: row.id });
2065
3068
  const started = parseJsonOut(res.stdout);
2066
3069
  const missionId = started && (started.mission ? started.mission.id : started.mission_id);
2067
- if (res.code !== 0 || !missionId) {
2068
- state.status = 'ready';
2069
- return { ok: false, reason: 'start_failed', message: 'it did not go back to the team; try again from the mac' };
2070
- }
3070
+ if (res.code !== 0 || !missionId) return close(row, `it could not go back to the team (exit code ${res.code})`);
2071
3071
  const owner = (started.mission && started.mission.owner) || pick.owner;
2072
- record(job.bet_id, { mission_id: missionId, owner, mission_result: null, done_seen_at: iso(), sent_back_at: iso() });
2073
- lockYesSteps(missionId, job.bet_id, again.steps);
2074
- const runArgs = missionRunArgs(missionId);
3072
+ record(row.id, { mission_id: missionId, owner, mission_result: null, mission_halted: null, mission_stalled: null, mission_out_of_time: null, mission_progress: null, mission_stop_reason: null, review_tries: 0, done_seen_at: null, sent_back_at: iso(), auto_sent_back: (Number(row.auto_sent_back) || 0) + 1 });
3073
+ lockYesSteps(missionId, row.id, again.steps);
3074
+ const runArgs = missionRunArgs(missionId, again);
2075
3075
  res = await deps.runCli(runArgs);
2076
- log({ card: 'bet', choice: 'send_back', command: display(runArgs), exit_code: res.code, bet_id: job.bet_id, mission_id: missionId });
2077
- state.status = 'cleared';
2078
- pollJobs();
2079
- return { ok: true, ack: `sent back. ${owner} has it again.` };
3076
+ log({ card: 'bet', choice: 'send_back', command: display(runArgs), exit_code: res.code, bet_id: row.id, mission_id: missionId });
3077
+ return null;
2080
3078
  }
2081
3079
 
2082
3080
  // yes: answers the mission's open ask through atris decide, matched by
@@ -2115,10 +3113,14 @@ function createBetDesk(deps) {
2115
3113
  return { ok: true, ack: `${job.owner}: got your yes` };
2116
3114
  }
2117
3115
 
3116
+ // Two sentences for the owner: what is going on, and why it matters to
3117
+ // the business. Nothing about how the work was checked.
2118
3118
  function publicJob(item) {
2119
3119
  const j = item.job;
2120
- if (item.kind === 'yes') return { title: j.title, owner: j.owner, ask: j.ask, ...(j.command ? { command: j.command } : {}) };
2121
- return { title: j.title, owner: j.owner, failed: j.failed, halted: j.halted, ...doneLines(j) };
3120
+ const expect = item.kind === 'yes' ? j.expect : j.bet && j.bet.expect;
3121
+ const why = expect ? ` ${sentence(expect)}` : '';
3122
+ if (item.kind === 'yes') return { what: j.ask, why: `${j.owner} is waiting on your yes for this.${why}` };
3123
+ return { what: j.title, why: `this is done.${why}` };
2122
3124
  }
2123
3125
 
2124
3126
  // Decision cards for the steps that need the owner's yes, for every bet
@@ -2161,7 +3163,8 @@ function createBetDesk(deps) {
2161
3163
  }
2162
3164
 
2163
3165
  function view() {
2164
- const events = state.waiting ? state.waiting.events : (state.status === 'failed' && state.lastFailed ? state.lastFailed : []);
3166
+ // the writer's progress stays in the log; the page never reads it
3167
+ const events = [];
2165
3168
  const cur = state.current;
2166
3169
  const kind = cur ? (cur.kind || 'bet') : null;
2167
3170
  return {
@@ -2169,8 +3172,19 @@ function createBetDesk(deps) {
2169
3172
  id: cur ? cur.id : null,
2170
3173
  kind,
2171
3174
  bet: kind === 'bet' ? cur.bet : null,
2172
- job: kind && kind !== 'bet' ? publicJob(cur) : null,
2173
- working: state.jobs.working.map(j => ({ title: j.title, owner: j.owner, running_for: j.running_for })),
3175
+ job: kind === 'yes' || kind === 'done' ? publicJob(cur) : (kind === 'wait' ? { ...WAIT_CARD } : null),
3176
+ question: kind === 'question' ? { ask: cur.question.ask, line: cur.question.line, choices: cur.question.choices } : null,
3177
+ report: kind === 'report' ? {
3178
+ what: `${cur.board.name}: what is it this week?`,
3179
+ why: cur.last ? `last time it was ${commas(cur.last.value)}, on ${monthDay(cur.last.at)}.` : 'one number a week shows if this is going well.',
3180
+ } : null,
3181
+ // each option: its title, and one plain line of why, the result it expects
3182
+ 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 } : {}) })),
3184
+ // the conversation on this card so far
3185
+ thread: cur && state.thread.id === cur.id ? state.thread.turns.slice() : [],
3186
+ // the footer count: only members really working
3187
+ team_working: teamTable().rows.filter(r => r.status === 'working').length,
2174
3188
  events: events.map(e => ({ i: e.i, kind: e.kind, line: e.line, ...(e.key ? { key: e.key } : {}), ...(e.n != null ? { n: e.n } : {}) })),
2175
3189
  error: state.error,
2176
3190
  queue: state.queue.length,
@@ -2178,14 +3192,280 @@ function createBetDesk(deps) {
2178
3192
  };
2179
3193
  }
2180
3194
 
2181
- // resolves when every review running now has finished (tests and shutdown)
2182
- const settle = () => Promise.all([...state.reviews.values()]);
3195
+ // ---- free text: each card is one turn in a conversation ----
3196
+
3197
+ function threadFor(id) {
3198
+ if (state.thread.id !== id) state.thread = { id, turns: [] };
3199
+ return state.thread;
3200
+ }
3201
+ const endThread = () => { state.thread = { id: null, turns: [] }; };
3202
+ function addTurns(t, ...turns) {
3203
+ t.turns = [...t.turns, ...turns].slice(-THREAD_TURNS);
3204
+ }
3205
+ const plainReply = text => (text && !ownerWord(text) ? text : null);
3206
+ const plainSuggestions = list => (Array.isArray(list) ? list : [])
3207
+ .filter(x => typeof x === 'string' && x.trim() && x.length <= 40 && !ownerWord(x) && !PROCESS_TALK.test(x))
3208
+ .slice(0, 3);
3209
+
3210
+ // a read, within its time; a slow or failed one is a failed read
3211
+ function readWithin(opts) {
3212
+ let timer;
3213
+ const slow = new Promise((resolve) => {
3214
+ timer = setTimeout(() => resolve({ ok: false, reason: 'the read took too long' }), deps.sayTimeoutMs || SAY_READ_MS);
3215
+ if (timer.unref) timer.unref();
3216
+ });
3217
+ return Promise.race([Promise.resolve().then(() => readFn(opts)).catch(err => ({ ok: false, reason: String((err && err.message) || err) })), slow])
3218
+ .finally(() => clearTimeout(timer));
3219
+ }
3220
+
3221
+ // the card as the reader sees it
3222
+ function cardForReader(v) {
3223
+ if (v.kind === 'question') return { kind: 'question', title: v.question.ask, detail: v.question.line, buttons: v.question.choices.map(c => c.label) };
3224
+ 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
+ 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: [] };
3227
+ return { kind: v.kind, title: v.job ? v.job.what : '', detail: v.job ? v.job.why : '', buttons: [] };
3228
+ }
3229
+
3230
+ // Read first. A question back or an unclear message is answered on the
3231
+ // same card and saves nothing; an answer or an instruction is acted on.
3232
+ // A failed or slow read falls back to the plain line and saves nothing.
3233
+ const QUESTION_HINT = 'his message ends with a question mark: it is a question back, not an answer';
3234
+ async function readTurn(t, text, card) {
3235
+ const thread = t.turns.slice(-THREAD_TURNS);
3236
+ let read = await readWithin({ text, card, thread });
3237
+ // words that end in a question mark are never saved as an answer: ask once
3238
+ // more with that said, and if it still reads as an answer, fall back
3239
+ if (read && read.ok && read.kind === 'answer' && /\?\s*$/.test(text)) {
3240
+ read = await readWithin({ text, card, thread, hint: QUESTION_HINT });
3241
+ if (read && read.ok && read.kind === 'answer') read = { ok: false, reason: 'a question read as an answer' };
3242
+ }
3243
+ if (read && read.ok && (read.kind === 'answer' || read.kind === 'instruction')) return { act: read };
3244
+ const kind = read && read.ok ? read.kind : 'unclear';
3245
+ const reply = (read && read.ok && plainReply(read.reply)) || SAY_FALLBACK;
3246
+ const suggestions = read && read.ok && reply !== SAY_FALLBACK ? plainSuggestions(read.suggestions) : [];
3247
+ addTurns(t, { who: 'owner', text }, { who: 'orb', text: reply });
3248
+ return { stay: { ok: true, stay: true, kind: reply === SAY_FALLBACK ? 'unclear' : kind, reply, ...(suggestions.length ? { suggestions } : {}), thread: t.turns.slice() } };
3249
+ }
3250
+
3251
+ // On a question it is the answer; on an idea, a note the writer reworks it
3252
+ // with (the rework is the next idea); on a choice or the plain card, the
3253
+ // 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 } = {}) {
3255
+ const said = String(text || '').trim();
3256
+ if (!said) return { ok: false, reason: 'empty', message: 'say something first' };
3257
+ if (said.length > SAY_MAX) return { ok: false, reason: 'too_long', message: `keep it under ${SAY_MAX} characters` };
3258
+ const cur = state.status === 'ready' ? state.current : null;
3259
+ if (!cur) return { ok: false, reason: 'gone', message: 'there is no card on screen' };
3260
+ const kind = cur.kind || 'bet';
3261
+ log({ card: 'bet', choice: 'say', command: null, ran: 'in process', exit_code: 0, kind, chars: said.length });
3262
+ const t = threadFor(cur.id);
3263
+ let read = { kind: 'instruction', reply: '' };
3264
+ // a second tap or a note acts on what was already read
3265
+ if (!confirm && !note) {
3266
+ const turn = await readTurn(t, said, cardForReader(view()));
3267
+ // the card moved on while it was read (a tap, do it): drop the reply
3268
+ if (state.current !== cur || state.status !== 'ready') return { ok: false, reason: 'gone', message: 'that card changed, so nothing was done' };
3269
+ if (turn.stay) return turn.stay;
3270
+ read = turn.act;
3271
+ }
3272
+ if (kind === 'question') {
3273
+ const q = cur.question;
3274
+ if (read.kind === 'answer') {
3275
+ if (q.key === 'scoreboard') {
3276
+ const out = answer({ choice: 'other', text: said });
3277
+ if (out.ok) endThread();
3278
+ return out;
3279
+ }
3280
+ appendLedger(focusFile, { workspace: deps.workspaceRoot, key: q.key, answer: 'own', label: said, ask: q.ask, at: iso() });
3281
+ if (!LOCAL_QUESTIONS.has(q.key)) appendLedger(questionsFile, { id: q.key, workspace: deps.workspaceRoot, status: 'answered' });
3282
+ state.status = 'cleared';
3283
+ endThread();
3284
+ pumpQuestions();
3285
+ return { ok: true, ack: `got it: ${said.replace(/[.\s]+$/, '')}.` };
3286
+ }
3287
+ // an instruction on a question: a note for the next idea
3288
+ state.pendingSharper = { ownerNote: said };
3289
+ state.status = 'cleared';
3290
+ endThread();
3291
+ pump();
3292
+ return { ok: true, ack: plainReply(read.reply) || 'got it. the next idea will fit that.' };
3293
+ }
3294
+ if (kind === 'yes' || kind === 'done') {
3295
+ const out = await steer({ member: cur.job.owner, text: said, confirm, note });
3296
+ if (out.ok) endThread();
3297
+ return out;
3298
+ }
3299
+ if (kind === 'bet') {
3300
+ record(cur.id, { status: 'passed', note: 'reworked with your note', owner_note: said });
3301
+ state.pendingSharper = { reworkOf: cur.bet.title, ownerNote: said };
3302
+ state.status = 'cleared';
3303
+ endThread();
3304
+ pump();
3305
+ return { ok: true, ack: plainReply(read.reply) || 'got it. reworking that idea with your note.' };
3306
+ }
3307
+ if (kind === 'choice') passJob(cur);
3308
+ state.pendingSharper = { ownerNote: said };
3309
+ state.status = 'cleared';
3310
+ endThread();
3311
+ pump();
3312
+ return { ok: true, ack: plainReply(read.reply) || 'got it. the next idea will fit that.' };
3313
+ }
3314
+
3315
+ // The talk card with one member: the same conversation. A question back is
3316
+ // answered from what the table knows; an instruction steers them.
3317
+ async function talk({ member, text, confirm = false, note = false } = {}) {
3318
+ const name = String(member || '');
3319
+ if (!(team || []).some(m => m.name === name)) return { ok: false, reason: 'gone', message: 'there is no such member on the team' };
3320
+ const said = String(text || '').trim();
3321
+ if (!said) return { ok: false, reason: 'empty', message: 'say something first' };
3322
+ if (said.length > SAY_MAX) return { ok: false, reason: 'too_long', message: `keep it under ${SAY_MAX} characters` };
3323
+ const t = threadFor(`member:${name}`);
3324
+ if (!confirm && !note) {
3325
+ const row = teamTable().rows.find(r => r.member === name);
3326
+ 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: [] });
3327
+ if (turn.stay) return turn.stay;
3328
+ }
3329
+ const out = await steer({ member: name, text: said, confirm, note });
3330
+ if (out.ok) endThread();
3331
+ return out;
3332
+ }
3333
+
3334
+ // ---- the team table: who is on what, from real state ----
3335
+
3336
+ // A member is working only when a process is really running its mission,
3337
+ // or the mission moved in the last few minutes. A handoff that no process
3338
+ // picked up is waiting to start. Members with nothing in the last day are
3339
+ // left out.
3340
+ const RECENT_MS = 24 * 60 * 60 * 1000;
3341
+ const MOVING_MS = 5 * 60 * 1000;
3342
+ const STATUS_ORDER = ['waiting for your yes', 'working', 'waiting to start', 'stopped', 'done', 'idle'];
3343
+ let teamCache = null;
3344
+ function teamTable() {
3345
+ if (teamCache && now() - teamCache.at < 10000) return teamCache.table;
3346
+ const names = new Set((team || []).map(m => m.name));
3347
+ let missions;
3348
+ try { missions = (deps.readMissions || defaultReadMissions)(deps.workspaceRoot); } catch { missions = null; }
3349
+ const list = missions instanceof Map ? [...missions.values()] : Object.values(missions || {});
3350
+ const ideaTitle = new Map(readLedger(deps.ledgerFile, deps.workspaceRoot).filter(r => r.mission_id && r.bet).map(r => [r.mission_id, r.bet.title]));
3351
+ const best = new Map();
3352
+ const put = (member, row, at) => {
3353
+ if (!names.has(member)) return;
3354
+ const rank = STATUS_ORDER.indexOf(row.status);
3355
+ const cur = best.get(member);
3356
+ if (!cur || rank < cur.rank || (rank === cur.rank && at > cur.at)) best.set(member, { row: { member, ...row }, rank, at });
3357
+ };
3358
+ for (const m of list) {
3359
+ if (!m || !m.owner) continue;
3360
+ const moved = Math.max(...[m.last_tick_at, m.updated_at, m.created_at].map(t => Date.parse(t || '') || 0));
3361
+ const age = now() - moved;
3362
+ if (!moved || age > RECENT_MS) continue;
3363
+ const created = Date.parse(m.created_at || '') || moved;
3364
+ const on = ideaTitle.get(m.id) || missionTitle(m.objective);
3365
+ const status = String(m.status || '');
3366
+ if (status === 'complete') { put(m.owner, { on, status: 'done', time: `finished ${spanWords(age)} ago` }, moved); continue; }
3367
+ if (status === 'stopped') { put(m.owner, { on, status: 'stopped', time: `stopped ${spanWords(age)} ago` }, moved); continue; }
3368
+ if (!OPEN_MISSION.has(status)) continue;
3369
+ const asks = (Array.isArray(m.human_asks) ? m.human_asks : []).some(a => a && !(typeof a === 'object' && a.answered_at))
3370
+ || require('./yes-lock').askedYesLocks(lockDir, m.id).length > 0;
3371
+ if (asks) { put(m.owner, { on, status: 'waiting for your yes', time: `waiting ${spanWords(age)}` }, moved); continue; }
3372
+ if (driverAlive(m) || age <= MOVING_MS) {
3373
+ const wall = Number(m.max_wall_seconds) || Number(m.budget_contract && m.budget_contract.requested_seconds) || 0;
3374
+ const left = wall ? created + wall * 1000 - now() : 0;
3375
+ put(m.owner, { on, status: 'working', time: left > 0 ? leftWords(left) : `at it for ${spanWords(now() - created)}` }, moved);
3376
+ continue;
3377
+ }
3378
+ if (!m.last_tick_at) { put(m.owner, { on, status: 'waiting to start', time: `waiting ${spanWords(now() - created)}` }, moved); continue; }
3379
+ put(m.owner, { on, status: 'stopped', time: `stopped ${spanWords(age)} ago` }, moved);
3380
+ }
3381
+ for (const hnd of (deps.readHandoffs || defaultReadHandoffs)(deps.workspaceRoot)) {
3382
+ const at = Date.parse((hnd && (hnd.closed_at || hnd.created_at)) || '') || 0;
3383
+ if (!hnd || !hnd.to || !at || now() - at > RECENT_MS) continue;
3384
+ const on = clip(String(hnd.summary || '').replace(/^\[[^\]]*\]\s*/, ''), 100);
3385
+ if (hnd.status === 'open') put(hnd.to, { on, status: 'waiting to start', time: `waiting ${spanWords(now() - at)}` }, at);
3386
+ else put(hnd.to, { on, status: 'idle', time: `free for ${spanWords(now() - at)}` }, at);
3387
+ }
3388
+ const rows = [...best.values()]
3389
+ .sort((a, b) => a.rank - b.rank || a.row.member.localeCompare(b.row.member))
3390
+ .map(x => x.row);
3391
+ const table = { rows };
3392
+ teamCache = { at: now(), table };
3393
+ return table;
3394
+ }
3395
+
3396
+ // A word to a member from its talk card or a card on screen. The phone
3397
+ // never calls a steer that can start anyone: it uses --no-ignite, which
3398
+ // only reaches a live run and otherwise starts nothing, and then the page
3399
+ // offers a second tap or a note. A confirmed start runs in the background
3400
+ // (--detach) and reports started or not from that. The owner's words go
3401
+ // after --, behind "the owner says:", so they are never read as options.
3402
+ const SAY_MAX = 280;
3403
+ function steeringNote(name, said) {
3404
+ const file = path.join(deps.workspaceRoot, '.atris', 'state', 'steering.jsonl');
3405
+ appendLedger(file, { schema: 'atris.steering.v1', id: `steer-${crypto.randomBytes(4).toString('hex')}`, kind: 'preference', status: 'active', scope: { member: name }, raw: said, memory: [said], created_at: iso(), source: 'gm-phone' });
3406
+ }
3407
+
3408
+ async function steer({ member, text, confirm = false, note = false } = {}) {
3409
+ const name = String(member || '');
3410
+ if (!(team || []).some(m => m.name === name)) return { ok: false, reason: 'gone', message: 'there is no such member on the team' };
3411
+ const said = String(text || '').trim();
3412
+ if (!said) return { ok: false, reason: 'empty', message: 'say something first' };
3413
+ if (said.length > SAY_MAX) return { ok: false, reason: 'too_long', message: `keep it under ${SAY_MAX} characters` };
3414
+ if (note) {
3415
+ steeringNote(name, said);
3416
+ log({ card: 'bet', choice: 'steer_note', command: null, ran: 'in process', exit_code: 0, member: name, chars: said.length });
3417
+ return { ok: true, ack: `saved for ${name}'s next run.` };
3418
+ }
3419
+ const words = `the owner says: ${said}`;
3420
+ const args = ['member', 'steer', name, confirm === true ? '--detach' : '--no-ignite', '--json', '--', words];
3421
+ const res = await deps.runCli(args);
3422
+ const out = parseJsonOut(res.stdout) || {};
3423
+ log({ card: 'bet', choice: confirm === true ? 'steer_start' : 'steer', command: `atris member steer ${name} ${args[3]} --json -- "<${said.length} characters>"`, exit_code: res.code, member: name, chars: said.length, action: out.action || null });
3424
+ teamCache = null;
3425
+ if (res.code === 0 && out.action === 'member_steered') return { ok: true, ack: `sent to ${name}.` };
3426
+ if (confirm === true) {
3427
+ if (res.code === 0 && out.action === 'member_run_detached') return { ok: true, ack: `${name} is starting on it.` };
3428
+ // refused before it began: say why, in its plain words
3429
+ 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]+$/, '')}.` };
3430
+ return { ok: false, reason: 'not_started', message: `${name} did not start. try again in a minute.` };
3431
+ }
3432
+ if (out.action === 'member_not_running' || res.code === 3) return { ok: false, reason: 'needs_confirm', message: `tap again to start ${name} on this` };
3433
+ return { ok: false, reason: 'steer_failed', message: 'it did not reach them. try again in a minute.' };
3434
+ }
3435
+
3436
+ // resolves when every review and send back running now has finished (tests and shutdown)
3437
+ async function settle() {
3438
+ while (state.reviews.size || state.retrying.size) await Promise.all([...state.reviews.values(), ...state.retrying.values()]);
3439
+ }
3440
+
3441
+ // the queue starts filling now, not when the page first opens; with the
3442
+ // queue already full, the facts for a question card are read on their own
3443
+ if (deps.warmOnCreate !== false) {
3444
+ pump();
3445
+ if (!state.inFlight && deps.readBundle) {
3446
+ Promise.resolve().then(() => deps.readBundle()).then((b) => { if (b && !state.bundle) state.bundle = b; }).catch(() => {});
3447
+ }
3448
+ }
2183
3449
 
2184
- return { warm, next, skip, doIt, nice, sendBack, answerYes, pollJobs, settle, yesCards, recordYes, view, state };
3450
+ return { warm, next, pick, say, talk, report, teamTable, steer, skip, doIt, nice, answerYes, answer, pollJobs, settle, yesCards, recordYes, view, state };
2185
3451
  }
2186
3452
 
2187
3453
  module.exports = {
3454
+ BACKOFF_MS,
3455
+ FALLBACK_QUESTION,
3456
+ readScoreboard,
3457
+ readSaid,
3458
+ scoreboardPath,
3459
+ choiceWhy,
2188
3460
  JOB_POLL_MS,
3461
+ OWNER_BANNED,
3462
+ jobTime,
3463
+ generateQuestion,
3464
+ readProjectContext,
3465
+ validateQuestion,
3466
+ STALL_MS,
3467
+ focusPath,
3468
+ readFocus,
2189
3469
  queuedProblem,
2190
3470
  MODEL_TIMEOUT_MS,
2191
3471
  STALE_MS,
@@ -2213,6 +3493,7 @@ module.exports = {
2213
3493
  readLedger,
2214
3494
  releaseFact,
2215
3495
  runModel,
3496
+ stopWriters,
2216
3497
  scoreBets,
2217
3498
  skepticCommand,
2218
3499
  validateBet,