atris 3.74.7 → 3.74.9

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
@@ -3,9 +3,9 @@
3
3
  // gm bet: one new, specific business move nobody wrote down yet, with a
4
4
  // prediction, a check date, and exact steps. The owner taps "think" on the
5
5
  // phone page; this module reads real local sources (each read is a progress
6
- // event with one real fact), asks a smart model for one bet as strict JSON,
7
- // checks it with plain rules, and keeps every bet in a ledger. "do it" turns
8
- // the bet into a real atris mission.
6
+ // event with one real fact), asks three writers at once for one bet each,
7
+ // checks each with plain rules and a skeptic as it lands, and keeps every bet
8
+ // in a ledger. "do it" turns the bet into a real atris mission.
9
9
  //
10
10
  // Everything that touches the outside world is passed in (the model command,
11
11
  // the mission runner, the clock, the file paths), so tests stub it.
@@ -712,10 +712,10 @@ function npmName(pkg) {
712
712
  return encodeURIComponent(pkg).replace('%40', '@');
713
713
  }
714
714
 
715
- function defaultFetchJson(url, { timeoutMs = 10000 } = {}) {
715
+ function defaultFetchJson(url, { timeoutMs = 10000, signal = null } = {}) {
716
716
  const https = require('https');
717
717
  return new Promise((resolve, reject) => {
718
- const req = https.get(url, { headers: { 'user-agent': 'atris-gm-bet' } }, (res) => {
718
+ const req = https.get(url, { headers: { 'user-agent': 'atris-gm-bet' }, ...(signal ? { signal } : {}) }, (res) => {
719
719
  if (res.statusCode !== 200) {
720
720
  res.resume();
721
721
  reject(new Error(`status ${res.statusCode}`));
@@ -849,6 +849,65 @@ function statusSentence(row) {
849
849
  return `${row.status}: ${on}, ${row.time}`;
850
850
  }
851
851
 
852
+ // The ovr cell from one member's entry in gm-season memberRatings: the
853
+ // rating, "new" with 1 or 2 finished, two dots with none finished.
854
+ function ovrCell(rated) {
855
+ if (!rated || !rated.finished) return NO_RATING;
856
+ return rated.ovr == null ? 'new' : String(rated.ovr);
857
+ }
858
+
859
+ // The engine pick the runner makes for a member, with the roster read once.
860
+ function teamEngineResolver(workspaceRoot) {
861
+ const reg = require('./engine-registry');
862
+ const { resolveEngineForMember } = require('./member-engine');
863
+ let rosterState = null;
864
+ try { rosterState = reg.readRosterState(workspaceRoot); } catch { rosterState = null; }
865
+ return name => resolveEngineForMember(name, workspaceRoot, { quiet: true, ...(rosterState ? { rosterState } : {}) });
866
+ }
867
+
868
+ // role, ovr, engine, and parked for each name: the team page's cells, and
869
+ // the same cells the board and the standup draw in a terminal, so every
870
+ // team view says the same thing. ratings is gm-season memberRatings over one
871
+ // loaded season; resolve gives a member's engine pick. A name with no card
872
+ // and no roster line is not on the team, so no engine shows for it.
873
+ function teamCells(workspaceRoot, names = [], { ratings = new Map(), resolve = null } = {}) {
874
+ const { memberTypeOf } = require('./member-type');
875
+ const pick = resolve || teamEngineResolver(workspaceRoot);
876
+ const out = new Map();
877
+ for (const name of names) {
878
+ const key = String(name || '');
879
+ if (!key || /[\\/]/.test(key) || key === '.' || key === '..') {
880
+ out.set(key, { role: NO_RATING, ovr: ovrCell(ratings.get(key)), engine: NO_RATING, parked: false });
881
+ continue;
882
+ }
883
+ const card = memberCardFacts(workspaceRoot, key);
884
+ const kind = memberTypeOf({ type: card.type });
885
+ let engine = kind.type === 'human' ? 'person' : NO_RATING;
886
+ if (kind.runnable) {
887
+ let picked = null;
888
+ try { picked = pick(key); } catch { picked = null; }
889
+ engine = picked && picked.found === false ? NO_RATING : engineWords(picked);
890
+ }
891
+ out.set(key, { role: memberRoleWords(key, card), ovr: ovrCell(ratings.get(key)), engine, parked: card.parked });
892
+ }
893
+ return out;
894
+ }
895
+
896
+ // The team table as a divided box for a terminal, in the team page's
897
+ // columns: one line per { member, status } with that member's cells from
898
+ // teamCells. A long role or status wraps to fit the width (the exact model
899
+ // never splits); null when even that does not fit.
900
+ function teamBox(rows, cells, { width } = {}) {
901
+ const { renderBorderedTable } = require('./text-table');
902
+ const wraps = new Set(['role', 'status']);
903
+ const columns = require('./gm-card').TEAM_COLUMNS.map(header => ({ header, wrap: wraps.has(header) }));
904
+ const blank = { role: NO_RATING, ovr: NO_RATING, engine: NO_RATING };
905
+ return renderBorderedTable(columns, (rows || []).map((row) => {
906
+ const c = (cells && cells.get(row.member)) || blank;
907
+ return [row.member, c.role, c.ovr, c.engine, row.status];
908
+ }), { width });
909
+ }
910
+
852
911
  // A verify command that always passes proves nothing. Binary checks only:
853
912
  // every piece of it is one of these, or it swallows failure with || true.
854
913
  const TRIVIAL_VERIFY = [
@@ -1104,13 +1163,46 @@ function bundleMetrics(bundle) {
1104
1163
  // a real fact (or says it was left out). A missing source is omitted, never
1105
1164
  // invented. Past bets that are due are scored first, so the ledger the model
1106
1165
  // reads already has their results.
1107
- async function readBundle(workspaceRoot, { deps = {}, emit = () => {}, ledgerFile, today = dayKey(Date.now()) } = {}) {
1166
+ // The value of p, or late() once the deadline passes or the signal stops it
1167
+ // first; p then goes on alone with its errors dropped. With no deadline it is
1168
+ // just p. The timer is cleared on every way out, and unref'd as a backstop,
1169
+ // so it never holds the process open.
1170
+ function byDeadline(p, deadline, late, signal = null) {
1171
+ const work = Promise.resolve(p);
1172
+ if (!deadline) return work;
1173
+ let timer;
1174
+ let onStop = null;
1175
+ const cut = new Promise((resolve) => {
1176
+ const give = () => { clearTimeout(timer); work.catch(() => {}); resolve(late()); };
1177
+ timer = setTimeout(give, Math.max(0, deadline - Date.now()));
1178
+ if (timer.unref) timer.unref();
1179
+ if (signal) {
1180
+ if (signal.aborted) give();
1181
+ else { onStop = give; signal.addEventListener('abort', onStop, { once: true }); }
1182
+ }
1183
+ });
1184
+ return Promise.race([work, cut]).finally(() => {
1185
+ clearTimeout(timer);
1186
+ if (onStop) signal.removeEventListener('abort', onStop);
1187
+ });
1188
+ }
1189
+
1190
+ // deadline (ms since 1970, optional): the sources are read by then. Scoring
1191
+ // past bets and each npm read that is still going at the deadline are left
1192
+ // out (scoring finishes on its own), so a slow network never eats the time
1193
+ // the writers need.
1194
+ // signal (optional): a stop ends the waits at once, and the npm reads with them.
1195
+ async function readBundle(workspaceRoot, { deps = {}, emit = () => {}, ledgerFile, today = dayKey(Date.now()), deadline = null, signal = null } = {}) {
1108
1196
  const ledger = ledgerFile || ledgerPath();
1197
+ const LATE = Symbol('late');
1109
1198
  try {
1110
- await (deps.scoreBets || scoreBets)(workspaceRoot, { ledgerFile: ledger, today, deps, emit });
1199
+ const scored = await byDeadline((deps.scoreBets || scoreBets)(workspaceRoot, { ledgerFile: ledger, today, deps, emit }), deadline, () => LATE, signal);
1200
+ if (scored === LATE) emit({ kind: 'skip', key: 'score', line: 'scoring past bets took too long, so it finishes on its own' });
1111
1201
  } catch {
1112
1202
  // scoring is a side job; a failure never blocks the next bet
1113
1203
  }
1204
+ // an npm read gets what is left before the deadline, at most its usual 10 seconds
1205
+ const fetchOpts = () => (deadline ? { timeoutMs: Math.max(1, Math.min(10000, deadline - Date.now())), ...(signal ? { signal } : {}) } : undefined);
1114
1206
  const bundle = { workspace: workspaceRoot, today, sources: {}, facts: [] };
1115
1207
  const add = (key, fact, data) => {
1116
1208
  bundle.sources[key] = { fact, ...data };
@@ -1164,7 +1256,9 @@ async function readBundle(workspaceRoot, { deps = {}, emit = () => {}, ledgerFil
1164
1256
  if (pkg) {
1165
1257
  try {
1166
1258
  // a year of days: last week, the week before, and the last 8 full weeks
1167
- installBody = await fetchJson(`https://api.npmjs.org/downloads/range/last-year/${npmName(pkg)}`);
1259
+ const url = `https://api.npmjs.org/downloads/range/last-year/${npmName(pkg)}`;
1260
+ installBody = await byDeadline(fetchOpts() ? fetchJson(url, fetchOpts()) : fetchJson(url), deadline, () => LATE, signal);
1261
+ if (installBody === LATE) throw new Error('npm took too long');
1168
1262
  installs = installWeeks(installBody);
1169
1263
  } catch {
1170
1264
  installs = null;
@@ -1189,7 +1283,8 @@ async function readBundle(workspaceRoot, { deps = {}, emit = () => {}, ledgerFil
1189
1283
  let version = null;
1190
1284
  if (pkg) {
1191
1285
  try {
1192
- const latest = await fetchJson(`https://registry.npmjs.org/${npmName(pkg)}/latest`);
1286
+ const url = `https://registry.npmjs.org/${npmName(pkg)}/latest`;
1287
+ const latest = await byDeadline(fetchOpts() ? fetchJson(url, fetchOpts()) : fetchJson(url), deadline, () => null, signal);
1193
1288
  version = latest && typeof latest.version === 'string' && /^\d+\.\d+\.\d+/.test(latest.version) ? latest.version : null;
1194
1289
  } catch {
1195
1290
  version = null;
@@ -1340,7 +1435,7 @@ function metricChoices(bundle) {
1340
1435
  });
1341
1436
  }
1342
1437
 
1343
- function buildPrompt(bundle, { today, previousTitle = null, reason = null, previousAnswer = null, blocker = null, avoidTitles = [], reworkOf = null, ownerNote = null } = {}) {
1438
+ function buildPrompt(bundle, { today, previousTitle = null, reason = null, previousAnswer = null, blocker = null, avoidTitles = [], reworkOf = null, ownerNote = null, angle = null, concern = null } = {}) {
1344
1439
  const choices = metricChoices(bundle);
1345
1440
  const lines = [
1346
1441
  'You are the strategist for a small software business. Below is everything we can read about it right now.',
@@ -1375,6 +1470,10 @@ function buildPrompt(bundle, { today, previousTitle = null, reason = null, previ
1375
1470
  // not one more step on the old idea
1376
1471
  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.`);
1377
1472
  if (previousTitle) lines.push('', `Make it sharper, more specific, and different from: ${previousTitle}`);
1473
+ // the owner asked to sharpen an idea a second look worried about
1474
+ if (previousTitle && concern) lines.push(`A second look at that idea worried: ${clip(String(concern).replace(/[.\s]+$/, ''), 240)}. The new idea must answer that worry.`);
1475
+ // each writer in the swarm leans a different way, so the drafts differ
1476
+ if (angle) lines.push('', angle);
1378
1477
  // what the owner typed on a card
1379
1478
  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.`);
1380
1479
  else if (ownerNote) lines.push('', `The owner said: "${clip(ownerNote, 280)}". The next idea must fit that.`);
@@ -1400,6 +1499,24 @@ function claudeArgs(prompt, model = MODEL) {
1400
1499
  return ['-p', prompt, '--model', model, '--output-format', 'stream-json', '--verbose', '--include-partial-messages', '--tools', '', '--no-session-persistence', '--strict-mcp-config'];
1401
1500
  }
1402
1501
 
1502
+ // The writer and the skeptic think briefly: on the real prompt, low effort
1503
+ // answered in 17 to 19 seconds against 21 at the default, and its answers
1504
+ // passed the plain-rule checks.
1505
+ const IDEA_EFFORT = 'low';
1506
+ function writerCommand() {
1507
+ return { bin: 'claude', args: p => [...claudeArgs(p, MODEL), '--effort', IDEA_EFFORT] };
1508
+ }
1509
+
1510
+ // The writer and the skeptic get every fact in the prompt, so they run in an
1511
+ // empty folder of their own: started in the workspace, the model also loads
1512
+ // that project's agent instructions and memory, about 11,000 tokens nobody
1513
+ // asked for, and its first word came 10 seconds later (measured).
1514
+ function quietDir() {
1515
+ const dir = path.join(require('os').tmpdir(), 'atris-gm-ideas');
1516
+ try { fs.mkdirSync(dir, { recursive: true }); } catch { /* the model can still start in a missing folder's parent */ }
1517
+ return dir;
1518
+ }
1519
+
1403
1520
  // Runs the model in its own process group. On timeout the whole group is
1404
1521
  // killed, so nothing it started keeps running. Streams real progress (tokens
1405
1522
  // thought, characters written) through onProgress.
@@ -1407,17 +1524,38 @@ function claudeArgs(prompt, model = MODEL) {
1407
1524
  // stops; otherwise a stopped server leaves them running on their own.
1408
1525
  const LIVE_WRITERS = new Map();
1409
1526
 
1527
+ // SIGTERM now, then SIGKILL for the same groups after the grace, so a child
1528
+ // that ignores SIGTERM does not outlive the server either.
1410
1529
  function stopWriters() {
1411
1530
  let n = 0;
1412
- for (const [pid, kill] of LIVE_WRITERS) {
1531
+ const groups = [...LIVE_WRITERS];
1532
+ for (const [pid, kill] of groups) {
1413
1533
  try { kill(-pid, 'SIGTERM'); n += 1; } catch { /* already gone */ }
1414
1534
  }
1415
1535
  LIVE_WRITERS.clear();
1536
+ if (groups.length) {
1537
+ const t = setTimeout(() => {
1538
+ for (const [pid, kill] of groups) {
1539
+ try { kill(-pid, 'SIGKILL'); } catch { /* already gone */ }
1540
+ }
1541
+ }, STOP_GRACE_MS);
1542
+ if (t.unref) t.unref();
1543
+ }
1416
1544
  return n;
1417
1545
  }
1418
1546
 
1419
- 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) } = {}) {
1547
+ // A signal (an AbortSignal) stops the call early: the whole process group
1548
+ // gets SIGTERM, then SIGKILL if it is still there after STOP_GRACE_MS, and
1549
+ // the call resolves once the process is gone, so nothing it started is left.
1550
+ const STOP_GRACE_MS = 1500;
1551
+ const STOPPED_REASON = 'stopped: another idea was picked first';
1552
+
1553
+ 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), signal = null } = {}) {
1420
1554
  return new Promise((resolve) => {
1555
+ if (signal && signal.aborted) {
1556
+ resolve({ ok: false, reason: STOPPED_REASON, stopped: true });
1557
+ return;
1558
+ }
1421
1559
  const args = typeof command.args === 'function' ? command.args(prompt) : [...(command.args || []), prompt];
1422
1560
  let child;
1423
1561
  try {
@@ -1434,15 +1572,36 @@ function runModel(prompt, { command = { bin: 'claude', args: claudeArgs }, cwd,
1434
1572
  let result = null;
1435
1573
  let thinking = 0;
1436
1574
  let timedOut = false;
1575
+ let stopped = false;
1576
+ let hardKill = null;
1437
1577
  let done = false;
1438
- const timer = setTimeout(() => {
1439
- timedOut = true;
1578
+ const killGroup = (sig) => {
1440
1579
  try {
1441
- kill(-child.pid, 'SIGKILL');
1580
+ kill(-child.pid, sig);
1442
1581
  } catch {
1443
- try { child.kill('SIGKILL'); } catch { /* already gone */ }
1582
+ try { child.kill(sig); } catch { /* already gone */ }
1444
1583
  }
1584
+ };
1585
+ const timer = setTimeout(() => {
1586
+ timedOut = true;
1587
+ killGroup('SIGKILL');
1445
1588
  }, timeoutMs);
1589
+ // The SIGKILL for the group always fires after the grace, even when the
1590
+ // model itself has exited by then: a child it started with its own
1591
+ // stdio can ignore SIGTERM and outlive it, so the group is tracked (and
1592
+ // the timer kept alive) until that kill has gone out.
1593
+ const onAbort = () => {
1594
+ if (done || stopped) return;
1595
+ stopped = true;
1596
+ killGroup('SIGTERM');
1597
+ hardKill = setTimeout(() => {
1598
+ killGroup('SIGKILL');
1599
+ if (LIVE_WRITERS.get(child.pid) === kill) LIVE_WRITERS.delete(child.pid);
1600
+ }, STOP_GRACE_MS);
1601
+ // it fires while the process lives, and never holds it open on its own
1602
+ if (hardKill.unref) hardKill.unref();
1603
+ };
1604
+ if (signal) signal.addEventListener('abort', onAbort, { once: true });
1446
1605
  const line = (raw) => {
1447
1606
  if (!raw.trim()) return;
1448
1607
  let ev;
@@ -1476,12 +1635,16 @@ function runModel(prompt, { command = { bin: 'claude', args: claudeArgs }, cwd,
1476
1635
  if (done) return;
1477
1636
  done = true;
1478
1637
  clearTimeout(timer);
1479
- LIVE_WRITERS.delete(child.pid);
1638
+ if (signal) signal.removeEventListener('abort', onAbort);
1639
+ // a stopped call keeps its SIGKILL and its place in LIVE_WRITERS until
1640
+ // the grace is over; every other call is finished with its group
1641
+ if (!stopped) LIVE_WRITERS.delete(child.pid);
1480
1642
  resolve(out);
1481
1643
  };
1482
1644
  child.on('error', err => finish({ ok: false, reason: `could not start the model: ${err.message}` }));
1483
1645
  child.on('close', (code) => {
1484
1646
  if (buf) line(buf);
1647
+ if (stopped) return finish({ ok: false, reason: STOPPED_REASON, stopped: true });
1485
1648
  if (timedOut) return finish({ ok: false, reason: `the model took longer than ${Math.round(timeoutMs / 1000)} seconds and was stopped`, timed_out: true });
1486
1649
  if (result && result.is_error) return finish({ ok: false, reason: `the model said: ${clip(result.result || result.subtype, 160)}` });
1487
1650
  const answer = result && typeof result.result === 'string' ? result.result : text;
@@ -1781,13 +1944,9 @@ function cleanBet(bet, today, bundle) {
1781
1944
 
1782
1945
  // ---- one generation ----------------------------------------------------------------------
1783
1946
 
1784
- // Reads the sources, asks the model, checks the answer with plain rules, then
1785
- // asks a skeptic model whether it makes sense; either can turn it down, and
1786
- // there is one retry with the reasons. Resolves { ok, bet, bundle } or
1787
- // { ok: false, reason }.
1788
1947
  // Every model attempt, writer and skeptic (the kind field), is kept (raw
1789
- // answer, parse result, reasons, timing), so
1790
- // a failed live run can be read back afterwards.
1948
+ // answer, parse result, reasons, timing), so a failed live run can be read
1949
+ // back afterwards. generateBet (the swarm, below) writes them.
1791
1950
  function attemptsPath(stateDir = defaultStateDir()) {
1792
1951
  return path.join(stateDir, 'gm-bet-attempts.jsonl');
1793
1952
  }
@@ -1811,21 +1970,30 @@ function wordCount(text) {
1811
1970
  // Whether the move actually moves its number, what has to be true first, and
1812
1971
  // whether a sharp founder would laugh at it is judgment, so a separate model
1813
1972
  // call owns it. It gets the bet and the same facts, never the writer's prompt.
1973
+ //
1974
+ // The bar is the inner scorecard: the team is judged only on what it controls
1975
+ // and can check. A pass needs every team step to be work the team can do and
1976
+ // see done, and the move to push the number the right way this week. It does
1977
+ // not have to reach the goal: one idea cannot add a whole week's target, and
1978
+ // the old bar (from the baseline to the goal) turned down all of the last 82
1979
+ // drafts it judged, so every tap waited and then got nothing.
1814
1980
  function buildSkepticPrompt(bet, bundle, { today } = {}) {
1815
1981
  return [
1816
1982
  'You are a skeptical cofounder. Someone else proposed the bet below for this week. Your job is to catch it if it does not make sense, before the team spends a week on it.',
1817
1983
  '',
1818
1984
  'Answer with strict JSON only, no prose and no code fence, in this shape:',
1819
- '{"verdict": "pass", "missing_first": "...", "moves_metric": true, "reason": "..."}',
1985
+ '{"verdict": "pass", "doable": true, "moves_metric": true, "missing_first": "...", "reason": "...", "concern": "..."}',
1820
1986
  '',
1821
- 'Check these against the facts below:',
1822
- '- 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.',
1987
+ 'The team is judged only on what it controls and can check. Judge it that way, against the facts below:',
1988
+ '- doable: is every team step work the team can do on its own this week, and check once it is done (a page that exists, a post drafted, a demo recorded, a test that passes)? steps for "you" wait for the owner\'s yes and are fine. a team step that only happens if strangers act is not doable.',
1989
+ '- moves_metric: if every step is done, would it plausibly push the target number the right way this week (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)? it does not have to reach target.goal; the goal is the hope, not the bar, so never fail it only because the goal is ambitious. 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.',
1823
1990
  '- 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.',
1824
1991
  '- would a sharp founder reading this find it obviously wrong, padded, or a waste of the week?',
1825
1992
  '- does it propose a trade, buying or selling stocks, shares, crypto, or any security, or moving money between accounts: fail it.',
1826
1993
  '- 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.',
1827
- '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".',
1994
+ 'verdict is "pass" when doable and moves_metric are both true, nothing is missing that the steps do not cover, it is not obviously wrong, and it is not plumbing. otherwise "fail".',
1828
1995
  '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.',
1996
+ `concern: when it fails, the worry in one plain everyday line under 120 characters for the owner, who reads it on the card; never these words: ${OWNER_BANNED_WORDS.join(', ')}. "" when it passes.`,
1829
1997
  '',
1830
1998
  `Today is ${today}.`,
1831
1999
  '',
@@ -1850,17 +2018,21 @@ function parseVerdict(text) {
1850
2018
  moves_metric: v.moves_metric,
1851
2019
  missing_first: typeof v.missing_first === 'string' ? clip(v.missing_first, 240) : '',
1852
2020
  reason: clip(v.reason, 400),
2021
+ // doable and concern are newer; an answer without them still counts
2022
+ ...(typeof v.doable === 'boolean' ? { doable: v.doable } : {}),
2023
+ ...(typeof v.concern === 'string' && v.concern.trim() ? { concern: clip(v.concern.trim(), 200) } : {}),
1853
2024
  },
1854
2025
  };
1855
2026
  }
1856
2027
 
1857
2028
  function skepticCommand() {
1858
- return { bin: 'claude', args: p => claudeArgs(p, SKEPTIC_MODEL) };
2029
+ return { bin: 'claude', args: p => [...claudeArgs(p, SKEPTIC_MODEL), '--effort', IDEA_EFFORT] };
1859
2030
  }
1860
2031
 
1861
- // A pass needs both: the verdict and the skeptic's own yes on moving the number.
2032
+ // A pass needs the verdict and the skeptic's own yes on moving the number,
2033
+ // and never a no on whether the team can do and check every step.
1862
2034
  function skepticPassed(v) {
1863
- return v.verdict === 'pass' && v.moves_metric === true;
2035
+ return v.verdict === 'pass' && v.moves_metric === true && v.doable !== false;
1864
2036
  }
1865
2037
 
1866
2038
  // What the skeptic said must be true first, or null when it said nothing is.
@@ -1872,7 +2044,8 @@ function blockerOf(v) {
1872
2044
  function skepticReason(v) {
1873
2045
  const missing = blockerOf(v) ? ` missing first: ${v.missing_first.replace(/[.\s]+$/, '')}.` : '';
1874
2046
  const moves = v.moves_metric ? '' : ' it would not move the target number.';
1875
- return `a skeptic turned it down: ${v.reason.replace(/[.\s]+$/, '')}.${moves}${missing} if the missing piece is something we can do, add it as a step (a release, or anything public, is a step for "you")`;
2047
+ const doable = v.doable === false ? ' a team step is not work the team can do and check on its own.' : '';
2048
+ return `a skeptic turned it down: ${v.reason.replace(/[.\s]+$/, '')}.${moves}${doable}${missing} if the missing piece is something we can do, add it as a step (a release, or anything public, is a step for "you")`;
1876
2049
  }
1877
2050
 
1878
2051
  // A free repair turn for an answer that broke only length or sentence limits:
@@ -1904,124 +2077,319 @@ function trimChangedMore(before, after) {
1904
2077
  return a.length !== b.length || a.some((s, i) => !b[i] || s.who !== b[i].who || s.public !== b[i].public);
1905
2078
  }
1906
2079
 
1907
- async function generateBet({ workspaceRoot, today, previousTitle = null, avoidTitles: avoidIn = [], reworkOf = null, ownerNote = null, emit = () => {}, onBundle = () => {}, deps = {}, ledgerFile, attemptsFile, allowTeamMetrics = false } = {}) {
2080
+ // ---- the swarm: several writers at once -----------------------------------------------
2081
+
2082
+ // Three writers start at once, each leaning a different way, and every draft
2083
+ // is checked the moment it lands: the plain rules in code, then the skeptic,
2084
+ // while the other writers keep going. The first draft that passes both is the
2085
+ // idea, and every call still running is stopped. Nothing waits past the
2086
+ // budget: when it runs out, the best draft so far is the idea, with the
2087
+ // skeptic's worry in one plain line. Measured on the real prompt, one writer
2088
+ // took 17 to 27 seconds and one skeptic 9 to 20, and one writer after another
2089
+ // with its skeptic took over two minutes across three tries.
2090
+ const SWARM_WRITERS = 3;
2091
+ const SWARM_BUDGET_MS = 30000;
2092
+ // reading the facts gets the first quarter of it; anything slower is left out
2093
+ const SOURCES_SHARE = 0.25;
2094
+ // a call that cannot finish inside the budget is not started: a writer needs
2095
+ // about 40% of it, a skeptic about 13% (the shares of a 30 second budget)
2096
+ const WRITE_MIN_LEFT_MS = 12000;
2097
+ const JUDGE_MIN_LEFT_MS = 4000;
2098
+ const COUNT_WORDS = ['no', 'one', 'two', 'three', 'four', 'five', 'six'];
2099
+ // each writer leans a different way; any project has these three doors
2100
+ const WRITER_ANGLES = [
2101
+ { key: 'reach', text: 'Lean toward reach: put something real in front of new people who would want it and do not know it exists yet.' },
2102
+ { key: 'first_hour', text: 'Lean toward the people already here: make their first hour better, so more of them stay, come back, or tell someone.' },
2103
+ { key: 'proof', text: 'Lean toward proof: one small thing people can see, try, or copy this week that shows what this does.' },
2104
+ ];
2105
+
2106
+ // What the waiting card says while the drafts are written, in the owner's
2107
+ // words: each event that carries say is one line the page can show.
2108
+ const SWARM_SAY = {
2109
+ drafting: n => (n === 1 ? 'drafting an idea' : `drafting ${COUNT_WORDS[n] || n} ideas at once`),
2110
+ checking: k => `the ${ORDINALS[k - 1] || 'next'} draft is in. checking it`,
2111
+ redraft: 'one draft missed a rule. redrafting it',
2112
+ shorten: 'one draft ran long. shortening it',
2113
+ heldUp: 'found one that holds up',
2114
+ notHeld: 'that one did not hold up. still working',
2115
+ outOfTime: 'out of time. bringing the best one so far',
2116
+ nothing: 'nothing usable came back this time',
2117
+ };
2118
+
2119
+ // The best draft when none passed: one the skeptic only had small doubts
2120
+ // about first, then one it had no time to look at, then the rest, oldest
2121
+ // first within each.
2122
+ function draftRank(c) {
2123
+ if (!c.verdict) return 1;
2124
+ const v = c.verdict;
2125
+ if (v.moves_metric === true && v.doable !== false && !blockerOf(v)) return 0;
2126
+ return 2 + (v.moves_metric === true ? 0 : 2) + (v.doable === false ? 1 : 0) + (blockerOf(v) ? 1 : 0);
2127
+ }
2128
+
2129
+ // The skeptic's worry as one line the owner reads on the card: its own
2130
+ // concern, else the first sentence of its reason, when that is plain words
2131
+ // with none of our own; otherwise a plain line that says what kind of worry.
2132
+ const CONCERN_MAX = 140;
2133
+ function ownerConcern(c) {
2134
+ if (!c.verdict) return c.unjudged && !/no time left/.test(c.unjudged) ? 'not double-checked: the second look did not finish.' : 'not double-checked yet: it came in as time ran out.';
2135
+ const v = c.verdict;
2136
+ const first = String(v.reason || '').split(/(?<=[.!?])\s+/)[0];
2137
+ const said = String(v.concern || first || '').replace(/\s+/g, ' ').trim().replace(/[.\s]+$/, '');
2138
+ const plain = said && said.length <= CONCERN_MAX - 12 && !ownerWord(said) && !/\u2014/.test(said) && !STEP_CODE.some(re => re.test(said));
2139
+ if (plain) return `one worry: ${said.charAt(0).toLowerCase()}${said.slice(1)}.`;
2140
+ if (v.moves_metric !== true) return 'one worry: it may not move your number much this week.';
2141
+ if (v.doable === false) return 'one worry: one step depends on people outside the team.';
2142
+ if (blockerOf(v)) return 'one worry: something has to be in place before it can work.';
2143
+ return 'one worry: it may not be the best use of this week.';
2144
+ }
2145
+
2146
+ // Reads the sources, then runs the swarm. Resolves { ok: true, bet, bundle,
2147
+ // checked: true } for a draft both checks passed; { ok: true, bet (with
2148
+ // concern), checked: false } for the best draft when none passed in the
2149
+ // budget; { ok: false, reason } only when no draft met the plain rules at
2150
+ // all; { ok: false, stopped: true } when the signal stopped it first.
2151
+ // writers: how many at once (tests pass 1 to walk one writer's retries).
2152
+ // Every model attempt, writer and skeptic (the kind field), with its lane, is
2153
+ // kept in the attempts log, the stopped ones too.
2154
+ async function generateBet({ workspaceRoot, today, previousTitle = null, avoidTitles: avoidIn = [], reworkOf = null, ownerNote = null, concern = null, emit = () => {}, onBundle = () => {}, deps = {}, ledgerFile, attemptsFile, allowTeamMetrics = false, writers = deps.writers || SWARM_WRITERS, budgetMs = deps.budgetMs || SWARM_BUDGET_MS, signal = null } = {}) {
2155
+ // the budget runs on the real clock, the one the stop timer uses
2156
+ const deadline = Date.now() + budgetMs;
2157
+ const left = () => deadline - Date.now();
2158
+ const writeMin = Math.min(WRITE_MIN_LEFT_MS, budgetMs * (WRITE_MIN_LEFT_MS / SWARM_BUDGET_MS));
2159
+ const judgeMin = Math.min(JUDGE_MIN_LEFT_MS, budgetMs * (JUDGE_MIN_LEFT_MS / SWARM_BUDGET_MS));
1908
2160
  // a rework may stay close to the idea it reworks
1909
2161
  const avoidTitles = reworkOf ? avoidIn.filter(t => t !== reworkOf) : avoidIn;
1910
- const bundle = await readBundle(workspaceRoot, { deps, emit, ledgerFile, today });
1911
- // the desk builds its question cards from these facts while the model works
1912
- try { onBundle(bundle); } catch { /* the desk's copy is a convenience */ }
1913
- const run = deps.runModel || runModel;
2162
+ const run = deps.runModel || ((prompt, opts) => runModel(prompt, { ...opts, command: writerCommand() }));
1914
2163
  // tests pass runSkeptic; a stubbed runModel without one is also used for the
1915
2164
  // skeptic, so a test can never reach the real model by accident
1916
2165
  const judge = deps.runSkeptic || deps.runModel || ((prompt, opts) => runModel(prompt, { ...opts, command: skepticCommand() }));
1917
2166
  const clock = deps.now || Date.now;
1918
- // How each try is asked. first: a fresh bet. patch: fix the last answer
1919
- // against the reasons (code rejects, or a skeptic fail that names no
1920
- // blocker). blocker: the skeptic named what must be true first, so the
1921
- // next bet is clearing that blocker, a new move with its own target.
1922
- // trim: inside a try, a free turn that only shortens fields over a limit.
1923
- let strategy = 'first';
1924
- let reason = null;
1925
- let previousAnswer = null;
1926
- let blocker = null;
1927
- const blocked = [];
2167
+ const cwd = deps.runModel ? workspaceRoot : quietDir();
2168
+ const n = Math.max(1, Math.floor(Number(writers)) || 1);
1928
2169
  const MODEL_LINES = { first: 'asking the model for one bet', patch: 'asking again with the reasons', blocker: 'asking again: clearing the blocker is the new bet', trim: 'asking for the same bet, shorter' };
1929
2170
 
1930
- // one model call and its code check, saved to the attempts log
1931
- async function ask(prompt, attempt, how, avoid) {
1932
- let lastSaid = 0;
1933
- const started = clock();
1934
- const answer = await run(prompt, {
1935
- cwd: workspaceRoot,
1936
- onProgress: ({ text = '' }) => {
1937
- const at = clock();
1938
- if (at - lastSaid < 1200) return;
1939
- lastSaid = at;
1940
- const words = wordCount(text);
1941
- emit({ kind: 'thinking', line: words ? `thinking... ${commas(words)} words written` : 'thinking...', n: words });
1942
- },
1943
- });
1944
- const base = { at: new Date(clock()).toISOString(), workspace: workspaceRoot, today, attempt, strategy: how, ...(how === 'blocker' ? { blocker: blocker.missing_first } : {}), previous_title: previousTitle, avoid_titles: avoid.length, ms: clock() - started };
1945
- if (!answer.ok) {
1946
- saveAttempt(attemptsFile, { ...base, kind: 'writer', model_ok: false, raw: null, parse: null, passed: false, reasons: [answer.reason] });
1947
- return { failed: answer.reason, base };
2171
+ // the swarm's shared state, set up before anything is read so the budget
2172
+ // and a stop cover the reading too: the drafts that met the rules, why each
2173
+ // lane ended, and the calls still running, so one stop ends them all
2174
+ const drafts = [];
2175
+ const ends = [];
2176
+ const live = new Set();
2177
+ let turnedDown = 0;
2178
+ let checking = 0;
2179
+ let winner = null;
2180
+ let over = null;
2181
+ let release;
2182
+ const ended = new Promise((resolve) => { release = resolve; });
2183
+ const stop = (why) => {
2184
+ if (over) return;
2185
+ over = why;
2186
+ for (const ac of live) ac.abort();
2187
+ live.clear();
2188
+ release();
2189
+ };
2190
+ if (signal && signal.aborted) stop('stopped');
2191
+ const onSignal = () => stop('stopped');
2192
+ if (signal && !over) signal.addEventListener('abort', onSignal, { once: true });
2193
+ const timer = setTimeout(() => stop('budget'), Math.max(0, left()));
2194
+ // the reading's waits end with the swarm, on every way out
2195
+ const readStop = new AbortController();
2196
+ const finish = (out) => {
2197
+ clearTimeout(timer);
2198
+ readStop.abort();
2199
+ if (signal) signal.removeEventListener('abort', onSignal);
2200
+ return out;
2201
+ };
2202
+
2203
+ // the facts, read in the first share of the budget: whatever is still
2204
+ // slow by then is left out, so the writers keep the time they need
2205
+ const reading = readBundle(workspaceRoot, { deps, emit, ledgerFile, today, deadline: Date.now() + budgetMs * SOURCES_SHARE, signal: readStop.signal });
2206
+ reading.catch(() => {});
2207
+ let bundle;
2208
+ try {
2209
+ bundle = await Promise.race([reading, ended.then(() => null)]);
2210
+ } catch (err) {
2211
+ finish();
2212
+ throw err;
2213
+ }
2214
+ if (over === 'stopped') return finish({ ok: false, stopped: true, reason: 'stopped before an idea was picked' });
2215
+ if (!bundle) return finish({ ok: false, reason: `the facts took longer than ${Math.round(budgetMs / 1000)} seconds to read` });
2216
+ // the desk builds its question cards from these facts while the model works
2217
+ try { onBundle(bundle); } catch { /* the desk's copy is a convenience */ }
2218
+
2219
+ // one model call the swarm can stop; none starts once the budget is spent,
2220
+ // even when the stop timer has not had its turn yet
2221
+ async function call(fn, prompt, opts) {
2222
+ if (over || left() <= 0) return { ok: false, reason: 'the time for this idea ran out', stopped: true };
2223
+ const ac = new AbortController();
2224
+ live.add(ac);
2225
+ try {
2226
+ return await fn(prompt, { ...opts, signal: ac.signal });
2227
+ } catch (err) {
2228
+ return { ok: false, reason: String((err && err.message) || err) };
2229
+ } finally {
2230
+ live.delete(ac);
1948
2231
  }
1949
- const parsed = parseBet(answer.text);
1950
- const check = parsed.error ? { ok: false, reasons: [parsed.error], formatOnly: false } : validateBet(parsed.bet, { bundle, today, previousTitle, avoidTitles: avoid, allowTeamMetrics });
1951
- return { parsed, check, base, raw: answer.text };
1952
- }
1953
- const save = (r, extra = {}) => saveAttempt(attemptsFile, { ...r.base, kind: 'writer', model_ok: true, raw: String(r.raw).slice(0, 20000), parse: r.parsed.error || 'ok', passed: r.check.ok, reasons: r.check.reasons, ...extra });
1954
-
1955
- for (let attempt = 1; attempt <= WRITER_ATTEMPTS; attempt += 1) {
1956
- emit({ kind: 'model', line: MODEL_LINES[strategy] });
1957
- // a blocked idea is not shown again as a patch
1958
- const avoid = [...avoidTitles, ...blocked];
1959
- const prompt = strategy === 'blocker'
1960
- ? buildPrompt(bundle, { today, previousTitle, blocker, avoidTitles: avoid, reworkOf, ownerNote })
1961
- : buildPrompt(bundle, { today, previousTitle, reason, previousAnswer, avoidTitles: avoid, reworkOf, ownerNote });
1962
- let r = await ask(prompt, attempt, strategy, avoid);
1963
- if (r.failed) return { ok: false, reason: r.failed, bundle };
1964
- save(r);
1965
-
1966
- // only length or sentence limits broken: one free trim turn, same try
1967
- if (!r.check.ok && r.check.formatOnly) {
1968
- emit({ kind: 'model', line: MODEL_LINES.trim });
1969
- const original = r.parsed.bet;
1970
- const trimmed = await ask(buildTrimPrompt(JSON.stringify(original), r.check.reasons), attempt, 'trim', avoid);
1971
- if (trimmed.failed) return { ok: false, reason: trimmed.failed, bundle };
1972
- if (!trimmed.parsed.error && trimChangedMore(original, trimmed.parsed.bet)) {
1973
- trimmed.check = { ok: false, reasons: ['the trim changed more than the fields over a limit'], formatOnly: false };
2232
+ }
2233
+
2234
+ async function lane(index) {
2235
+ const laneNo = index + 1;
2236
+ const angle = n > 1 ? WRITER_ANGLES[index % WRITER_ANGLES.length] : null;
2237
+ // How each try is asked. first: a fresh bet. patch: fix the last answer
2238
+ // against the reasons (code rejects, or a skeptic fail that names no
2239
+ // blocker). blocker: the skeptic named what must be true first, so the
2240
+ // next bet is clearing that blocker, a new move with its own target.
2241
+ // trim: inside a try, a free turn that only shortens fields over a limit.
2242
+ let strategy = 'first';
2243
+ let reason = null;
2244
+ let previousAnswer = null;
2245
+ let blocker = null;
2246
+ const blocked = [];
2247
+ const tag = { lane: laneNo, ...(angle ? { angle: angle.key } : {}) };
2248
+
2249
+ // one writer call and its code check, saved to the attempts log
2250
+ async function ask(prompt, attempt, how, avoid) {
2251
+ let lastSaid = 0;
2252
+ const started = clock();
2253
+ const answer = await call(run, prompt, {
2254
+ cwd,
2255
+ onProgress: ({ text = '' }) => {
2256
+ if (over) return;
2257
+ const at = clock();
2258
+ if (at - lastSaid < 1200) return;
2259
+ lastSaid = at;
2260
+ const w = wordCount(text);
2261
+ emit({ kind: 'thinking', line: w ? `thinking... ${commas(w)} words written` : 'thinking...', n: w, lane: laneNo });
2262
+ },
2263
+ });
2264
+ const base = { at: new Date(clock()).toISOString(), workspace: workspaceRoot, today, attempt, strategy: how, ...tag, ...(how === 'blocker' ? { blocker: blocker.missing_first } : {}), previous_title: previousTitle, avoid_titles: avoid.length, ms: clock() - started };
2265
+ if (!answer.ok) {
2266
+ saveAttempt(attemptsFile, { ...base, kind: 'writer', model_ok: false, ...(answer.stopped ? { stopped: true } : {}), raw: null, parse: null, passed: false, reasons: [answer.reason] });
2267
+ return { failed: answer.reason, stopped: Boolean(answer.stopped), base };
1974
2268
  }
1975
- save(trimmed);
1976
- r = trimmed;
2269
+ const parsed = parseBet(answer.text);
2270
+ const check = parsed.error ? { ok: false, reasons: [parsed.error], formatOnly: false } : validateBet(parsed.bet, { bundle, today, previousTitle, avoidTitles: avoid, allowTeamMetrics });
2271
+ return { parsed, check, base, raw: answer.text };
1977
2272
  }
2273
+ const save = r => saveAttempt(attemptsFile, { ...r.base, kind: 'writer', model_ok: true, raw: String(r.raw).slice(0, 20000), parse: r.parsed.error || 'ok', passed: r.check.ok, reasons: r.check.reasons });
2274
+
2275
+ for (let attempt = 1; attempt <= WRITER_ATTEMPTS; attempt += 1) {
2276
+ if (over) return;
2277
+ // a writer that cannot finish inside the budget is never started, the first one included
2278
+ if (left() < writeMin) { ends.push({ lane: laneNo, out_of_time: true, reason }); return; }
2279
+ emit({ kind: 'model', line: MODEL_LINES[strategy], ...tag });
2280
+ // a blocked idea is not shown again as a patch
2281
+ const avoid = [...avoidTitles, ...blocked];
2282
+ const angleText = angle ? angle.text : null;
2283
+ const prompt = strategy === 'blocker'
2284
+ ? buildPrompt(bundle, { today, previousTitle, blocker, avoidTitles: avoid, reworkOf, ownerNote, angle: angleText, concern })
2285
+ : buildPrompt(bundle, { today, previousTitle, reason, previousAnswer, avoidTitles: avoid, reworkOf, ownerNote, angle: angleText, concern });
2286
+ let r = await ask(prompt, attempt, strategy, avoid);
2287
+ if (over) return;
2288
+ if (r.failed) { ends.push({ lane: laneNo, model_failed: true, reason: r.failed }); return; }
2289
+ save(r);
2290
+
2291
+ // only length or sentence limits broken: one free trim turn, same try
2292
+ if (!r.check.ok && r.check.formatOnly) {
2293
+ emit({ kind: 'model', line: MODEL_LINES.trim, ...tag, say: SWARM_SAY.shorten });
2294
+ const original = r.parsed.bet;
2295
+ const trimmed = await ask(buildTrimPrompt(JSON.stringify(original), r.check.reasons), attempt, 'trim', avoid);
2296
+ if (over) return;
2297
+ if (trimmed.failed) { ends.push({ lane: laneNo, model_failed: true, reason: trimmed.failed }); return; }
2298
+ if (!trimmed.parsed.error && trimChangedMore(original, trimmed.parsed.bet)) {
2299
+ trimmed.check = { ok: false, reasons: ['the trim changed more than the fields over a limit'], formatOnly: false };
2300
+ }
2301
+ save(trimmed);
2302
+ r = trimmed;
2303
+ }
1978
2304
 
1979
- previousAnswer = r.parsed.error ? null : JSON.stringify(r.parsed.bet);
1980
- if (!r.check.ok) {
1981
- strategy = 'patch';
1982
- reason = r.check.reasons.slice(0, 4).join('; ');
1983
- emit({ kind: 'reject', line: `${ORDINALS[attempt - 1]} answer turned down: ${reason}` });
1984
- continue;
1985
- }
2305
+ previousAnswer = r.parsed.error ? null : JSON.stringify(r.parsed.bet);
2306
+ if (!r.check.ok) {
2307
+ strategy = 'patch';
2308
+ reason = r.check.reasons.slice(0, 4).join('; ');
2309
+ turnedDown += 1;
2310
+ const again = attempt < WRITER_ATTEMPTS && left() >= writeMin;
2311
+ emit({ kind: 'reject', line: `${ORDINALS[attempt - 1]} answer turned down: ${reason}`, ...tag, ...(again ? { say: SWARM_SAY.redraft } : {}) });
2312
+ continue;
2313
+ }
1986
2314
 
1987
- // the code checks passed; now a second model judges whether it makes sense
1988
- const bet = cleanBet(r.parsed.bet, today, bundle);
1989
- emit({ kind: 'skeptic', line: 'a second model is checking that it makes sense' });
1990
- const judgedAt = clock();
1991
- const said = await judge(buildSkepticPrompt(bet, bundle, { today }), { cwd: workspaceRoot, onProgress: () => {} });
1992
- const skepticBase = { ...r.base, kind: 'skeptic', at: new Date(clock()).toISOString(), ms: clock() - judgedAt, title: bet.title };
1993
- if (!said.ok) {
1994
- saveAttempt(attemptsFile, { ...skepticBase, model_ok: false, raw: null, parse: null, passed: false, reasons: [said.reason] });
1995
- return { ok: false, reason: `the skeptic could not check the bet: ${said.reason}`, bundle };
1996
- }
1997
- const judged = parseVerdict(said.text);
1998
- const passed = !judged.error && skepticPassed(judged.verdict);
1999
- saveAttempt(attemptsFile, {
2000
- ...skepticBase,
2001
- model_ok: true,
2002
- raw: String(said.text).slice(0, 20000),
2003
- parse: judged.error || 'ok',
2004
- verdict: judged.verdict || null,
2005
- passed,
2006
- reasons: judged.error ? [judged.error] : (passed ? [] : [judged.verdict.reason]),
2007
- });
2008
- if (judged.error) return { ok: false, reason: `the skeptic could not check the bet: ${judged.error}`, bundle };
2009
- if (passed) {
2010
- emit({ kind: 'check', line: 'checked the bet: every rule passed and the skeptic agreed' });
2011
- return { ok: true, bet: { ...bet, skeptic: judged.verdict }, bundle, attempts: attempt };
2012
- }
2013
- reason = skepticReason(judged.verdict);
2014
- const missing = blockerOf(judged.verdict);
2015
- if (missing) {
2016
- strategy = 'blocker';
2017
- blocker = { title: bet.title, missing_first: missing };
2018
- blocked.push(bet.title);
2019
- } else {
2020
- strategy = 'patch';
2315
+ // the code checks passed; now a second model judges whether it makes sense
2316
+ const bet = cleanBet(r.parsed.bet, today, bundle);
2317
+ const draft = { bet, lane: laneNo, attempt, verdict: null, order: drafts.length };
2318
+ drafts.push(draft);
2319
+ if (left() < judgeMin) { draft.unjudged = 'no time left to check it'; ends.push({ lane: laneNo, out_of_time: true, reason }); return; }
2320
+ checking += 1;
2321
+ emit({ kind: 'skeptic', line: 'a second model is checking that it makes sense', ...tag, say: SWARM_SAY.checking(checking) });
2322
+ const judgedAt = clock();
2323
+ const said = await call(judge, buildSkepticPrompt(bet, bundle, { today }), { cwd, onProgress: () => {} });
2324
+ const skepticBase = { ...r.base, kind: 'skeptic', at: new Date(clock()).toISOString(), ms: clock() - judgedAt, title: bet.title };
2325
+ if (!said.ok) {
2326
+ saveAttempt(attemptsFile, { ...skepticBase, model_ok: false, ...(said.stopped ? { stopped: true } : {}), raw: null, parse: null, passed: false, reasons: [said.reason] });
2327
+ if (over) return;
2328
+ draft.unjudged = `the skeptic could not check the bet: ${said.reason}`;
2329
+ ends.push({ lane: laneNo, reason: draft.unjudged });
2330
+ return;
2331
+ }
2332
+ const judged = parseVerdict(said.text);
2333
+ const passed = !judged.error && skepticPassed(judged.verdict);
2334
+ saveAttempt(attemptsFile, {
2335
+ ...skepticBase,
2336
+ model_ok: true,
2337
+ raw: String(said.text).slice(0, 20000),
2338
+ parse: judged.error || 'ok',
2339
+ verdict: judged.verdict || null,
2340
+ passed,
2341
+ reasons: judged.error ? [judged.error] : (passed ? [] : [judged.verdict.reason]),
2342
+ });
2343
+ if (over) return;
2344
+ if (judged.error) {
2345
+ draft.unjudged = `the skeptic could not check the bet: ${judged.error}`;
2346
+ ends.push({ lane: laneNo, reason: draft.unjudged });
2347
+ return;
2348
+ }
2349
+ draft.verdict = judged.verdict;
2350
+ if (passed) {
2351
+ winner = draft;
2352
+ emit({ kind: 'check', line: 'checked the bet: every rule passed and the skeptic agreed', ...tag, say: SWARM_SAY.heldUp });
2353
+ stop('pass');
2354
+ return;
2355
+ }
2356
+ reason = skepticReason(judged.verdict);
2357
+ turnedDown += 1;
2358
+ const missing = blockerOf(judged.verdict);
2359
+ if (missing) {
2360
+ strategy = 'blocker';
2361
+ blocker = { title: bet.title, missing_first: missing };
2362
+ blocked.push(bet.title);
2363
+ } else {
2364
+ strategy = 'patch';
2365
+ }
2366
+ emit({ kind: 'reject', line: `${ORDINALS[attempt - 1]} answer turned down: ${reason}`, ...tag, say: SWARM_SAY.notHeld });
2021
2367
  }
2022
- emit({ kind: 'reject', line: `${ORDINALS[attempt - 1]} answer turned down: ${reason}` });
2368
+ ends.push({ lane: laneNo, reason });
2023
2369
  }
2024
- return { ok: false, reason: `the model's answer was turned down ${WRITER_ATTEMPTS} times (${reason})`, bundle };
2370
+
2371
+ emit({ kind: 'swarm', line: `${plural(n, 'writer')} at once`, n, say: SWARM_SAY.drafting(n) });
2372
+ const lanes = Array.from({ length: n }, (_, i) => lane(i).catch((err) => { ends.push({ lane: i + 1, reason: String((err && err.message) || err) }); }));
2373
+ Promise.all(lanes).then(() => stop('done'));
2374
+ await ended;
2375
+ finish();
2376
+
2377
+ if (over === 'stopped') return { ok: false, stopped: true, reason: 'stopped before an idea was picked', bundle };
2378
+ if (winner) return { ok: true, bet: { ...winner.bet, skeptic: winner.verdict }, bundle, attempts: winner.attempt, lane: winner.lane, checked: true };
2379
+ // none passed: the best draft is still a card the owner can act on
2380
+ const best = [...drafts].sort((a, b) => draftRank(a) - draftRank(b) || a.order - b.order)[0];
2381
+ if (best) {
2382
+ const why = best.verdict ? skepticReason(best.verdict) : best.unjudged;
2383
+ emit({ kind: 'fallback', line: `no draft passed every check${over === 'budget' ? ' in time' : ''}; showing the best one: ${why}`, say: SWARM_SAY.outOfTime });
2384
+ return { ok: true, bet: { ...best.bet, skeptic: best.verdict || null, concern: ownerConcern(best) }, bundle, attempts: best.attempt, lane: best.lane, checked: false, reason: why };
2385
+ }
2386
+ emit({ kind: 'none', line: 'no draft met the plain rules', say: SWARM_SAY.nothing });
2387
+ const failedAll = ends.length > 0 && ends.every(e => e.model_failed);
2388
+ const last = [...ends].reverse().find(e => e.reason);
2389
+ if (failedAll) return { ok: false, reason: ends[0].reason, bundle };
2390
+ if (ends.length > 0 && ends.every(e => e.out_of_time) && !turnedDown) return { ok: false, reason: `no time was left to draft inside ${Math.round(budgetMs / 1000)} seconds once the facts were read`, bundle };
2391
+ if (over === 'budget') return { ok: false, reason: `nothing usable came back in ${Math.round(budgetMs / 1000)} seconds${last ? ` (${last.reason})` : ''}`, bundle };
2392
+ return { ok: false, reason: `the model's answer was turned down ${plural(turnedDown, 'time')} (${last ? last.reason : 'no answer met the rules'})`, bundle };
2025
2393
  }
2026
2394
 
2027
2395
  // ---- do it: the mission -------------------------------------------------------------------
@@ -2456,7 +2824,8 @@ function queuedProblem(bet, team) {
2456
2824
 
2457
2825
  // ideas kept ready ahead; one is written again the moment one is shown
2458
2826
  const QUEUE_TARGET = 3;
2459
- // one generation at a time; the cap of 3 calls in flight per session is never reached
2827
+ // one drafting at a time (each runs up to three model calls at once, the
2828
+ // swarm); the cap of 3 draftings in flight per session is never reached
2460
2829
  const MAX_IN_FLIGHT = 1;
2461
2830
  const SESSION_IN_FLIGHT_CAP = 3;
2462
2831
  const STALE_MS = 6 * 60 * 60 * 1000;
@@ -2562,7 +2931,7 @@ function createBetDesk(deps) {
2562
2931
  // queued bets live in the ledger, so a restart or a reload wastes nothing
2563
2932
  for (const row of readLedger(deps.ledgerFile, deps.workspaceRoot)) {
2564
2933
  if (row.status !== 'queued' || !row.bet) continue;
2565
- state.queue.push({ id: row.id, bet: row.bet, created_at: Date.parse(row.created_at) || 0 });
2934
+ state.queue.push({ id: row.id, bet: row.bet, created_at: Date.parse(row.created_at) || 0, ...(row.state_key ? { state_key: row.state_key } : {}), ...(row.rework_of ? { rework_of: row.rework_of } : {}) });
2566
2935
  }
2567
2936
 
2568
2937
  // the team, read once: a queued bet whose members are not on it is not shown
@@ -2573,12 +2942,52 @@ function createBetDesk(deps) {
2573
2942
  team = null;
2574
2943
  }
2575
2944
 
2576
- // Old bets go: older than 6 hours, or made under rules that have changed
2577
- // since. Each is marked stale with why, and the queue refills behind them.
2945
+ // What a prepared idea was written from on the owner's side: his answers
2946
+ // to the question cards and his own number. An idea written before either
2947
+ // changed would not know what he just said, so it is never shown.
2948
+ function ownerStateKey() {
2949
+ const focus = Object.values(readFocus(focusFile, deps.workspaceRoot)).map(a => [String(a.key), String(a.label)]).sort((a, b) => a[0].localeCompare(b[0]));
2950
+ const board = readScoreboard(scoreboardFile, deps.workspaceRoot);
2951
+ const last = board && board.reports.length ? board.reports[board.reports.length - 1].value : null;
2952
+ return crypto.createHash('sha256').update(JSON.stringify({ focus, board: board ? [board.name, board.up, board.source, last] : null })).digest('hex').slice(0, 12);
2953
+ }
2954
+
2955
+ // Titles the owner already saw or turned down: shown, passed, skipped, or started.
2956
+ const SEEN = new Set(['proposed', 'passed', 'skipped', 'doing']);
2957
+ function seenTitles() {
2958
+ return readLedger(deps.ledgerFile, deps.workspaceRoot).filter(r => r.bet && r.bet.title && (SEEN.has(r.status) || r.shown_at)).map(r => ({ id: r.id, title: r.bet.title }));
2959
+ }
2960
+
2961
+ // The number an idea starts from, as it was when it was written, against
2962
+ // the newest facts read: when it moved, the idea's numbers are old.
2963
+ function startMoved(bet) {
2964
+ const t = bet && bet.target;
2965
+ const m = t && state.bundle ? bundleMetrics(state.bundle)[t.metric] : null;
2966
+ if (!m || !Number.isFinite(m.baseline) || !Number.isFinite(t.baseline) || m.baseline === t.baseline) return null;
2967
+ return `the number it starts from moved: ${commas(t.baseline)} then, ${commas(m.baseline)} now`;
2968
+ }
2969
+
2970
+ // Why a prepared idea can no longer be shown, or null while it is fresh:
2971
+ // older than 6 hours, made under rules that changed since, written before
2972
+ // the owner's newest answer or number, too close to one he already saw or
2973
+ // turned down, or its starting number moved.
2974
+ function staleWhy(item, seen) {
2975
+ if (now() - item.created_at > STALE_MS) return 'older than 6 hours';
2976
+ const rule = queuedProblem(item.bet, team);
2977
+ if (rule) return rule;
2978
+ if (item.state_key && item.state_key !== ownerStateKey()) return 'written before the owner\'s newest answer';
2979
+ // a rework of the idea the owner wrote a note on may stay close to it
2980
+ const close = seen.find(s => s.id !== item.id && s.title !== item.rework_of && nearDuplicate(item.bet.title, s.title));
2981
+ if (close) return `too close to one already shown: ${clip(close.title, 80)}`;
2982
+ return startMoved(item.bet);
2983
+ }
2984
+
2985
+ // Old bets go. Each is marked stale with why, and the queue refills behind them.
2578
2986
  function dropStale() {
2579
2987
  const keep = [];
2988
+ const seen = state.queue.length ? seenTitles() : [];
2580
2989
  for (const item of state.queue) {
2581
- const why = now() - item.created_at > STALE_MS ? 'older than 6 hours' : queuedProblem(item.bet, team);
2990
+ const why = staleWhy(item, seen);
2582
2991
  if (why) {
2583
2992
  record(item.id, { status: 'stale', stale_reason: why });
2584
2993
  log({ card: 'bet', choice: 'stale', command: null, ran: 'in process', exit_code: 0, bet_id: item.id, why });
@@ -2990,12 +3399,118 @@ function createBetDesk(deps) {
2990
3399
  // page brings the idea in once it is written
2991
3400
  const WAIT_CARD = { what: 'writing your next idea.' };
2992
3401
  function showWait() {
2993
- state.current = { kind: 'wait', id: `wait:${now()}` };
3402
+ // already the calm line: it stays, with its id and the notes typed on it
3403
+ if (state.status === 'ready' && state.current && state.current.kind === 'wait') return;
3404
+ state.current = { kind: 'wait', id: `wait:${now()}`, shown_at: now(), lines: [] };
2994
3405
  state.status = 'ready';
2995
3406
  state.mission = null;
2996
3407
  state.error = null;
2997
3408
  log({ card: 'bet', choice: 'show', command: null, ran: 'in process', exit_code: 0, kind: 'wait' });
2998
3409
  }
3410
+ const onWait = () => state.status === 'ready' && Boolean(state.current) && state.current.kind === 'wait';
3411
+
3412
+ // Words typed on the calm line are a note for the idea being written: no
3413
+ // read, no new card. The next drafting starts with them (one under way
3414
+ // that nobody asked for is stopped for it), and the calm line waits for
3415
+ // that idea; one written without them is kept for later. It waits only
3416
+ // while that idea is still being written, and at most NOTE_HOLD_MS, a
3417
+ // drafting's own budget. Past that the ready idea comes in on its own
3418
+ // with NOTE_FOLDING as its line, and the idea written with the note is
3419
+ // the next card after it (state.noteComing), never swapped in under him.
3420
+ const NOTE_HOLD_MS = SWARM_BUDGET_MS;
3421
+ const WAIT_NOTE_ACK = 'got it, i\'ll fold that in.';
3422
+ const NOTE_FOLDING = 'your note is still being folded in. that version comes next.';
3423
+ // the newest notes that fit in one note's room, oldest first
3424
+ function joinNotes(notes) {
3425
+ let out = '';
3426
+ for (let i = notes.length - 1; i >= 0; i -= 1) {
3427
+ const more = out ? `${notes[i]}; ${out}` : notes[i];
3428
+ if (more.length > SAY_MAX) break;
3429
+ out = more;
3430
+ }
3431
+ return out || notes[notes.length - 1].slice(0, SAY_MAX);
3432
+ }
3433
+ function noteOnWait(cur, said) {
3434
+ if (!cur.notes && state.pendingSharper && state.pendingSharper.ownerNote) cur.notes = [state.pendingSharper.ownerNote];
3435
+ cur.notes = [...(cur.notes || []), said].slice(-3);
3436
+ cur.note = joinNotes(cur.notes);
3437
+ cur.noteAt = now();
3438
+ state.pendingSharper = { ...(state.pendingSharper || {}), ownerNote: cur.note };
3439
+ log({ card: 'bet', choice: 'wait_note', command: null, ran: 'in process', exit_code: 0, kind: 'wait', chars: said.length });
3440
+ pump();
3441
+ return { ok: true, noted: true, ack: WAIT_NOTE_ACK };
3442
+ }
3443
+ // the idea written with the newest note: the calm line's own, or one still coming after an idea shown without it
3444
+ const notedAt = (cur) => {
3445
+ const note = cur && cur.kind === 'wait' && cur.note ? cur.note : state.noteComing;
3446
+ return note ? state.queue.findIndex(i => i.owner_note === note) : -1;
3447
+ };
3448
+ function waitHolds() {
3449
+ const cur = state.current;
3450
+ if (!onWait() || !cur.note || notedAt(cur) >= 0) return false;
3451
+ return (state.inFlight > 0 || Boolean(state.pendingSharper)) && now() - cur.noteAt < NOTE_HOLD_MS;
3452
+ }
3453
+ // a note already on its way: waiting to start, being written, or ready
3454
+ const noteUnderWay = note => Boolean((state.pendingSharper && state.pendingSharper.ownerNote === note)
3455
+ || (state.gen && !state.gen.stopping && state.gen.asks && state.gen.asks.ownerNote === note)
3456
+ || state.queue.some(i => i.owner_note === note));
3457
+ // the notes typed on the calm line, and the answer to them
3458
+ function waitThread(cur) {
3459
+ const thread = (cur.notes || []).map(text => ({ who: 'owner', text }));
3460
+ if (thread.length) thread.push({ who: 'orb', text: WAIT_NOTE_ACK });
3461
+ return thread;
3462
+ }
3463
+
3464
+ // How the waiting card shows it is alive: the plain lines of the drafting
3465
+ // it waits on (the one running when it showed, or the one that took over
3466
+ // when that was stopped), newest last, and when that drafting began.
3467
+ const WAIT_LINES = 6;
3468
+ function waitProgress(cur) {
3469
+ const was = cur.waitsOn;
3470
+ if (state.gen && (!was || (was.stopping && was !== state.gen))) {
3471
+ // the line that says why it started over stays on the card
3472
+ if (was && was.stopSay) cur.lines = [...(cur.lines || []), { at: was.stopped_at, line: was.stopSay }];
3473
+ cur.waitsOn = state.gen;
3474
+ }
3475
+ const g = cur.waitsOn || null;
3476
+ const said = g ? g.events.filter(e => e.say).map(e => ({ at: e.at, line: e.say })) : [];
3477
+ if (g && g.stopping && g.stopSay) said.push({ at: g.stopped_at, line: g.stopSay });
3478
+ const lines = [...(cur.lines || []), ...said].sort((a, b) => a.at - b.at).slice(-WAIT_LINES);
3479
+ const startedAt = g ? g.started_at : (cur.shown_at || now());
3480
+ return { progress: lines, started_at: startedAt, waited_ms: Math.max(0, now() - startedAt) };
3481
+ }
3482
+
3483
+ // The wait ended with no draft that met the plain rules (the model is down,
3484
+ // or every draft broke a rule): a card the owner can act on, never an error.
3485
+ const MISSED_CARD = { what: 'no idea came together this time.' };
3486
+ // It remembers the wait it ended (after, and the id that wait was served
3487
+ // under), so words the owner was typing there still count.
3488
+ function showMissed() {
3489
+ const was = state.current && state.current.kind === 'wait' ? state.current : null;
3490
+ const servedWait = was && state.served && state.served.kind === 'wait' ? state.served.id : null;
3491
+ state.current = { kind: 'missed', id: `missed:${now()}`, ...(was ? { after: was.id, afterServed: servedWait, ...(was.note ? { note: was.note, notes: was.notes } : {}) } : {}) };
3492
+ state.status = 'ready';
3493
+ state.mission = null;
3494
+ state.error = null;
3495
+ log({ card: 'bet', choice: 'show', command: null, ran: 'in process', exit_code: 0, kind: 'missed' });
3496
+ }
3497
+
3498
+ // The owner asked for something (a sharper idea, words on a card): a
3499
+ // background top-up still drafting is stopped, so his ask starts now
3500
+ // instead of after it.
3501
+ function makeRoomFor() {
3502
+ if (state.gen && !state.gen.asked && !state.gen.stopping) state.gen.stop('the owner asked for something', 'starting a new one for what you asked');
3503
+ }
3504
+
3505
+ // Which ready idea goes next: one the owner asked for first, then a
3506
+ // checked one; one a second look worried about waits behind them.
3507
+ function nextIndex() {
3508
+ if (!state.queue.length) return -1;
3509
+ if (state.queue[0].asked) return 0;
3510
+ const i = state.queue.findIndex(item => !item.bet.concern);
3511
+ return i >= 0 ? i : 0;
3512
+ }
3513
+ const checkedReady = () => state.queue.filter(item => !item.bet.concern);
2999
3514
 
3000
3515
  function showQuestion(item) {
3001
3516
  if (!LOCAL_QUESTIONS.has(item.question.key)) appendLedger(questionsFile, { id: item.question.key, workspace: deps.workspaceRoot, status: 'shown', shown_at: iso() });
@@ -3009,7 +3524,7 @@ function createBetDesk(deps) {
3009
3524
  // a re-tap on a job card moves on: a yes waits for a later session, a done is seen.
3010
3525
  // later: not now on a yes, which also sends it to the back of the job deck
3011
3526
  function passJob(item, { later = false } = {}) {
3012
- if (item.kind === 'wait') return;
3527
+ if (item.kind === 'wait' || item.kind === 'missed') return;
3013
3528
  if (item.kind === 'report') { state.passedQuestions.add(item.id); return; }
3014
3529
  if (item.kind === 'choice') {
3015
3530
  for (const i of item.items) record(i.id, { status: 'passed', note: 'not picked' });
@@ -3044,18 +3559,31 @@ function createBetDesk(deps) {
3044
3559
  state.retry = setTimer(() => { state.retry = null; pump(); }, BACKOFF_MS);
3045
3560
  }
3046
3561
 
3047
- function startGeneration({ sharperOf = null, reworkOf = null, ownerNote = null } = {}) {
3562
+ function startGeneration({ sharperOf = null, reworkOf = null, ownerNote = null, concern = null } = {}) {
3048
3563
  if (state.inFlight >= MAX_IN_FLIGHT || state.inFlight >= SESSION_IN_FLIGHT_CAP) return null;
3049
3564
  state.inFlight += 1;
3050
3565
  state.maxInFlight = Math.max(state.maxInFlight, state.inFlight);
3051
3566
  state.run += 1;
3052
- const gen = { run: state.run, events: [], sharperOf };
3567
+ // what the idea is written from on the owner's side, read before it starts
3568
+ const key = ownerStateKey();
3569
+ const stopper = new AbortController();
3570
+ const asks = { sharperOf, reworkOf, ownerNote, concern };
3571
+ const gen = { run: state.run, events: [], sharperOf, started_at: now(), key, asked: Boolean(sharperOf || ownerNote), asks, stopping: false };
3572
+ // stops every model call this drafting has running; its answer is dropped.
3573
+ // say: the plain line the waiting card shows for it
3574
+ gen.stop = (why, say = null) => {
3575
+ if (gen.stopping) return;
3576
+ gen.stopping = why || true;
3577
+ gen.stopSay = say;
3578
+ gen.stopped_at = now();
3579
+ stopper.abort();
3580
+ };
3053
3581
  state.gen = gen;
3054
3582
  const emit = (ev) => { gen.events.push({ i: gen.events.length, at: now(), ...ev }); };
3055
3583
  // started now, not on a later tick, so the in-flight count is exact
3056
3584
  let started;
3057
3585
  try {
3058
- started = Promise.resolve(generate({ workspaceRoot: deps.workspaceRoot, today: dayKey(now()), previousTitle: sharperOf, avoidTitles: avoidTitles(), ...(ownerNote ? { reworkOf, ownerNote } : {}), emit, onBundle: (b) => { state.bundle = b; } }));
3586
+ started = Promise.resolve(generate({ workspaceRoot: deps.workspaceRoot, today: dayKey(now()), previousTitle: sharperOf, avoidTitles: avoidTitles(), ...(ownerNote ? { reworkOf, ownerNote } : {}), ...(concern ? { concern } : {}), signal: stopper.signal, emit, onBundle: (b) => { state.bundle = b; } }));
3059
3587
  } catch (err) {
3060
3588
  started = Promise.reject(err);
3061
3589
  }
@@ -3065,18 +3593,39 @@ function createBetDesk(deps) {
3065
3593
  state.inFlight -= 1;
3066
3594
  if (state.gen === gen) state.gen = null;
3067
3595
  if (out.bundle) state.bundle = out.bundle;
3596
+ // stopped on purpose: not a failure, and an ask it carried is asked again
3597
+ if (gen.stopping || out.stopped) {
3598
+ log({ card: 'bet', choice: 'generate', command: null, ran: 'in process', exit_code: 0, stopped: clip(String(gen.stopping === true ? 'stopped' : gen.stopping || 'stopped'), 120) });
3599
+ if (gen.asked && !state.pendingSharper) state.pendingSharper = asks;
3600
+ return pump();
3601
+ }
3068
3602
  if (!out.ok) {
3069
- // a failure stays in the log; the owner never waits on it or sees it
3603
+ // a failure stays in the log; the owner never sees the reason
3070
3604
  log({ card: 'bet', choice: 'generate', command: null, ran: 'in process', exit_code: 1, error: clip(out.reason, 200) });
3071
3605
  emit({ kind: 'failed', line: out.reason });
3072
3606
  state.failures += 1;
3073
3607
  if (state.failures >= MAX_BACKGROUND_FAILURES) backOff();
3608
+ // the owner is waiting on the calm line: the wait ends in a card he can act on
3609
+ if (onWait()) showMissed();
3610
+ return pump();
3611
+ }
3612
+ const item = { id: newId(), bet: out.bet, created_at: now(), state_key: key, ...(gen.asked ? { asked: true } : {}), ...(ownerNote && reworkOf ? { rework_of: reworkOf } : {}), ...(ownerNote ? { owner_note: ownerNote } : {}) };
3613
+ // stale the moment it lands (too close to one just shown): counted as
3614
+ // a failure, so a writer that keeps landing there rests instead of looping
3615
+ const staleNow = staleWhy(item, seenTitles());
3616
+ if (staleNow) {
3617
+ log({ card: 'bet', choice: 'generate', command: null, ran: 'in process', exit_code: 1, error: clip(`stale on arrival: ${staleNow}`, 200) });
3618
+ state.failures += 1;
3619
+ if (state.failures >= MAX_BACKGROUND_FAILURES) {
3620
+ backOff();
3621
+ // resting now, so nothing is drafting for a wait on screen
3622
+ if (onWait()) showMissed();
3623
+ }
3074
3624
  return pump();
3075
3625
  }
3076
3626
  state.failures = 0;
3077
- const item = { id: newId(), bet: out.bet, created_at: now() };
3078
- record(item.id, { created_at: iso(), status: 'queued', bet: out.bet, previous_title: sharperOf, ...(ownerNote ? { rework_of: reworkOf, owner_note: ownerNote } : {}) });
3079
- log({ card: 'bet', choice: 'generate', command: null, ran: 'in process', exit_code: 0, bet_id: item.id });
3627
+ record(item.id, { created_at: iso(), status: 'queued', bet: out.bet, state_key: key, checked: out.checked !== false && !out.bet.concern, previous_title: sharperOf, ...(ownerNote ? { rework_of: reworkOf, owner_note: ownerNote } : {}) });
3628
+ log({ card: 'bet', choice: 'generate', command: null, ran: 'in process', exit_code: 0, bet_id: item.id, checked: out.checked !== false && !out.bet.concern, ms: now() - gen.started_at });
3080
3629
  emit({ kind: 'done', line: 'bet ready' });
3081
3630
  // the sharper or reworked idea the owner asked for is the next shown
3082
3631
  if (sharperOf || ownerNote) state.queue.unshift(item);
@@ -3095,6 +3644,10 @@ function createBetDesk(deps) {
3095
3644
  pumpQuestions();
3096
3645
  pumpSuggestions();
3097
3646
  dropStale();
3647
+ // the owner answered or picked a number since this drafting began: what
3648
+ // it writes would not know that, so it stops and a fresh one starts
3649
+ if (state.gen && !state.gen.stopping && state.gen.key !== ownerStateKey()) state.gen.stop('the owner answered something new', 'starting over with your newest answer');
3650
+ if (state.pendingSharper) makeRoomFor();
3098
3651
  if (state.inFlight >= MAX_IN_FLIGHT) return state.gen && state.gen.done;
3099
3652
  if (state.pendingSharper) {
3100
3653
  const asked = state.pendingSharper;
@@ -3125,6 +3678,10 @@ function createBetDesk(deps) {
3125
3678
  function next({ sharper = false, later = false } = {}) {
3126
3679
  if (state.status === 'starting') return { ok: true, busy: true, ...view() };
3127
3680
  const onScreen = state.status === 'ready' && state.current ? state.current : null;
3681
+ // try again after a wait that ended with a note on it: the note goes into the next
3682
+ // drafting, or the calm line joins the one already writing it, never a second
3683
+ const carried = onScreen && onScreen.kind === 'missed' && onScreen.note ? onScreen : null;
3684
+ if (carried && !state.pendingSharper && !noteUnderWay(carried.note)) state.pendingSharper = { ownerNote: carried.note };
3128
3685
  // passed, skipped, or answered: the card after a yes is never another yes
3129
3686
  const afterYes = Boolean(state.current && state.current.kind === 'yes');
3130
3687
  if (onScreen && onScreen.kind && onScreen.kind !== 'bet') {
@@ -3132,7 +3689,9 @@ function createBetDesk(deps) {
3132
3689
  } else if (onScreen) {
3133
3690
  record(onScreen.id, { status: 'passed', ...(sharper ? { note: 'asked for a sharper idea' } : {}) });
3134
3691
  log({ card: 'bet', choice: sharper ? 'sharper' : 'pass', command: null, ran: 'in process', exit_code: 0, bet_id: onScreen.id });
3135
- if (sharper) state.pendingSharper = { sharperOf: onScreen.bet.title };
3692
+ // sharpening one a second look worried about: the new draft must answer the worry
3693
+ const worry = onScreen.bet.concern && onScreen.bet.skeptic && onScreen.bet.skeptic.reason;
3694
+ if (sharper) state.pendingSharper = { sharperOf: onScreen.bet.title, ...(worry ? { concern: worry } : {}) };
3136
3695
  } else {
3137
3696
  log({ card: 'bet', choice: 'think', command: null, ran: 'in process', exit_code: 0 });
3138
3697
  }
@@ -3140,15 +3699,28 @@ function createBetDesk(deps) {
3140
3699
  if (now() - state.jobs.at > 5000) pollJobs();
3141
3700
  const job = nextJobCard({ afterYes });
3142
3701
  const ask = job ? null : askCard();
3702
+ const checked = checkedReady();
3703
+ const noted = notedAt(onScreen);
3704
+ // the calm line's note is not in yet and its wait is over: the ready idea comes on its own, saying so
3705
+ const folding = noted < 0 && onScreen && onScreen.kind === 'wait' && onScreen.note ? onScreen.note : null;
3143
3706
  if (job) showJob(job);
3144
3707
  else if (ask) showAsk(ask);
3145
- else if (deps.choices !== false && state.queue.length >= 2) showChoice(state.queue.splice(0, CHOICE_MAX));
3146
- else if (state.queue.length) show(state.queue.shift());
3708
+ // the idea written with the note comes in on its own
3709
+ else if (noted >= 0) { show(state.queue.splice(noted, 1)[0]); state.noteComing = null; }
3710
+ else if (folding && state.queue.length) { show(state.queue.splice(nextIndex(), 1)[0]); state.current.folding = true; state.noteComing = folding; }
3711
+ // two or more checked ideas ready: one card to pick from; one a second look worried about is never in it
3712
+ else if (deps.choices !== false && checked.length >= 2) {
3713
+ const items = checked.slice(0, CHOICE_MAX);
3714
+ state.queue = state.queue.filter(item => !items.includes(item));
3715
+ showChoice(items);
3716
+ } else if (state.queue.length) show(state.queue.splice(nextIndex(), 1)[0]);
3147
3717
  else {
3148
3718
  const question = nextQuestion();
3149
3719
  if (question) showQuestion(question);
3150
3720
  else {
3151
3721
  showWait();
3722
+ // after try again the calm line carries the note: it shows it and waits for the idea written with it
3723
+ if (carried && state.current.kind === 'wait' && !state.current.note) Object.assign(state.current, { notes: carried.notes || [carried.note], note: carried.note, noteAt: now() });
3152
3724
  // the card says an idea is being written, so one is, even mid backoff
3153
3725
  if (!state.gen) { state.failures = 0; state.backoffUntil = 0; }
3154
3726
  }
@@ -3447,7 +4019,7 @@ function createBetDesk(deps) {
3447
4019
  id: cur ? cur.id : null,
3448
4020
  kind,
3449
4021
  bet: kind === 'bet' ? cur.bet : null,
3450
- job: kind === 'yes' || kind === 'done' ? publicJob(cur) : (kind === 'wait' ? { ...WAIT_CARD } : null),
4022
+ job: kind === 'yes' || kind === 'done' ? publicJob(cur) : (kind === 'wait' ? { ...WAIT_CARD } : (kind === 'missed' ? { ...MISSED_CARD } : null)),
3451
4023
  question: kind === 'question' ? { ask: cur.question.ask, line: cur.question.line, choices: cur.question.choices } : null,
3452
4024
  report: kind === 'report' ? {
3453
4025
  what: `${cur.board.name}: what is it this week?`,
@@ -3501,6 +4073,7 @@ function createBetDesk(deps) {
3501
4073
  // the card as the reader sees it
3502
4074
  function cardForReader(v) {
3503
4075
  if (v.kind === 'question') return { kind: 'question', title: v.question.ask, detail: v.question.line, buttons: v.question.choices.map(c => c.label) };
4076
+ if (v.kind === 'bet' && v.bet.concern) return { kind: 'idea', title: v.bet.title, detail: [v.bet.why_now, v.bet.expect, v.bet.concern].join(' '), buttons: ['go with it', 'sharpen it', 'something else'] };
3504
4077
  if (v.kind === 'bet') return { kind: 'idea', title: v.bet.title, detail: [v.bet.why_now, v.bet.expect].join(' '), buttons: ['do it', 'sharper idea', 'not now'] };
3505
4078
  if (v.kind === 'choice') return { kind: 'choice', title: 'which one next?', detail: v.choice.options.map(o => o.title).join(' / '), buttons: [] };
3506
4079
  if (v.kind === 'report') return { kind: 'number', title: v.report.what, detail: v.report.why, buttons: ['save', 'not now'] };
@@ -3537,18 +4110,24 @@ function createBetDesk(deps) {
3537
4110
  const said = String(text || '').trim();
3538
4111
  if (!said) return { ok: false, reason: 'empty', message: 'say something first' };
3539
4112
  if (said.length > SAY_MAX) return { ok: false, reason: 'too_long', message: `keep it under ${SAY_MAX} characters` };
3540
- const cur = state.status === 'ready' ? state.current : null;
4113
+ let cur = state.status === 'ready' ? state.current : null;
3541
4114
  if (!cur) return { ok: false, reason: 'gone', message: 'there is no card on screen' };
3542
4115
  // words said on a card the page no longer shows act on nothing
3543
4116
  if (card !== undefined && card !== cur.id) return { ok: false, reason: 'gone', message: 'that card changed, so nothing was done. tap the orb for the new one' };
3544
- const kind = cur.kind || 'bet';
4117
+ // on the calm line there is nothing to answer: the words are a note for the idea being written, read by no model
4118
+ if (cur.kind === 'wait') return noteOnWait(cur, said);
4119
+ let kind = cur.kind || 'bet';
3545
4120
  log({ card: 'bet', choice: 'say', command: null, ran: 'in process', exit_code: 0, kind, chars: said.length });
3546
4121
  const t = threadFor(cur.id);
3547
4122
  let read = { kind: 'instruction', reply: '' };
3548
4123
  // a second tap or a note acts on what was already read
3549
4124
  if (!confirm && !note) {
3550
4125
  const turn = await readTurn(t, said, cardForReader(view()));
3551
- // the card moved on while it was read (a tap, do it): drop the reply
4126
+ // the card moved on while it was read (a tap, do it): drop the reply.
4127
+ // Not when a wait ended on its own (nothing came back): his words are
4128
+ // said on the try-again card that took its place, never dropped
4129
+ const replaced = kind === 'wait' && state.status === 'ready' && state.current && state.current.kind === 'missed' && state.current.after === cur.id;
4130
+ if (replaced) { cur = state.current; kind = 'missed'; }
3552
4131
  if (state.current !== cur || state.status !== 'ready') return { ok: false, reason: 'gone', message: 'that card changed, so nothing was done' };
3553
4132
  if (turn.stay) return turn.stay;
3554
4133
  read = turn.act;
@@ -3732,33 +4311,7 @@ function createBetDesk(deps) {
3732
4311
  const loaded = (deps.loadSeason || (root => season.loadSeason(root, { now: now(), lastSeen: null })))(deps.workspaceRoot);
3733
4312
  if (loaded && !loaded.error) ratings = season.memberRatings(loaded.outcomes || []);
3734
4313
  } catch { ratings = new Map(); }
3735
- let resolve = deps.resolveEngine;
3736
- if (!resolve) {
3737
- const reg = require('./engine-registry');
3738
- const { resolveEngineForMember } = require('./member-engine');
3739
- let rosterState = null;
3740
- try { rosterState = reg.readRosterState(deps.workspaceRoot); } catch { rosterState = null; }
3741
- resolve = name => resolveEngineForMember(name, deps.workspaceRoot, { quiet: true, ...(rosterState ? { rosterState } : {}) });
3742
- }
3743
- const facts = new Map();
3744
- const { memberTypeOf } = require('./member-type');
3745
- for (const m of team || []) {
3746
- const card = memberCardFacts(deps.workspaceRoot, m.name);
3747
- const kind = memberTypeOf({ type: card.type });
3748
- let engine = kind.type === 'human' ? 'person' : NO_RATING;
3749
- if (kind.runnable) {
3750
- let picked = null;
3751
- try { picked = resolve(m.name); } catch { picked = null; }
3752
- engine = engineWords(picked);
3753
- }
3754
- const rated = ratings.get(m.name);
3755
- facts.set(m.name, {
3756
- role: memberRoleWords(m.name, card),
3757
- ovr: !rated || !rated.finished ? NO_RATING : rated.ovr == null ? 'new' : String(rated.ovr),
3758
- engine,
3759
- parked: card.parked,
3760
- });
3761
- }
4314
+ const facts = teamCells(deps.workspaceRoot, (team || []).map(m => m.name), { ratings, resolve: deps.resolveEngine || null });
3762
4315
  rosterCache = { at: now(), facts };
3763
4316
  return facts;
3764
4317
  }
@@ -3981,11 +4534,13 @@ function createBetDesk(deps) {
3981
4534
  }
3982
4535
  if (state.status !== 'ready') return null;
3983
4536
  const v = view();
3984
- if (kind === 'bet') return gmCard.idea({ id: cur.id, title: cur.bet.title, body: betBody(cur.bet) });
4537
+ // an idea no draft beat in time carries the second look's worry, in one plain line
4538
+ if (kind === 'bet') return gmCard.idea({ id: cur.id, title: cur.bet.title, body: betBody(cur.bet), ...(cur.bet.concern ? { concern: cur.folding ? `${cur.bet.concern} ${NOTE_FOLDING}` : cur.bet.concern } : {}), ...(cur.folding ? { line: NOTE_FOLDING } : {}) });
3985
4539
  if (kind === 'question') return gmCard.question({ id: cur.id, title: v.question.ask, body: v.question.line, choices: v.question.choices });
3986
4540
  if (kind === 'report') return gmCard.report({ id: cur.id, title: v.report.what, body: v.report.why });
3987
4541
  if (kind === 'choice') return gmCard.choice({ id: cur.id, items: v.choice.options });
3988
- if (kind === 'wait') return gmCard.wait({ id: cur.id });
4542
+ if (kind === 'wait') return gmCard.wait({ id: cur.id, ...waitProgress(cur), thread: waitThread(cur) });
4543
+ if (kind === 'missed') return gmCard.missed({ id: cur.id });
3989
4544
  if (kind === 'yes') {
3990
4545
  const j = cur.job;
3991
4546
  return gmCard.yes({ id: cur.id, title: v.job.what, body: v.job.why, job: jobFacts(j.owner), request: { job: j.mission_id, key: j.key, hash: j.lock_hash || null } });
@@ -4058,7 +4613,8 @@ function createBetDesk(deps) {
4058
4613
  ...extra,
4059
4614
  card: gmCard.wire(card),
4060
4615
  status: v.status,
4061
- queue: v.queue,
4616
+ // ideas ready behind the card: none yet while the calm line waits for the one with its note
4617
+ queue: waitHolds() ? 0 : v.queue,
4062
4618
  team_working: v.team_working,
4063
4619
  needs_you: v.needs_you,
4064
4620
  };
@@ -4092,7 +4648,12 @@ function createBetDesk(deps) {
4092
4648
  async function sayOnMain(text, more = {}) {
4093
4649
  const under = underCard();
4094
4650
  const out = await say({ text, ...more });
4095
- const back = replied(out, under.id, text);
4651
+ // a note on the calm line: the same card stays, now with the note on it
4652
+ if (out.noted) return done200({ ack: out.ack });
4653
+ // a wait that ended on its own while the words were read: the reply sits on the card that took its place
4654
+ const stays = out.stay || out.reason === 'needs_confirm';
4655
+ const base = stays && under && under.kind === 'wait' && state.current && state.current.kind === 'missed' ? (underCard() || under) : under;
4656
+ const back = replied(out, base.id, text);
4096
4657
  if (back) return back;
4097
4658
  if (!out.ok) return refuse(out.reason || 'gone', out.message || 'that did not send. try again');
4098
4659
  state.reply = null;
@@ -4125,11 +4686,25 @@ function createBetDesk(deps) {
4125
4686
  // from the footer. confirm: the second tap on an option that asks for one.
4126
4687
  async function act(body = {}) {
4127
4688
  const cardId = body.card_id == null || body.card_id === '' ? null : String(body.card_id);
4128
- const shown = state.served;
4129
- const stale = Boolean(cardId && (!shown || cardId !== shown.id));
4689
+ let shown = state.served;
4690
+ // the calm line's words change while the idea is written, under the same
4691
+ // card: an id it was served under before still names it
4692
+ const base = id => String(id).split('#')[0];
4693
+ const same = Boolean(shown && cardId && (cardId === shown.id || (shown.kind === 'wait' && base(cardId) === base(shown.id))));
4694
+ let stale = Boolean(cardId && !same);
4695
+ // words typed on a wait that ended on its own (nothing came back) still
4696
+ // count: they are said on the try-again card that took its place
4697
+ const cur = state.status === 'ready' ? state.current : null;
4698
+ if (stale && cur && cur.kind === 'missed' && cur.afterServed && base(cur.afterServed) === base(cardId) && typeof body.text === 'string' && body.text.trim() && body.option_id == null && !state.layer) {
4699
+ state.reply = null;
4700
+ shown = serve();
4701
+ stale = false;
4702
+ }
4130
4703
  // the big orb: the next card; a page that names an older card gets the one on screen, and nothing is passed
4131
4704
  if (body.orb === true) {
4132
4705
  if (stale && shown) return done200();
4706
+ // the calm line waits for the idea written with its note
4707
+ if (shown && shown.kind === 'wait' && waitHolds()) return done200();
4133
4708
  state.layer = null;
4134
4709
  state.reply = null;
4135
4710
  const out = next({ sharper: false });
@@ -4311,5 +4886,10 @@ module.exports = {
4311
4886
  stopWriters,
4312
4887
  scoreBets,
4313
4888
  skepticCommand,
4889
+ buildSkepticPrompt,
4890
+ writerCommand,
4891
+ WRITER_ANGLES,
4314
4892
  validateBet,
4893
+ teamBox,
4894
+ teamCells,
4315
4895
  };