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/commands/gm.js +41 -14
- package/commands/member.js +41 -7
- package/lib/gm-bet.js +768 -188
- package/lib/gm-card.js +45 -4
- package/lib/gm-phone-page.js +508 -74
- package/lib/text-table.js +138 -12
- package/lib/zen-apply.js +1 -1
- package/lib/zen.js +15 -2
- package/package.json +1 -1
- package/scripts/det/ytnotes +9 -3
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
|
|
7
|
-
// checks
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1439
|
-
timedOut = true;
|
|
1578
|
+
const killGroup = (sig) => {
|
|
1440
1579
|
try {
|
|
1441
|
-
kill(-child.pid,
|
|
1580
|
+
kill(-child.pid, sig);
|
|
1442
1581
|
} catch {
|
|
1443
|
-
try { child.kill(
|
|
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
|
-
|
|
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
|
-
//
|
|
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": "...", "
|
|
1985
|
+
'{"verdict": "pass", "doable": true, "moves_metric": true, "missing_first": "...", "reason": "...", "concern": "..."}',
|
|
1820
1986
|
'',
|
|
1821
|
-
'
|
|
1822
|
-
'-
|
|
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"
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1919
|
-
|
|
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
|
-
//
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
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
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
//
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
let
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
//
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
const
|
|
1970
|
-
const
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
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
|
-
|
|
1976
|
-
|
|
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
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
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
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
passed
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
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
|
-
|
|
2368
|
+
ends.push({ lane: laneNo, reason });
|
|
2023
2369
|
}
|
|
2024
|
-
|
|
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
|
|
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
|
-
//
|
|
2577
|
-
//
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
3078
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3146
|
-
else if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4129
|
-
|
|
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
|
};
|