atris 3.74.7 → 3.74.8

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}`));
@@ -1104,13 +1104,46 @@ function bundleMetrics(bundle) {
1104
1104
  // a real fact (or says it was left out). A missing source is omitted, never
1105
1105
  // invented. Past bets that are due are scored first, so the ledger the model
1106
1106
  // reads already has their results.
1107
- async function readBundle(workspaceRoot, { deps = {}, emit = () => {}, ledgerFile, today = dayKey(Date.now()) } = {}) {
1107
+ // The value of p, or late() once the deadline passes or the signal stops it
1108
+ // first; p then goes on alone with its errors dropped. With no deadline it is
1109
+ // just p. The timer is cleared on every way out, and unref'd as a backstop,
1110
+ // so it never holds the process open.
1111
+ function byDeadline(p, deadline, late, signal = null) {
1112
+ const work = Promise.resolve(p);
1113
+ if (!deadline) return work;
1114
+ let timer;
1115
+ let onStop = null;
1116
+ const cut = new Promise((resolve) => {
1117
+ const give = () => { clearTimeout(timer); work.catch(() => {}); resolve(late()); };
1118
+ timer = setTimeout(give, Math.max(0, deadline - Date.now()));
1119
+ if (timer.unref) timer.unref();
1120
+ if (signal) {
1121
+ if (signal.aborted) give();
1122
+ else { onStop = give; signal.addEventListener('abort', onStop, { once: true }); }
1123
+ }
1124
+ });
1125
+ return Promise.race([work, cut]).finally(() => {
1126
+ clearTimeout(timer);
1127
+ if (onStop) signal.removeEventListener('abort', onStop);
1128
+ });
1129
+ }
1130
+
1131
+ // deadline (ms since 1970, optional): the sources are read by then. Scoring
1132
+ // past bets and each npm read that is still going at the deadline are left
1133
+ // out (scoring finishes on its own), so a slow network never eats the time
1134
+ // the writers need.
1135
+ // signal (optional): a stop ends the waits at once, and the npm reads with them.
1136
+ async function readBundle(workspaceRoot, { deps = {}, emit = () => {}, ledgerFile, today = dayKey(Date.now()), deadline = null, signal = null } = {}) {
1108
1137
  const ledger = ledgerFile || ledgerPath();
1138
+ const LATE = Symbol('late');
1109
1139
  try {
1110
- await (deps.scoreBets || scoreBets)(workspaceRoot, { ledgerFile: ledger, today, deps, emit });
1140
+ const scored = await byDeadline((deps.scoreBets || scoreBets)(workspaceRoot, { ledgerFile: ledger, today, deps, emit }), deadline, () => LATE, signal);
1141
+ if (scored === LATE) emit({ kind: 'skip', key: 'score', line: 'scoring past bets took too long, so it finishes on its own' });
1111
1142
  } catch {
1112
1143
  // scoring is a side job; a failure never blocks the next bet
1113
1144
  }
1145
+ // an npm read gets what is left before the deadline, at most its usual 10 seconds
1146
+ const fetchOpts = () => (deadline ? { timeoutMs: Math.max(1, Math.min(10000, deadline - Date.now())), ...(signal ? { signal } : {}) } : undefined);
1114
1147
  const bundle = { workspace: workspaceRoot, today, sources: {}, facts: [] };
1115
1148
  const add = (key, fact, data) => {
1116
1149
  bundle.sources[key] = { fact, ...data };
@@ -1164,7 +1197,9 @@ async function readBundle(workspaceRoot, { deps = {}, emit = () => {}, ledgerFil
1164
1197
  if (pkg) {
1165
1198
  try {
1166
1199
  // 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)}`);
1200
+ const url = `https://api.npmjs.org/downloads/range/last-year/${npmName(pkg)}`;
1201
+ installBody = await byDeadline(fetchOpts() ? fetchJson(url, fetchOpts()) : fetchJson(url), deadline, () => LATE, signal);
1202
+ if (installBody === LATE) throw new Error('npm took too long');
1168
1203
  installs = installWeeks(installBody);
