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 +706 -161
- package/lib/gm-card.js +45 -4
- package/lib/gm-phone-page.js +450 -59
- package/lib/zen.js +15 -2
- package/package.json +1 -1
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}`));
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1439
|
-
timedOut = true;
|
|
1519
|
+
const killGroup = (sig) => {
|
|
1440
1520
|
try {
|
|
1441
|
-
kill(-child.pid,
|
|
1521
|
+
kill(-child.pid, sig);
|
|
1442
1522
|
} catch {
|
|
1443
|
-
try { child.kill(
|
|
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
|
-
|
|
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
|
-
//
|
|
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": "...", "
|
|
1926
|
+
'{"verdict": "pass", "doable": true, "moves_metric": true, "missing_first": "...", "reason": "...", "concern": "..."}',
|
|
1820
1927
|
'',
|
|
1821
|
-
'
|
|
1822
|
-
'-
|
|
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"
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 = [];
|
|
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
|
-
//
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1976
|
-
|
|
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
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
2577
|
-
//
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
3078
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3146
|
-
else if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4129
|
-
|
|
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
|
};
|