1169
1204
  } catch {
1170
1205
  installs = null;
@@ -1189,7 +1224,8 @@ async function readBundle(workspaceRoot, { deps = {}, emit = () => {}, ledgerFil
1189
1224
  let version = null;
1190
1225
  if (pkg) {
1191
1226
  try {
1192
- const latest = await fetchJson(`https://registry.npmjs.org/${npmName(pkg)}/latest`);
1227
+ const url = `https://registry.npmjs.org/${npmName(pkg)}/latest`;
1228
+ const latest = await byDeadline(fetchOpts() ? fetchJson(url, fetchOpts()) : fetchJson(url), deadline, () => null, signal);
1193
1229
  version = latest && typeof latest.version === 'string' && /^\d+\.\d+\.\d+/.test(latest.version) ? latest.version : null;
1194
1230
  } catch {
1195
1231
  version = null;
@@ -1340,7 +1376,7 @@ function metricChoices(bundle) {
1340
1376
  });
1341
1377
  }
1342
1378
 
1343
- function buildPrompt(bundle, { today, previousTitle = null, reason = null, previousAnswer = null, blocker = null, avoidTitles = [], reworkOf = null, ownerNote = null } = {}) {
1379
+ function buildPrompt(bundle, { today, previousTitle = null, reason = null, previousAnswer = null, blocker = null, avoidTitles = [], reworkOf = null, ownerNote = null, angle = null, concern = null } = {}) {
1344
1380
  const choices = metricChoices(bundle);
1345
1381
  const lines = [
1346
1382
  'You are the strategist for a small software business. Below is everything we can read about it right now.',
@@ -1375,6 +1411,10 @@ function buildPrompt(bundle, { today, previousTitle = null, reason = null, previ
1375
1411
  // not one more step on the old idea
1376
1412
  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
1413
  if (previousTitle) lines.push('', `Make it sharper, more specific, and different from: ${previousTitle}`);
1414
+ // the owner asked to sharpen an idea a second look worried about
1415
+ 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.`);
1416
+ // each writer in the swarm leans a different way, so the drafts differ
1417
+ if (angle) lines.push('', angle);
1378
1418
  // what the owner typed on a card
1379
1419
  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
1420
  else if (ownerNote) lines.push('', `The owner said: "${clip(ownerNote, 280)}". The next idea must fit that.`);
@@ -1400,6 +1440,24 @@ function claudeArgs(prompt, model = MODEL) {
1400
1440
  return ['-p', prompt, '--model', model, '--output-format', 'stream-json', '--verbose', '--include-partial-messages', '--tools', '', '--no-session-persistence', '--strict-mcp-config'];
1401
1441
  }
1402
1442
 
1443
+ // The writer and the skeptic think briefly: on the real prompt, low effort
1444
+ // answered in 17 to 19 seconds against 21 at the default, and its answers
1445
+ // passed the plain-rule checks.
1446
+ const IDEA_EFFORT = 'low';
1447
+ function writerCommand() {
1448
+ return { bin: 'claude', args: p => [...claudeArgs(p, MODEL), '--effort', IDEA_EFFORT] };
1449
+ }
1450
+
1451
+ // The writer and the skeptic get every fact in the prompt, so they run in an
1452
+ // empty folder of their own: started in the workspace, the model also loads
1453
+ // that project's agent instructions and memory, about 11,000 tokens nobody
1454
+ // asked for, and its first word came 10 seconds later (measured).
1455
+ function quietDir() {
1456
+ const dir = path.join(require('os').tmpdir(), 'atris-gm-ideas');
1457
+ try { fs.mkdirSync(dir, { recursive: true }); } catch { /* the model can still start in a missing folder's parent */ }
1458
+ return dir;
1459
+ }
1460
+
1403
1461
  // Runs the model in its own process group. On timeout the whole group is
1404
1462
  // killed, so nothing it started keeps running. Streams real progress (tokens
1405
1463
  // thought, characters written) through onProgress.
@@ -1407,17 +1465,38 @@ function claudeArgs(prompt, model = MODEL) {
1407
1465
  // stops; otherwise a stopped server leaves them running on their own.
1408
1466
  const LIVE_WRITERS = new Map();
1409
1467
 
1468
+ // SIGTERM now, then SIGKILL for the same groups after the grace, so a child
1469
+ // that ignores SIGTERM does not outlive the server either.
1410
1470
  function stopWriters() {
1411
1471
  let n = 0;
1412
- for (const [pid, kill] of LIVE_WRITERS) {
1472
+ const groups = [...LIVE_WRITERS];
1473
+ for (const [pid, kill] of groups) {
1413
1474
  try { kill(-pid, 'SIGTERM'); n += 1; } catch { /* already gone */ }
1414
1475
  }
1415
1476
  LIVE_WRITERS.clear();
1477
+ if (groups.length) {
1478
+ const t = setTimeout(() => {
1479
+ for (const [pid, kill] of groups) {
1480
+ try { kill(-pid, 'SIGKILL'); } catch { /* already gone */ }
1481
+ }
1482
+ }, STOP_GRACE_MS);
1483
+ if (t.unref) t.unref();
1484
+ }
1416
1485
  return n;
1417
1486
  }
1418
1487
 
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) } = {}) {
1488
+ // A signal (an AbortSignal) stops the call early: the whole process group
1489
+ // gets SIGTERM, then SIGKILL if it is still there after STOP_GRACE_MS, and
1490
+ // the call resolves once the process is gone, so nothing it started is left.
1491
+ const STOP_GRACE_MS = 1500;
1492
+ const STOPPED_REASON = 'stopped: another idea was picked first';
1493
+
1494
+ 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
1495
  return new Promise((resolve) => {
1496
+ if (signal && signal.aborted) {
1497
+ resolve({ ok: false, reason: STOPPED_REASON, stopped: true });
1498
+ return;
1499
+ }
1421
1500
  const args = typeof command.args === 'function' ? command.args(prompt) : [...(command.args || []), prompt];
1422
1501
  let child;
1423
1502
  try {
@@ -1434,15 +1513,36 @@ function runModel(prompt, { command = { bin: 'claude', args: claudeArgs }, cwd,
1434
1513
  let result = null;
1435
1514
  let thinking = 0;
1436
1515
  let timedOut = false;
1516
+ let stopped = false;
1517
+ let hardKill = null;
1437
1518
  let done = false;
1438
- const timer = setTimeout(() => {
1439
- timedOut = true;
1519
+ const killGroup = (sig) => {
1440
1520
  try {
1441
- kill(-child.pid, 'SIGKILL');
1521
+ kill(-child.pid, sig);
1442
1522
  } catch {
1443
- try { child.kill('SIGKILL'); } catch { /* already gone */ }
1523
+ try { child.kill(sig); } catch { /* already gone */ }
1444
1524
  }
1525
+ };
1526
+ const timer = setTimeout(() => {
1527
+ timedOut = true;
1528
+ killGroup('SIGKILL');
1445
1529
  }, timeoutMs);
1530
+ // The SIGKILL for the group always fires after the grace, even when the
1531
+ // model itself has exited by then: a child it started with its own
1532
+ // stdio can ignore SIGTERM and outlive it, so the group is tracked (and
1533
+ // the timer kept alive) until that kill has gone out.
1534
+ const onAbort = () => {
1535
+ if (done || stopped) return;
1536
+ stopped = true;
1537
+ killGroup('SIGTERM');
1538
+ hardKill = setTimeout(() => {
1539
+ killGroup('SIGKILL');
1540
+ if (LIVE_WRITERS.get(child.pid) === kill) LIVE_WRITERS.delete(child.pid);
1541
+ }, STOP_GRACE_MS);
1542
+ // it fires while the process lives, and never holds it open on its own
1543
+ if (hardKill.unref) hardKill.unref();
1544
+ };
1545
+ if (signal) signal.addEventListener('abort', onAbort, { once: true });
1446
1546
  const line = (raw) => {
1447
1547
  if (!raw.trim()) return;
1448
1548
  let ev;
@@ -1476,12 +1576,16 @@ function runModel(prompt, { command = { bin: 'claude', args: claudeArgs }, cwd,
1476
1576
  if (done) return;
1477
1577
  done = true;
1478
1578
  clearTimeout(timer);
1479
- LIVE_WRITERS.delete(child.pid);
1579
+ if (signal) signal.removeEventListener('abort', onAbort);
1580
+ // a stopped call keeps its SIGKILL and its place in LIVE_WRITERS until
1581
+ // the grace is over; every other call is finished with its group
1582
+ if (!stopped) LIVE_WRITERS.delete(child.pid);
1480
1583
  resolve(out);
1481
1584
  };
1482
1585
  child.on('error', err => finish({ ok: false, reason: `could not start the model: ${err.message}` }));
1483
1586
  child.on('close', (code) => {
1484
1587
  if (buf) line(buf);
1588
+ if (stopped) return finish({ ok: false, reason: STOPPED_REASON, stopped: true });
1485
1589
  if (timedOut) return finish({ ok: false, reason: `the model took longer than ${Math.round(timeoutMs / 1000)} seconds and was stopped`, timed_out: true });
1486
1590
  if (result && result.is_error) return finish({ ok: false, reason: `the model said: ${clip(result.result || result.subtype, 160)}` });
1487
1591
  const answer = result && typeof result.result === 'string' ? result.result : text;
@@ -1781,13 +1885,9 @@ function cleanBet(bet, today, bundle) {
1781
1885
 
1782
1886
  // ---- one generation ----------------------------------------------------------------------
1783
1887
 
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
1888
  // 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.
1889
+ // answer, parse result, reasons, timing), so a failed live run can be read
1890
+ // back afterwards. generateBet (the swarm, below) writes them.
1791
1891
  function attemptsPath(stateDir = defaultStateDir()) {
1792
1892
  return path.join(stateDir, 'gm-bet-attempts.jsonl');
1793
1893
  }
@@ -1811,21 +1911,30 @@ function wordCount(text) {
1811
1911
  // Whether the move actually moves its number, what has to be true first, and
1812
1912
  // whether a sharp founder would laugh at it is judgment, so a separate model
1813
1913
  // call owns it. It gets the bet and the same facts, never the writer's prompt.
1914
+ //
1915
+ // The bar is the inner scorecard: the team is judged only on what it controls
1916
+ // and can check. A pass needs every team step to be work the team can do and
1917
+ // see done, and the move to push the number the right way this week. It does
1918
+ // not have to reach the goal: one idea cannot add a whole week's target, and
1919
+ // the old bar (from the baseline to the goal) turned down all of the last 82
1920
+ // drafts it judged, so every tap waited and then got nothing.
1814
1921
  function buildSkepticPrompt(bet, bundle, { today } = {}) {
1815
1922
  return [
1816
1923
  '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
1924
  '',
1818
1925
  'Answer with strict JSON only, no prose and no code fence, in this shape:',
1819
- '{"verdict": "pass", "missing_first": "...", "moves_metric": true, "reason": "..."}',
1926
+ '{"verdict": "pass", "doable": true, "moves_metric": true, "missing_first": "...", "reason": "...", "concern": "..."}',
1820
1927
  '',
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.',
1928
+ 'The team is judged only on what it controls and can check. Judge it that way, against the facts below:',
1929
+ '- 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.',
1930
+ '- 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
1931
  '- 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
1932
  '- would a sharp founder reading this find it obviously wrong, padded, or a waste of the week?',
1825
1933
  '- does it propose a trade, buying or selling stocks, shares, crypto, or any security, or moving money between accounts: fail it.',
1826
1934
  '- 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".',
1935
+ '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
1936
  '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.',
1937
+ `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
1938
  '',
1830
1939
  `Today is ${today}.`,
1831
1940
  '',
@@ -1850,17 +1959,21 @@ function parseVerdict(text) {
1850
1959
  moves_metric: v.moves_metric,
1851
1960
  missing_first: typeof v.missing_first === 'string' ? clip(v.missing_first, 240) : '',
1852
1961
  reason: clip(v.reason, 400),
1962
+ // doable and concern are newer; an answer without them still counts
1963
+ ...(typeof v.doable === 'boolean' ? { doable: v.doable } : {}),
1964
+ ...(typeof v.concern === 'string' && v.concern.trim() ? { concern: clip(v.concern.trim(), 200) } : {}),
1853
1965
  },
1854
1966
  };
1855
1967
  }
1856
1968
 
1857
1969
  function skepticCommand() {
1858
- return { bin: 'claude', args: p => claudeArgs(p, SKEPTIC_MODEL) };
1970
+ return { bin: 'claude', args: p => [...claudeArgs(p, SKEPTIC_MODEL), '--effort', IDEA_EFFORT] };
1859
1971
  }
1860
1972
 
1861
- // A pass needs both: the verdict and the skeptic's own yes on moving the number.
1973
+ // A pass needs the verdict and the skeptic's own yes on moving the number,
1974
+ // and never a no on whether the team can do and check every step.
1862
1975
  function skepticPassed(v) {
1863
- return v.verdict === 'pass' && v.moves_metric === true;
1976
+ return v.verdict === 'pass' && v.moves_metric === true && v.doable !== false;
1864
1977
  }
1865
1978
 
1866
1979
  // What the skeptic said must be true first, or null when it said nothing is.
@@ -1872,7 +1985,8 @@ function blockerOf(v) {
1872
1985
  function skepticReason(v) {
1873
1986
  const missing = blockerOf(v) ? ` missing first: ${v.missing_first.replace(/[.\s]+$/, '')}.` : '';
1874
1987
  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")`;
1988
+ const doable = v.doable === false ? ' a team step is not work the team can do and check on its own.' : '';
1989
+ 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
1990
  }
1877
1991
 
1878
1992
  // A free repair turn for an answer that broke only length or sentence limits:
@@ -1904,124 +2018,319 @@ function trimChangedMore(before, after) {
1904
2018
  return a.length !== b.length || a.some((s, i) => !b[i] || s.who !== b[i].who || s.public !== b[i].public);
1905
2019
  }
1906
2020
 
1907
- async function generateBet({ workspaceRoot, today, previousTitle = null, avoidTitles: avoidIn = [], reworkOf = null, ownerNote = null, emit = () => {}, onBundle = () => {}, deps = {}, ledgerFile, attemptsFile, allowTeamMetrics = false } = {}) {
2021
+ // ---- the swarm: several writers at once -----------------------------------------------
2022
+
2023
+ // Three writers start at once, each leaning a different way, and every draft
2024
+ // is checked the moment it lands: the plain rules in code, then the skeptic,
2025
+ // while the other writers keep going. The first draft that passes both is the
2026
+ // idea, and every call still running is stopped. Nothing waits past the
2027
+ // budget: when it runs out, the best draft so far is the idea, with the
2028
+ // skeptic's worry in one plain line. Measured on the real prompt, one writer
2029
+ // took 17 to 27 seconds and one skeptic 9 to 20, and one writer after another
2030
+ // with its skeptic took over two minutes across three tries.
2031
+ const SWARM_WRITERS = 3;
2032
+ const SWARM_BUDGET_MS = 30000;
2033
+ // reading the facts gets the first quarter of it; anything slower is left out
2034
+ const SOURCES_SHARE = 0.25;
2035
+ // a call that cannot finish inside the budget is not started: a writer needs
2036
+ // about 40% of it, a skeptic about 13% (the shares of a 30 second budget)
2037
+ const WRITE_MIN_LEFT_MS = 12000;
2038
+ const JUDGE_MIN_LEFT_MS = 4000;
2039
+ const COUNT_WORDS = ['no', 'one', 'two', 'three', 'four', 'five', 'six'];
2040
+ // each writer leans a different way; any project has these three doors
2041
+ const WRITER_ANGLES = [
2042
+ { 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.' },
2043
+ { 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.' },
2044
+ { key: 'proof', text: 'Lean toward proof: one small thing people can see, try, or copy this week that shows what this does.' },
2045
+ ];
2046
+
2047
+ // What the waiting card says while the drafts are written, in the owner's
2048
+ // words: each event that carries say is one line the page can show.
2049
+ const SWARM_SAY = {
2050
+ drafting: n => (n === 1 ? 'drafting an idea' : `drafting ${COUNT_WORDS[n] || n} ideas at once`),
2051
+ checking: k => `the ${ORDINALS[k - 1] || 'next'} draft is in. checking it`,
2052
+ redraft: 'one draft missed a rule. redrafting it',
2053
+ shorten: 'one draft ran long. shortening it',
2054
+ heldUp: 'found one that holds up',
2055
+ notHeld: 'that one did not hold up. still working',
2056
+ outOfTime: 'out of time. bringing the best one so far',
2057
+ nothing: 'nothing usable came back this time',
2058
+ };
2059
+
2060
+ // The best draft when none passed: one the skeptic only had small doubts
2061
+ // about first, then one it had no time to look at, then the rest, oldest
2062
+ // first within each.
2063
+ function draftRank(c) {
2064
+ if (!c.verdict) return 1;
2065
+ const v = c.verdict;
2066
+ if (v.moves_metric === true && v.doable !== false && !blockerOf(v)) return 0;
2067
+ return 2 + (v.moves_metric === true ? 0 : 2) + (v.doable === false ? 1 : 0) + (blockerOf(v) ? 1 : 0);
2068
+ }
2069
+
2070
+ // The skeptic's worry as one line the owner reads on the card: its own
2071
+ // concern, else the first sentence of its reason, when that is plain words
2072
+ // with none of our own; otherwise a plain line that says what kind of worry.
2073
+ const CONCERN_MAX = 140;
2074
+ function ownerConcern(c) {
2075
+ 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.';
2076
+ const v = c.verdict;
2077
+ const first = String(v.reason || '').split(/(?<=[.!?])\s+/)[0];
2078
+ const said = String(v.concern || first || '').replace(/\s+/g, ' ').trim().replace(/[.\s]+$/, '');
2079
+ const plain = said && said.length <= CONCERN_MAX - 12 && !ownerWord(said) && !/\u2014/.test(said) && !STEP_CODE.some(re => re.test(said));
2080
+ if (plain) return `one worry: ${said.charAt(0).toLowerCase()}${said.slice(1)}.`;
2081
+ if (v.moves_metric !== true) return 'one worry: it may not move your number much this week.';
2082
+ if (v.doable === false) return 'one worry: one step depends on people outside the team.';
2083
+ if (blockerOf(v)) return 'one worry: something has to be in place before it can work.';
2084
+ return 'one worry: it may not be the best use of this week.';
2085
+ }
2086
+
2087
+ // Reads the sources, then runs the swarm. Resolves { ok: true, bet, bundle,
2088
+ // checked: true } for a draft both checks passed; { ok: true, bet (with
2089
+ // concern), checked: false } for the best draft when none passed in the
2090
+ // budget; { ok: false, reason } only when no draft met the plain rules at
2091
+ // all; { ok: false, stopped: true } when the signal stopped it first.
2092
+ // writers: how many at once (tests pass 1 to walk one writer's retries).
2093
+ // Every model attempt, writer and skeptic (the kind field), with its lane, is
2094
+ // kept in the attempts log, the stopped ones too.
2095
+ 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 } = {}) {
2096
+ // the budget runs on the real clock, the one the stop timer uses
2097
+ const deadline = Date.now() + budgetMs;
2098
+ const left = () => deadline - Date.now();
2099
+ const writeMin = Math.min(WRITE_MIN_LEFT_MS, budgetMs * (WRITE_MIN_LEFT_MS / SWARM_BUDGET_MS));
2100
+ const judgeMin = Math.min(JUDGE_MIN_LEFT_MS, budgetMs * (JUDGE_MIN_LEFT_MS / SWARM_BUDGET_MS));
1908
2101
  // a rework may stay close to the idea it reworks
1909
2102
  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;
2103
+ const run = deps.runModel || ((prompt, opts) => runModel(prompt, { ...opts, command: writerCommand() }));
1914
2104
  // tests pass runSkeptic; a stubbed runModel without one is also used for the
1915
2105
  // skeptic, so a test can never reach the real model by accident
1916
2106
  const judge = deps.runSkeptic || deps.runModel || ((prompt, opts) => runModel(prompt, { ...opts, command: skepticCommand() }));
1917
2107
  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 = [];
2108
+ const cwd = deps.runModel ? workspaceRoot : quietDir();
2109
+ const n = Math.max(1, Math.floor(Number(writers)) || 1);
1928
2110
  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
2111
 
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 };
2112
+ // the swarm's shared state, set up before anything is read so the budget
2113
+ // and a stop cover the reading too: the drafts that met the rules, why each
2114
+ // lane ended, and the calls still running, so one stop ends them all
2115
+ const drafts = [];
2116
+ const ends = [];
2117
+ const live = new Set();
2118
+ let turnedDown = 0;
2119
+ let checking = 0;
2120
+ let winner = null;
2121
+ let over = null;
2122
+ let release;
2123
+ const ended = new Promise((resolve) => { release = resolve; });
2124
+ const stop = (why) => {
2125
+ if (over) return;
2126
+ over = why;
2127
+ for (const ac of live) ac.abort();
2128
+ live.clear();
2129
+ release();
2130
+ };
2131
+ if (signal && signal.aborted) stop('stopped');
2132
+ const onSignal = () => stop('stopped');
2133
+ if (signal && !over) signal.addEventListener('abort', onSignal, { once: true });
2134
+ const timer = setTimeout(() => stop('budget'), Math.max(0, left()));
2135
+ // the reading's waits end with the swarm, on every way out
2136
+ const readStop = new AbortController();
2137
+ const finish = (out) => {
2138
+ clearTimeout(timer);
2139
+ readStop.abort();
2140
+ if (signal) signal.removeEventListener('abort', onSignal);
2141
+ return out;
2142
+ };
2143
+
2144
+ // the facts, read in the first share of the budget: whatever is still
2145
+ // slow by then is left out, so the writers keep the time they need
2146
+ const reading = readBundle(workspaceRoot, { deps, emit, ledgerFile, today, deadline: Date.now() + budgetMs * SOURCES_SHARE, signal: readStop.signal });
2147
+ reading.catch(() => {});
2148
+ let bundle;
2149
+ try {
2150
+ bundle = await Promise.race([reading, ended.then(() => null)]);
2151
+ } catch (err) {
2152
+ finish();
2153
+ throw err;
2154
+ }
2155
+ if (over === 'stopped') return finish({ ok: false, stopped: true, reason: 'stopped before an idea was picked' });
2156
+ if (!bundle) return finish({ ok: false, reason: `the facts took longer than ${Math.round(budgetMs / 1000)} seconds to read` });
2157
+ // the desk builds its question cards from these facts while the model works
2158
+ try { onBundle(bundle); } catch { /* the desk's copy is a convenience */ }
2159
+
2160
+ // one model call the swarm can stop; none starts once the budget is spent,
2161
+ // even when the stop timer has not had its turn yet
2162
+ async function call(fn, prompt, opts) {
2163
+ if (over || left() <= 0) return { ok: false, reason: 'the time for this idea ran out', stopped: true };
2164
+ const ac = new AbortController();
2165
+ live.add(ac);
2166
+ try {
2167
+ return await fn(prompt, { ...opts, signal: ac.signal });
2168
+ } catch (err) {
2169
+ return { ok: false, reason: String((err && err.message) || err) };
2170
+ } finally {
2171
+ live.delete(ac);
1948
2172
  }
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 };
2173
+ }
2174
+
2175
+ async function lane(index) {
2176
+ const laneNo = index + 1;
2177
+ const angle = n > 1 ? WRITER_ANGLES[index % WRITER_ANGLES.length] : null;
2178
+ // How each try is asked. first: a fresh bet. patch: fix the last answer
2179
+ // against the reasons (code rejects, or a skeptic fail that names no
2180
+ // blocker). blocker: the skeptic named what must be true first, so the
2181
+ // next bet is clearing that blocker, a new move with its own target.
2182
+ // trim: inside a try, a free turn that only shortens fields over a limit.
2183
+ let strategy = 'first';
2184
+ let reason = null;
2185
+ let previousAnswer = null;
2186
+ let blocker = null;
2187
+ const blocked = [];
2188
+ const tag = { lane: laneNo, ...(angle ? { angle: angle.key } : {}) };
2189
+
2190
+ // one writer call and its code check, saved to the attempts log
2191
+ async function ask(prompt, attempt, how, avoid) {
2192
+ let lastSaid = 0;
2193
+ const started = clock();
2194
+ const answer = await call(run, prompt, {
2195
+ cwd,
2196
+ onProgress: ({ text = '' }) => {
2197
+ if (over) return;
2198
+ const at = clock();
2199
+ if (at - lastSaid < 1200) return;
2200
+ lastSaid = at;
2201
+ const w = wordCount(text);
2202
+ emit({ kind: 'thinking', line: w ? `thinking... ${commas(w)} words written` : 'thinking...', n: w, lane: laneNo });
2203
+ },
2204
+ });
2205
+ 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 };
2206
+ if (!answer.ok) {
2207
+ saveAttempt(attemptsFile, { ...base, kind: 'writer', model_ok: false, ...(answer.stopped ? { stopped: true } : {}), raw: null, parse: null, passed: false, reasons: [answer.reason] });
2208
+ return { failed: answer.reason, stopped: Boolean(answer.stopped), base };
1974
2209
  }
1975
- save(trimmed);
1976
- r = trimmed;
2210
+ const parsed = parseBet(answer.text);
2211
+ const check = parsed.error ? { ok: false, reasons: [parsed.error], formatOnly: false } : validateBet(parsed.bet, { bundle, today, previousTitle, avoidTitles: avoid, allowTeamMetrics });
2212
+ return { parsed, check, base, raw: answer.text };
1977
2213
  }
2214
+ 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 });
2215
+
2216
+ for (let attempt = 1; attempt <= WRITER_ATTEMPTS; attempt += 1) {
2217
+ if (over) return;
2218
+ // a writer that cannot finish inside the budget is never started, the first one included
2219
+ if (left() < writeMin) { ends.push({ lane: laneNo, out_of_time: true, reason }); return; }
2220
+ emit({ kind: 'model', line: MODEL_LINES[strategy], ...tag });
2221
+ // a blocked idea is not shown again as a patch
2222
+ const avoid = [...avoidTitles, ...blocked];
2223
+ const angleText = angle ? angle.text : null;
2224
+ const prompt = strategy === 'blocker'
2225
+ ? buildPrompt(bundle, { today, previousTitle, blocker, avoidTitles: avoid, reworkOf, ownerNote, angle: angleText, concern })
2226
+ : buildPrompt(bundle, { today, previousTitle, reason, previousAnswer, avoidTitles: avoid, reworkOf, ownerNote, angle: angleText, concern });
2227
+ let r = await ask(prompt, attempt, strategy, avoid);
2228
+ if (over) return;
2229
+ if (r.failed) { ends.push({ lane: laneNo, model_failed: true, reason: r.failed }); return; }
2230
+ save(r);
2231
+
2232
+ // only length or sentence limits broken: one free trim turn, same try
2233
+ if (!r.check.ok && r.check.formatOnly) {
2234
+ emit({ kind: 'model', line: MODEL_LINES.trim, ...tag, say: SWARM_SAY.shorten });
2235
+ const original = r.parsed.bet;
2236
+ const trimmed = await ask(buildTrimPrompt(JSON.stringify(original), r.check.reasons), attempt, 'trim', avoid);
2237
+ if (over) return;
2238
+ if (trimmed.failed) { ends.push({ lane: laneNo, model_failed: true, reason: trimmed.failed }); return; }
2239
+ if (!trimmed.parsed.error && trimChangedMore(original, trimmed.parsed.bet)) {
2240
+ trimmed.check = { ok: false, reasons: ['the trim changed more than the fields over a limit'], formatOnly: false };
2241
+ }
2242
+ save(trimmed);
2243
+ r = trimmed;
2244
+ }
1978
2245
 
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
- }
2246
+ previousAnswer = r.parsed.error ? null : JSON.stringify(r.parsed.bet);
2247
+ if (!r.check.ok) {
2248
+ strategy = 'patch';
2249
+ reason = r.check.reasons.slice(0, 4).join('; ');
2250
+ turnedDown += 1;
2251
+ const again = attempt < WRITER_ATTEMPTS && left() >= writeMin;
2252
+ emit({ kind: 'reject', line: `${ORDINALS[attempt - 1]} answer turned down: ${reason}`, ...tag, ...(again ? { say: SWARM_SAY.redraft } : {}) });
2253
+ continue;
2254
+ }
1986
2255
 
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';
2256
+ // the code checks passed; now a second model judges whether it makes sense
2257
+ const bet = cleanBet(r.parsed.bet, today, bundle);
2258
+ const draft = { bet, lane: laneNo, attempt, verdict: null, order: drafts.length };
2259
+ drafts.push(draft);
2260
+ if (left() < judgeMin) { draft.unjudged = 'no time left to check it'; ends.push({ lane: laneNo, out_of_time: true, reason }); return; }
2261
+ checking += 1;
2262
+ emit({ kind: 'skeptic', line: 'a second model is checking that it makes sense', ...tag, say: SWARM_SAY.checking(checking) });
2263
+ const judgedAt = clock();
2264
+ const said = await call(judge, buildSkepticPrompt(bet, bundle, { today }), { cwd, onProgress: () => {} });
2265
+ const skepticBase = { ...r.base, kind: 'skeptic', at: new Date(clock()).toISOString(), ms: clock() - judgedAt, title: bet.title };
2266
+ if (!said.ok) {
2267
+ saveAttempt(attemptsFile, { ...skepticBase, model_ok: false, ...(said.stopped ? { stopped: true } : {}), raw: null, parse: null, passed: false, reasons: [said.reason] });
2268
+ if (over) return;
2269
+ draft.unjudged = `the skeptic could not check the bet: ${said.reason}`;
2270
+ ends.push({ lane: laneNo, reason: draft.unjudged });
2271
+ return;
2272
+ }
2273
+ const judged = parseVerdict(said.text);
2274
+ const passed = !judged.error && skepticPassed(judged.verdict);
2275
+ saveAttempt(attemptsFile, {
2276
+ ...skepticBase,
2277
+ model_ok: true,
2278
+ raw: String(said.text).slice(0, 20000),
2279
+ parse: judged.error || 'ok',
2280
+ verdict: judged.verdict || null,
2281
+ passed,
2282
+ reasons: judged.error ? [judged.error] : (passed ? [] : [judged.verdict.reason]),
2283
+ });
2284
+ if (over) return;
2285
+ if (judged.error) {
2286
+ draft.unjudged = `the skeptic could not check the bet: ${judged.error}`;
2287
+ ends.push({ lane: laneNo, reason: draft.unjudged });
2288
+ return;
2289
+ }
2290
+ draft.verdict = judged.verdict;
2291
+ if (passed) {
2292
+ winner = draft;
2293
+ emit({ kind: 'check', line: 'checked the bet: every rule passed and the skeptic agreed', ...tag, say: SWARM_SAY.heldUp });
2294
+ stop('pass');
2295
+ return;
2296
+ }
2297
+ reason = skepticReason(judged.verdict);
2298
+ turnedDown += 1;
2299
+ const missing = blockerOf(judged.verdict);
2300
+ if (missing) {
2301
+ strategy = 'blocker';
2302
+ blocker = { title: bet.title, missing_first: missing };
2303
+ blocked.push(bet.title);
2304
+ } else {
2305
+ strategy = 'patch';
2306
+ }
2307
+ emit({ kind: 'reject', line: `${ORDINALS[attempt - 1]} answer turned down: ${reason}`, ...tag, say: SWARM_SAY.notHeld });
2021
2308
  }
2022
- emit({ kind: 'reject', line: `${ORDINALS[attempt - 1]} answer turned down: ${reason}` });
2309
+ ends.push({ lane: laneNo, reason });
2310
+ }
2311
+
2312
+ emit({ kind: 'swarm', line: `${plural(n, 'writer')} at once`, n, say: SWARM_SAY.drafting(n) });
2313
+ const lanes = Array.from({ length: n }, (_, i) => lane(i).catch((err) => { ends.push({ lane: i + 1, reason: String((err && err.message) || err) }); }));
2314
+ Promise.all(lanes).then(() => stop('done'));
2315
+ await ended;
2316
+ finish();
2317
+
2318
+ if (over === 'stopped') return { ok: false, stopped: true, reason: 'stopped before an idea was picked', bundle };
2319
+ if (winner) return { ok: true, bet: { ...winner.bet, skeptic: winner.verdict }, bundle, attempts: winner.attempt, lane: winner.lane, checked: true };
2320
+ // none passed: the best draft is still a card the owner can act on
2321
+ const best = [...drafts].sort((a, b) => draftRank(a) - draftRank(b) || a.order - b.order)[0];
2322
+ if (best) {
2323
+ const why = best.verdict ? skepticReason(best.verdict) : best.unjudged;
2324
+ emit({ kind: 'fallback', line: `no draft passed every check${over === 'budget' ? ' in time' : ''}; showing the best one: ${why}`, say: SWARM_SAY.outOfTime });
2325
+ return { ok: true, bet: { ...best.bet, skeptic: best.verdict || null, concern: ownerConcern(best) }, bundle, attempts: best.attempt, lane: best.lane, checked: false, reason: why };
2023
2326
  }
2024
- return { ok: false, reason: `the model's answer was turned down ${WRITER_ATTEMPTS} times (${reason})`, bundle };
2327
+ emit({ kind: 'none', line: 'no draft met the plain rules', say: SWARM_SAY.nothing });
2328
+ const failedAll = ends.length > 0 && ends.every(e => e.model_failed);
2329
+ const last = [...ends].reverse().find(e => e.reason);
2330
+ if (failedAll) return { ok: false, reason: ends[0].reason, bundle };
2331
+ 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 };
2332
+ if (over === 'budget') return { ok: false, reason: `nothing usable came back in ${Math.round(budgetMs / 1000)} seconds${last ? ` (${last.reason})` : ''}`, bundle };
2333
+ return { ok: false, reason: `the model's answer was turned down ${plural(turnedDown, 'time')} (${last ? last.reason : 'no answer met the rules'})`, bundle };
2025
2334
  }
2026
2335
 
2027
2336
  // ---- do it: the mission -------------------------------------------------------------------
@@ -2456,7 +2765,8 @@ function queuedProblem(bet, team) {
2456
2765
 
2457
2766
  // ideas kept ready ahead; one is written again the moment one is shown
2458
2767
  const QUEUE_TARGET = 3;
2459
- // one generation at a time; the cap of 3 calls in flight per session is never reached
2768
+ // one drafting at a time (each runs up to three model calls at once, the
2769
+ // swarm); the cap of 3 draftings in flight per session is never reached
2460
2770
  const MAX_IN_FLIGHT = 1;
2461
2771
  const SESSION_IN_FLIGHT_CAP = 3;
2462
2772
  const STALE_MS = 6 * 60 * 60 * 1000;
@@ -2562,7 +2872,7 @@ function createBetDesk(deps) {
2562
2872
  // queued bets live in the ledger, so a restart or a reload wastes nothing
2563
2873
  for (const row of readLedger(deps.ledgerFile, deps.workspaceRoot)) {
2564
2874
  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 });
2875
+ 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
2876
  }
2567
2877
 
2568
2878
  // the team, read once: a queued bet whose members are not on it is not shown
@@ -2573,12 +2883,52 @@ function createBetDesk(deps) {
2573
2883
  team = null;
2574
2884
  }
2575
2885
 
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.
2886
+ // What a prepared idea was written from on the owner's side: his answers
2887
+ // to the question cards and his own number. An idea written before either
2888
+ // changed would not know what he just said, so it is never shown.
2889
+ function ownerStateKey() {
2890
+ const focus = Object.values(readFocus(focusFile, deps.workspaceRoot)).map(a => [String(a.key), String(a.label)]).sort((a, b) => a[0].localeCompare(b[0]));
2891
+ const board = readScoreboard(scoreboardFile, deps.workspaceRoot);
2892
+ const last = board && board.reports.length ? board.reports[board.reports.length - 1].value : null;
2893
+ return crypto.createHash('sha256').update(JSON.stringify({ focus, board: board ? [board.name, board.up, board.source, last] : null })).digest('hex').slice(0, 12);
2894
+ }
2895
+
2896
+ // Titles the owner already saw or turned down: shown, passed, skipped, or started.
2897
+ const SEEN = new Set(['proposed', 'passed', 'skipped', 'doing']);
2898
+ function seenTitles() {
2899
+ 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 }));
2900
+ }
2901
+
2902
+ // The number an idea starts from, as it was when it was written, against
2903
+ // the newest facts read: when it moved, the idea's numbers are old.
2904
+ function startMoved(bet) {
2905
+ const t = bet && bet.target;
2906
+ const m = t && state.bundle ? bundleMetrics(state.bundle)[t.metric] : null;
2907
+ if (!m || !Number.isFinite(m.baseline) || !Number.isFinite(t.baseline) || m.baseline === t.baseline) return null;
2908
+ return `the number it starts from moved: ${commas(t.baseline)} then, ${commas(m.baseline)} now`;
2909
+ }
2910
+
2911
+ // Why a prepared idea can no longer be shown, or null while it is fresh:
2912
+ // older than 6 hours, made under rules that changed since, written before
2913
+ // the owner's newest answer or number, too close to one he already saw or
2914
+ // turned down, or its starting number moved.
2915
+ function staleWhy(item, seen) {
2916
+ if (now() - item.created_at > STALE_MS) return 'older than 6 hours';
2917
+ const rule = queuedProblem(item.bet, team);
2918
+ if (rule) return rule;
2919
+ if (item.state_key && item.state_key !== ownerStateKey()) return 'written before the owner\'s newest answer';
2920
+ // a rework of the idea the owner wrote a note on may stay close to it
2921
+ const close = seen.find(s => s.id !== item.id && s.title !== item.rework_of && nearDuplicate(item.bet.title, s.title));
2922
+ if (close) return `too close to one already shown: ${clip(close.title, 80)}`;
2923
+ return startMoved(item.bet);
2924
+ }
2925
+
2926
+ // Old bets go. Each is marked stale with why, and the queue refills behind them.
2578
2927
  function dropStale() {
2579
2928
  const keep = [];
2929
+ const seen = state.queue.length ? seenTitles() : [];
2580
2930
  for (const item of state.queue) {
2581
- const why = now() - item.created_at > STALE_MS ? 'older than 6 hours' : queuedProblem(item.bet, team);
2931
+ const why = staleWhy(item, seen);
2582
2932
  if (why) {
2583
2933
  record(item.id, { status: 'stale', stale_reason: why });
2584
2934
  log({ card: 'bet', choice: 'stale', command: null, ran: 'in process', exit_code: 0, bet_id: item.id, why });
@@ -2990,12 +3340,118 @@ function createBetDesk(deps) {
2990
3340
  // page brings the idea in once it is written
2991
3341
  const WAIT_CARD = { what: 'writing your next idea.' };
2992
3342
  function showWait() {
2993
- state.current = { kind: 'wait', id: `wait:${now()}` };
3343
+ // already the calm line: it stays, with its id and the notes typed on it
3344
+ if (state.status === 'ready' && state.current && state.current.kind === 'wait') return;
3345
+ state.current = { kind: 'wait', id: `wait:${now()}`, shown_at: now(), lines: [] };
2994
3346
  state.status = 'ready';
2995
3347
  state.mission = null;
2996
3348
  state.error = null;
2997
3349
  log({ card: 'bet', choice: 'show', command: null, ran: 'in process', exit_code: 0, kind: 'wait' });
2998
3350
  }
3351
+ const onWait = () => state.status === 'ready' && Boolean(state.current) && state.current.kind === 'wait';
3352
+
3353
+ // Words typed on the calm line are a note for the idea being written: no
3354
+ // read, no new card. The next drafting starts with them (one under way
3355
+ // that nobody asked for is stopped for it), and the calm line waits for
3356
+ // that idea; one written without them is kept for later. It waits only
3357
+ // while that idea is still being written, and at most NOTE_HOLD_MS, a
3358
+ // drafting's own budget. Past that the ready idea comes in on its own
3359
+ // with NOTE_FOLDING as its line, and the idea written with the note is
3360
+ // the next card after it (state.noteComing), never swapped in under him.
3361
+ const NOTE_HOLD_MS = SWARM_BUDGET_MS;
3362
+ const WAIT_NOTE_ACK = 'got it, i\'ll fold that in.';
3363
+ const NOTE_FOLDING = 'your note is still being folded in. that version comes next.';
3364
+ // the newest notes that fit in one note's room, oldest first
3365
+ function joinNotes(notes) {
3366
+ let out = '';
3367
+ for (let i = notes.length - 1; i >= 0; i -= 1) {
3368
+ const more = out ? `${notes[i]}; ${out}` : notes[i];
3369
+ if (more.length > SAY_MAX) break;
3370
+ out = more;
3371
+ }
3372
+ return out || notes[notes.length - 1].slice(0, SAY_MAX);
3373
+ }
3374
+ function noteOnWait(cur, said) {
3375
+ if (!cur.notes && state.pendingSharper && state.pendingSharper.ownerNote) cur.notes = [state.pendingSharper.ownerNote];
3376
+ cur.notes = [...(cur.notes || []), said].slice(-3);
3377
+ cur.note = joinNotes(cur.notes);
3378
+ cur.noteAt = now();
3379
+ state.pendingSharper = { ...(state.pendingSharper || {}), ownerNote: cur.note };
3380
+ log({ card: 'bet', choice: 'wait_note', command: null, ran: 'in process', exit_code: 0, kind: 'wait', chars: said.length });
3381
+ pump();
3382
+ return { ok: true, noted: true, ack: WAIT_NOTE_ACK };
3383
+ }
3384
+ // the idea written with the newest note: the calm line's own, or one still coming after an idea shown without it
3385
+ const notedAt = (cur) => {
3386
+ const note = cur && cur.kind === 'wait' && cur.note ? cur.note : state.noteComing;
3387
+ return note ? state.queue.findIndex(i => i.owner_note === note) : -1;
3388
+ };
3389
+ function waitHolds() {
3390
+ const cur = state.current;
3391
+ if (!onWait() || !cur.note || notedAt(cur) >= 0) return false;
3392
+ return (state.inFlight > 0 || Boolean(state.pendingSharper)) && now() - cur.noteAt < NOTE_HOLD_MS;
3393
+ }
3394
+ // a note already on its way: waiting to start, being written, or ready
3395
+ const noteUnderWay = note => Boolean((state.pendingSharper && state.pendingSharper.ownerNote === note)
3396
+ || (state.gen && !state.gen.stopping && state.gen.asks && state.gen.asks.ownerNote === note)
3397
+ || state.queue.some(i => i.owner_note === note));
3398
+ // the notes typed on the calm line, and the answer to them
3399
+ function waitThread(cur) {
3400
+ const thread = (cur.notes || []).map(text => ({ who: 'owner', text }));
3401
+ if (thread.length) thread.push({ who: 'orb', text: WAIT_NOTE_ACK });
3402
+ return thread;
3403
+ }
3404
+
3405
+ // How the waiting card shows it is alive: the plain lines of the drafting
3406
+ // it waits on (the one running when it showed, or the one that took over
3407
+ // when that was stopped), newest last, and when that drafting began.
3408
+ const WAIT_LINES = 6;
3409
+ function waitProgress(cur) {
3410
+ const was = cur.waitsOn;
3411
+ if (state.gen && (!was || (was.stopping && was !== state.gen))) {
3412
+ // the line that says why it started over stays on the card
3413
+ if (was && was.stopSay) cur.lines = [...(cur.lines || []), { at: was.stopped_at, line: was.stopSay }];
3414
+ cur.waitsOn = state.gen;
3415
+ }
3416
+ const g = cur.waitsOn || null;
3417
+ const said = g ? g.events.filter(e => e.say).map(e => ({ at: e.at, line: e.say })) : [];
3418
+ if (g && g.stopping && g.stopSay) said.push({ at: g.stopped_at, line: g.stopSay });
3419
+ const lines = [...(cur.lines || []), ...said].sort((a, b) => a.at - b.at).slice(-WAIT_LINES);
3420
+ const startedAt = g ? g.started_at : (cur.shown_at || now());
3421
+ return { progress: lines, started_at: startedAt, waited_ms: Math.max(0, now() - startedAt) };
3422
+ }
3423
+
3424
+ // The wait ended with no draft that met the plain rules (the model is down,
3425
+ // or every draft broke a rule): a card the owner can act on, never an error.
3426
+ const MISSED_CARD = { what: 'no idea came together this time.' };
3427
+ // It remembers the wait it ended (after, and the id that wait was served
3428
+ // under), so words the owner was typing there still count.
3429
+ function showMissed() {
3430
+ const was = state.current && state.current.kind === 'wait' ? state.current : null;
3431
+ const servedWait = was && state.served && state.served.kind === 'wait' ? state.served.id : null;
3432
+ state.current = { kind: 'missed', id: `missed:${now()}`, ...(was ? { after: was.id, afterServed: servedWait, ...(was.note ? { note: was.note, notes: was.notes } : {}) } : {}) };
3433
+ state.status = 'ready';
3434
+ state.mission = null;
3435
+ state.error = null;
3436
+ log({ card: 'bet', choice: 'show', command: null, ran: 'in process', exit_code: 0, kind: 'missed' });
3437
+ }
3438
+
3439
+ // The owner asked for something (a sharper idea, words on a card): a
3440
+ // background top-up still drafting is stopped, so his ask starts now
3441
+ // instead of after it.
3442
+ function makeRoomFor() {
3443
+ 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');
3444
+ }
3445
+
3446
+ // Which ready idea goes next: one the owner asked for first, then a
3447
+ // checked one; one a second look worried about waits behind them.
3448
+ function nextIndex() {
3449
+ if (!state.queue.length) return -1;
3450
+ if (state.queue[0].asked) return 0;
3451
+ const i = state.queue.findIndex(item => !item.bet.concern);
3452
+ return i >= 0 ? i : 0;
3453
+ }
3454
+ const checkedReady = () => state.queue.filter(item => !item.bet.concern);
2999
3455
 
3000
3456
  function showQuestion(item) {
3001
3457
  if (!LOCAL_QUESTIONS.has(item.question.key)) appendLedger(questionsFile, { id: item.question.key, workspace: deps.workspaceRoot, status: 'shown', shown_at: iso() });
@@ -3009,7 +3465,7 @@ function createBetDesk(deps) {
3009
3465
  // a re-tap on a job card moves on: a yes waits for a later session, a done is seen.
3010
3466
  // later: not now on a yes, which also sends it to the back of the job deck
3011
3467
  function passJob(item, { later = false } = {}) {
3012
- if (item.kind === 'wait') return;
3468
+ if (item.kind === 'wait' || item.kind === 'missed') return;
3013
3469
  if (item.kind === 'report') { state.passedQuestions.add(item.id); return; }
3014
3470
  if (item.kind === 'choice') {
3015
3471
  for (const i of item.items) record(i.id, { status: 'passed', note: 'not picked' });
@@ -3044,18 +3500,31 @@ function createBetDesk(deps) {
3044
3500
  state.retry = setTimer(() => { state.retry = null; pump(); }, BACKOFF_MS);
3045
3501
  }
3046
3502
 
3047
- function startGeneration({ sharperOf = null, reworkOf = null, ownerNote = null } = {}) {
3503
+ function startGeneration({ sharperOf = null, reworkOf = null, ownerNote = null, concern = null } = {}) {
3048
3504
  if (state.inFlight >= MAX_IN_FLIGHT || state.inFlight >= SESSION_IN_FLIGHT_CAP) return null;
3049
3505
  state.inFlight += 1;
3050
3506
  state.maxInFlight = Math.max(state.maxInFlight, state.inFlight);
3051
3507
  state.run += 1;
3052
- const gen = { run: state.run, events: [], sharperOf };
3508
+ // what the idea is written from on the owner's side, read before it starts
3509
+ const key = ownerStateKey();
3510
+ const stopper = new AbortController();
3511
+ const asks = { sharperOf, reworkOf, ownerNote, concern };
3512
+ const gen = { run: state.run, events: [], sharperOf, started_at: now(), key, asked: Boolean(sharperOf || ownerNote), asks, stopping: false };
3513
+ // stops every model call this drafting has running; its answer is dropped.
3514
+ // say: the plain line the waiting card shows for it
3515
+ gen.stop = (why, say = null) => {
3516
+ if (gen.stopping) return;
3517
+ gen.stopping = why || true;
3518
+ gen.stopSay = say;
3519
+ gen.stopped_at = now();
3520
+ stopper.abort();
3521
+ };
3053
3522
  state.gen = gen;
3054
3523
  const emit = (ev) => { gen.events.push({ i: gen.events.length, at: now(), ...ev }); };
3055
3524
  // started now, not on a later tick, so the in-flight count is exact
3056
3525
  let started;
3057
3526
  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; } }));
3527
+ 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
3528
  } catch (err) {
3060
3529
  started = Promise.reject(err);
3061
3530
  }
@@ -3065,18 +3534,39 @@ function createBetDesk(deps) {
3065
3534
  state.inFlight -= 1;
3066
3535
  if (state.gen === gen) state.gen = null;
3067
3536
  if (out.bundle) state.bundle = out.bundle;
3537
+ // stopped on purpose: not a failure, and an ask it carried is asked again
3538
+ if (gen.stopping || out.stopped) {
3539
+ log({ card: 'bet', choice: 'generate', command: null, ran: 'in process', exit_code: 0, stopped: clip(String(gen.stopping === true ? 'stopped' : gen.stopping || 'stopped'), 120) });
3540
+ if (gen.asked && !state.pendingSharper) state.pendingSharper = asks;
3541
+ return pump();
3542
+ }
3068
3543
  if (!out.ok) {
3069
- // a failure stays in the log; the owner never waits on it or sees it
3544
+ // a failure stays in the log; the owner never sees the reason
3070
3545
  log({ card: 'bet', choice: 'generate', command: null, ran: 'in process', exit_code: 1, error: clip(out.reason, 200) });
3071
3546
  emit({ kind: 'failed', line: out.reason });
3072
3547
  state.failures += 1;
3073
3548
  if (state.failures >= MAX_BACKGROUND_FAILURES) backOff();
3549
+ // the owner is waiting on the calm line: the wait ends in a card he can act on
3550
+ if (onWait()) showMissed();
3551
+ return pump();
3552
+ }
3553
+ 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 } : {}) };
3554
+ // stale the moment it lands (too close to one just shown): counted as
3555
+ // a failure, so a writer that keeps landing there rests instead of looping
3556
+ const staleNow = staleWhy(item, seenTitles());
3557
+ if (staleNow) {
3558
+ log({ card: 'bet', choice: 'generate', command: null, ran: 'in process', exit_code: 1, error: clip(`stale on arrival: ${staleNow}`, 200) });
3559
+ state.failures += 1;
3560
+ if (state.failures >= MAX_BACKGROUND_FAILURES) {
3561
+ backOff();
3562
+ // resting now, so nothing is drafting for a wait on screen
3563
+ if (onWait()) showMissed();
3564
+ }
3074
3565
  return pump();
3075
3566
  }
3076
3567
  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 });
3568
+ 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 } : {}) });
3569
+ 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
3570
  emit({ kind: 'done', line: 'bet ready' });
3081
3571
  // the sharper or reworked idea the owner asked for is the next shown
3082
3572
  if (sharperOf || ownerNote) state.queue.unshift(item);
@@ -3095,6 +3585,10 @@ function createBetDesk(deps) {
3095
3585
  pumpQuestions();
3096
3586
  pumpSuggestions();
3097
3587
  dropStale();
3588
+ // the owner answered or picked a number since this drafting began: what
3589
+ // it writes would not know that, so it stops and a fresh one starts
3590
+ if (state.gen && !state.gen.stopping && state.gen.key !== ownerStateKey()) state.gen.stop('the owner answered something new', 'starting over with your newest answer');
3591
+ if (state.pendingSharper) makeRoomFor();
3098
3592
  if (state.inFlight >= MAX_IN_FLIGHT) return state.gen && state.gen.done;
3099
3593
  if (state.pendingSharper) {
3100
3594
  const asked = state.pendingSharper;
@@ -3125,6 +3619,10 @@ function createBetDesk(deps) {
3125
3619
  function next({ sharper = false, later = false } = {}) {
3126
3620
  if (state.status === 'starting') return { ok: true, busy: true, ...view() };
3127
3621
  const onScreen = state.status === 'ready' && state.current ? state.current : null;
3622
+ // try again after a wait that ended with a note on it: the note goes into the next
3623
+ // drafting, or the calm line joins the one already writing it, never a second
3624
+ const carried = onScreen && onScreen.kind === 'missed' && onScreen.note ? onScreen : null;
3625
+ if (carried && !state.pendingSharper && !noteUnderWay(carried.note)) state.pendingSharper = { ownerNote: carried.note };
3128
3626
  // passed, skipped, or answered: the card after a yes is never another yes
3129
3627
  const afterYes = Boolean(state.current && state.current.kind === 'yes');
3130
3628
  if (onScreen && onScreen.kind && onScreen.kind !== 'bet') {
@@ -3132,7 +3630,9 @@ function createBetDesk(deps) {
3132
3630
  } else if (onScreen) {
3133
3631
  record(onScreen.id, { status: 'passed', ...(sharper ? { note: 'asked for a sharper idea' } : {}) });
3134
3632
  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 };
3633
+ // sharpening one a second look worried about: the new draft must answer the worry
3634
+ const worry = onScreen.bet.concern && onScreen.bet.skeptic && onScreen.bet.skeptic.reason;
3635
+ if (sharper) state.pendingSharper = { sharperOf: onScreen.bet.title, ...(worry ? { concern: worry } : {}) };
3136
3636
  } else {
3137
3637
  log({ card: 'bet', choice: 'think', command: null, ran: 'in process', exit_code: 0 });
3138
3638
  }
@@ -3140,15 +3640,28 @@ function createBetDesk(deps) {
3140
3640
  if (now() - state.jobs.at > 5000) pollJobs();
3141
3641
  const job = nextJobCard({ afterYes });
3142
3642
  const ask = job ? null : askCard();
3643
+ const checked = checkedReady();
3644
+ const noted = notedAt(onScreen);
3645
+ // the calm line's note is not in yet and its wait is over: the ready idea comes on its own, saying so
3646
+ const folding = noted < 0 && onScreen && onScreen.kind === 'wait' && onScreen.note ? onScreen.note : null;
3143
3647
  if (job) showJob(job);
3144
3648
  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());
3649
+ // the idea written with the note comes in on its own
3650
+ else if (noted >= 0) { show(state.queue.splice(noted, 1)[0]); state.noteComing = null; }
3651
+ else if (folding && state.queue.length) { show(state.queue.splice(nextIndex(), 1)[0]); state.current.folding = true; state.noteComing = folding; }
3652
+ // two or more checked ideas ready: one card to pick from; one a second look worried about is never in it
3653
+ else if (deps.choices !== false && checked.length >= 2) {
3654
+ const items = checked.slice(0, CHOICE_MAX);
3655
+ state.queue = state.queue.filter(item => !items.includes(item));
3656
+ showChoice(items);
3657
+ } else if (state.queue.length) show(state.queue.splice(nextIndex(), 1)[0]);
3147
3658
  else {
3148
3659
  const question = nextQuestion();
3149
3660
  if (question) showQuestion(question);
3150
3661
  else {
3151
3662
  showWait();
3663
+ // after try again the calm line carries the note: it shows it and waits for the idea written with it
3664
+ if (carried && state.current.kind === 'wait' && !state.current.note) Object.assign(state.current, { notes: carried.notes || [carried.note], note: carried.note, noteAt: now() });
3152
3665
  // the card says an idea is being written, so one is, even mid backoff
3153
3666
  if (!state.gen) { state.failures = 0; state.backoffUntil = 0; }
3154
3667
  }
@@ -3447,7 +3960,7 @@ function createBetDesk(deps) {
3447
3960
  id: cur ? cur.id : null,
3448
3961
  kind,
3449
3962
  bet: kind === 'bet' ? cur.bet : null,
3450
- job: kind === 'yes' || kind === 'done' ? publicJob(cur) : (kind === 'wait' ? { ...WAIT_CARD } : null),
3963
+ job: kind === 'yes' || kind === 'done' ? publicJob(cur) : (kind === 'wait' ? { ...WAIT_CARD } : (kind === 'missed' ? { ...MISSED_CARD } : null)),
3451
3964
  question: kind === 'question' ? { ask: cur.question.ask, line: cur.question.line, choices: cur.question.choices } : null,
3452
3965
  report: kind === 'report' ? {
3453
3966
  what: `${cur.board.name}: what is it this week?`,
@@ -3501,6 +4014,7 @@ function createBetDesk(deps) {
3501
4014
  // the card as the reader sees it
3502
4015
  function cardForReader(v) {
3503
4016
  if (v.kind === 'question') return { kind: 'question', title: v.question.ask, detail: v.question.line, buttons: v.question.choices.map(c => c.label) };
4017
+ 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
4018
  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
4019
  if (v.kind === 'choice') return { kind: 'choice', title: 'which one next?', detail: v.choice.options.map(o => o.title).join(' / '), buttons: [] };
3506
4020
  if (v.kind === 'report') return { kind: 'number', title: v.report.what, detail: v.report.why, buttons: ['save', 'not now'] };
@@ -3537,18 +4051,24 @@ function createBetDesk(deps) {
3537
4051
  const said = String(text || '').trim();
3538
4052
  if (!said) return { ok: false, reason: 'empty', message: 'say something first' };
3539
4053
  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;
4054
+ let cur = state.status === 'ready' ? state.current : null;
3541
4055
  if (!cur) return { ok: false, reason: 'gone', message: 'there is no card on screen' };
3542
4056
  // words said on a card the page no longer shows act on nothing
3543
4057
  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';
4058
+ // on the calm line there is nothing to answer: the words are a note for the idea being written, read by no model
4059
+ if (cur.kind === 'wait') return noteOnWait(cur, said);
4060
+ let kind = cur.kind || 'bet';
3545
4061
  log({ card: 'bet', choice: 'say', command: null, ran: 'in process', exit_code: 0, kind, chars: said.length });
3546
4062
  const t = threadFor(cur.id);
3547
4063
  let read = { kind: 'instruction', reply: '' };
3548
4064
  // a second tap or a note acts on what was already read
3549
4065
  if (!confirm && !note) {
3550
4066
  const turn = await readTurn(t, said, cardForReader(view()));
3551
- // the card moved on while it was read (a tap, do it): drop the reply
4067
+ // the card moved on while it was read (a tap, do it): drop the reply.
4068
+ // Not when a wait ended on its own (nothing came back): his words are
4069
+ // said on the try-again card that took its place, never dropped
4070
+ const replaced = kind === 'wait' && state.status === 'ready' && state.current && state.current.kind === 'missed' && state.current.after === cur.id;
4071
+ if (replaced) { cur = state.current; kind = 'missed'; }
3552
4072
  if (state.current !== cur || state.status !== 'ready') return { ok: false, reason: 'gone', message: 'that card changed, so nothing was done' };
3553
4073
  if (turn.stay) return turn.stay;
3554
4074
  read = turn.act;
@@ -3981,11 +4501,13 @@ function createBetDesk(deps) {
3981
4501
  }
3982
4502
  if (state.status !== 'ready') return null;
3983
4503
  const v = view();
3984
- if (kind === 'bet') return gmCard.idea({ id: cur.id, title: cur.bet.title, body: betBody(cur.bet) });
4504
+ // an idea no draft beat in time carries the second look's worry, in one plain line
4505
+ 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
4506
  if (kind === 'question') return gmCard.question({ id: cur.id, title: v.question.ask, body: v.question.line, choices: v.question.choices });
3986
4507
  if (kind === 'report') return gmCard.report({ id: cur.id, title: v.report.what, body: v.report.why });
3987
4508
  if (kind === 'choice') return gmCard.choice({ id: cur.id, items: v.choice.options });
3988
- if (kind === 'wait') return gmCard.wait({ id: cur.id });
4509
+ if (kind === 'wait') return gmCard.wait({ id: cur.id, ...waitProgress(cur), thread: waitThread(cur) });
4510
+ if (kind === 'missed') return gmCard.missed({ id: cur.id });
3989
4511
  if (kind === 'yes') {
3990
4512
  const j = cur.job;
3991
4513
  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 +4580,8 @@ function createBetDesk(deps) {
4058
4580
  ...extra,
4059
4581
  card: gmCard.wire(card),
4060
4582
  status: v.status,
4061
- queue: v.queue,
4583
+ // ideas ready behind the card: none yet while the calm line waits for the one with its note
4584
+ queue: waitHolds() ? 0 : v.queue,
4062
4585
  team_working: v.team_working,
4063
4586
  needs_you: v.needs_you,
4064
4587
  };
@@ -4092,7 +4615,12 @@ function createBetDesk(deps) {
4092
4615
  async function sayOnMain(text, more = {}) {
4093
4616
  const under = underCard();
4094
4617
  const out = await say({ text, ...more });
4095
- const back = replied(out, under.id, text);
4618
+ // a note on the calm line: the same card stays, now with the note on it
4619
+ if (out.noted) return done200({ ack: out.ack });
4620
+ // a wait that ended on its own while the words were read: the reply sits on the card that took its place
4621
+ const stays = out.stay || out.reason === 'needs_confirm';
4622
+ const base = stays && under && under.kind === 'wait' && state.current && state.current.kind === 'missed' ? (underCard() || under) : under;
4623
+ const back = replied(out, base.id, text);
4096
4624
  if (back) return back;
4097
4625
  if (!out.ok) return refuse(out.reason || 'gone', out.message || 'that did not send. try again');
4098
4626
  state.reply = null;
@@ -4125,11 +4653,25 @@ function createBetDesk(deps) {
4125
4653
  // from the footer. confirm: the second tap on an option that asks for one.
4126
4654
  async function act(body = {}) {
4127
4655
  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));
4656
+ let shown = state.served;
4657
+ // the calm line's words change while the idea is written, under the same
4658
+ // card: an id it was served under before still names it
4659
+ const base = id => String(id).split('#')[0];
4660
+ const same = Boolean(shown && cardId && (cardId === shown.id || (shown.kind === 'wait' && base(cardId) === base(shown.id))));
4661
+ let stale = Boolean(cardId && !same);
4662
+ // words typed on a wait that ended on its own (nothing came back) still
4663
+ // count: they are said on the try-again card that took its place
4664
+ const cur = state.status === 'ready' ? state.current : null;
4665
+ 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) {
4666
+ state.reply = null;
4667
+ shown = serve();
4668
+ stale = false;
4669
+ }
4130
4670
  // the big orb: the next card; a page that names an older card gets the one on screen, and nothing is passed
4131
4671
  if (body.orb === true) {
4132
4672
  if (stale && shown) return done200();
4673
+ // the calm line waits for the idea written with its note
4674
+ if (shown && shown.kind === 'wait' && waitHolds()) return done200();
4133
4675
  state.layer = null;
4134
4676
  state.reply = null;
4135
4677
  const out = next({ sharper: false });
@@ -4311,5 +4853,8 @@ module.exports = {
4311
4853
  stopWriters,
4312
4854
  scoreBets,
4313
4855
  skepticCommand,
4856
+ buildSkepticPrompt,
4857
+ writerCommand,
4858
+ WRITER_ANGLES,
4314
4859
  validateBet,
4315
4860
  };