atris 3.71.3 → 3.72.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/gm-bet.js ADDED
@@ -0,0 +1,2219 @@
1
+ 'use strict';
2
+
3
+ // gm bet: one new, specific business move nobody wrote down yet, with a
4
+ // prediction, a check date, and exact steps. The owner taps "think" on the
5
+ // phone page; this module reads real local sources (each read is a progress
6
+ // event with one real fact), asks a smart model for one bet as strict JSON,
7
+ // checks it with plain rules, and keeps every bet in a ledger. "do it" turns
8
+ // the bet into a real atris mission.
9
+ //
10
+ // Everything that touches the outside world is passed in (the model command,
11
+ // the mission runner, the clock, the file paths), so tests stub it.
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const crypto = require('crypto');
16
+
17
+ const MODEL = 'opus';
18
+ // the skeptic is a separate call with its own prompt; it never sees the
19
+ // writer's reasoning, only the bet and the same facts
20
+ const SKEPTIC_MODEL = 'opus';
21
+ const MODEL_TIMEOUT_MS = 120000;
22
+ // the number is read over the 7 days that end on the check date, so the
23
+ // check is at least a week out and every day read is after the start
24
+ const MIN_CHECK_DAYS = 7;
25
+ const CHECK_WINDOW_DAYS = 14;
26
+ // an installs goal must beat the best week of this many past weeks
27
+ const BEST_WEEKS = 8;
28
+ // a bet whose number cannot be read yet (npm lags a few days) is tried again
29
+ // for this many days past its check date, then marked unreadable
30
+ const SCORE_GRACE_DAYS = 7;
31
+ // the task events that count as a job landing, the same ones the season board counts
32
+ const LANDED_ACTIONS = new Set(['accepted', 'merged']);
33
+ // the writer gets this many tries in all; each retry carries the reasons
34
+ const WRITER_ATTEMPTS = 3;
35
+ const ORDINALS = ['first', 'second', 'third', 'fourth', 'fifth'];
36
+ const BRIEF_COUNT = 3;
37
+ const BRIEF_LINES = 40;
38
+ const NOW_LINES = 60;
39
+ const OUTPUT_MAX = 2 * 1024 * 1024;
40
+ const MIN_STEPS = 2;
41
+ const MAX_STEPS = 5;
42
+ // the card has to fit one phone screen with do it visible
43
+ const WHY_MAX = 160;
44
+ const EXPECT_MAX = 160;
45
+ const STEP_MAX = 70;
46
+ const DETAIL_MAX = 240;
47
+
48
+ // A title whose whole action is one of these says nothing.
49
+ const VAGUE_VERBS = ['ship', 'improve', 'optimize', 'optimise', 'leverage', 'enhance', 'boost', 'streamline', 'explore', 'consider', 'grow', 'scale', 'focus', 'drive', 'unlock', 'maximize', 'empower', 'revamp', 'iterate', 'double'];
50
+ // Words that sound like a deliverable but are not one.
51
+ const VAGUE_NOUNS = new Set(['it', 'things', 'stuff', 'everything', 'growth', 'engagement', 'performance', 'experience', 'value', 'synergy', 'impact', 'momentum', 'better', 'more', 'improvements', 'efficiency', 'quality', 'results', 'business', 'product', 'strategy']);
52
+ const STOP_WORDS = new Set(['the', 'a', 'an', 'and', 'or', 'to', 'for', 'of', 'in', 'on', 'with', 'by', 'from', 'that', 'this', 'our', 'your', 'its', 'into', 'at', 'as', 'so', 'it', 'is', 'are', 'be', 'we', 'up', 'out', 'new', 'one', 'all', 'can', 'will']);
53
+ // A step that does any of these reaches the outside world or cannot be taken
54
+ // back, so only the owner can say yes to it.
55
+ // The action of a step is its first word and any verb joined to it with
56
+ // "and" or "then": "draft the launch post" is team work, "draft and post the
57
+ // thread" is not.
58
+ const OWNER_ONLY_WORDS = new Set(['publish', 'post', 'release', 'send', 'email', 'tweet', 'announce', 'spend', 'pay', 'buy', 'purchase', 'charge', 'deploy', 'delete', 'launch', 'submit', 'invoice', 'refund', 'dm', 'merge']);
59
+ // Words that name a source we can actually read the result from.
60
+ const METRIC_SOURCES = ['npm', 'install', 'download', 'git', 'master', 'landed', 'commit', 'task', 'review', 'queue', 'waiting', 'target', 'season', 'gm', 'ledger', 'bet', 'now.md', 'brief', 'mission'];
61
+ // The numbers a bet's target can name: each one we can read again on the
62
+ // check date, from the same place, the same way. Installs should go up past
63
+ // our best recent week; at least half of the jobs waiting on review today
64
+ // should land. Changes merged and the size of the queue are left out on
65
+ // purpose: filler jobs inflate one, and closing jobs empties the other.
66
+ const METRICS = {
67
+ installs: { what: 'npm installs in the 7 days that end on the check date', now: 'installs last week', up: true },
68
+ queue_landed: { what: 'jobs waiting on review today with real work behind them (a commit on a branch or on master that touches their own check) that land, accepted or merged, by the check date; placeholders do not count, and closing, dropping, or parking one counts zero', now: 'jobs landed from today\'s review queue', up: true },
69
+ };
70
+
71
+ // ---- small helpers -------------------------------------------------------------
72
+
73
+ function plural(n, one, many = `${one}s`) {
74
+ return `${n} ${n === 1 ? one : many}`;
75
+ }
76
+
77
+ function commas(n) {
78
+ return Number(n).toLocaleString('en-US');
79
+ }
80
+
81
+ function dayKey(at) {
82
+ const d = new Date(at);
83
+ const pad = v => String(v).padStart(2, '0');
84
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
85
+ }
86
+
87
+ function clip(text, max) {
88
+ const s = String(text || '').replace(/\s+/g, ' ').trim();
89
+ return s.length > max ? `${s.slice(0, max - 1)}…` : s;
90
+ }
91
+
92
+ function defaultStateDir() {
93
+ return path.join(require('./state-home').atrisHome(), 'state');
94
+ }
95
+
96
+ // ---- the ledger ------------------------------------------------------------------
97
+
98
+ function ledgerPath(stateDir = defaultStateDir()) {
99
+ return path.join(stateDir, 'gm-bets.jsonl');
100
+ }
101
+
102
+ // Every row is one change to one bet; the newest row for an id wins.
103
+ function appendLedger(file, row) {
104
+ fs.mkdirSync(path.dirname(file), { recursive: true });
105
+ fs.appendFileSync(file, `${JSON.stringify(row)}\n`);
106
+ }
107
+
108
+ function readLedger(file, workspaceRoot) {
109
+ let text = '';
110
+ try {
111
+ text = fs.readFileSync(file, 'utf8');
112
+ } catch {
113
+ return [];
114
+ }
115
+ const byId = new Map();
116
+ for (const line of text.split('\n')) {
117
+ if (!line.trim()) continue;
118
+ let row;
119
+ try {
120
+ row = JSON.parse(line);
121
+ } catch {
122
+ continue;
123
+ }
124
+ if (!row || !row.id || row.workspace !== workspaceRoot) continue;
125
+ byId.set(row.id, { ...(byId.get(row.id) || {}), ...row });
126
+ }
127
+ return [...byId.values()].sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)));
128
+ }
129
+
130
+ function ledgerLine(bets) {
131
+ if (!bets.length) return 'past bets: none yet';
132
+ const count = s => bets.filter(b => b.status === s).length;
133
+ const open = count('proposed') + count('doing');
134
+ return `past bets: ${bets.length} (${count('won')} won, ${count('lost')} lost, ${open} open, ${count('skipped')} skipped)`;
135
+ }
136
+
137
+ // ---- reading the sources -----------------------------------------------------------
138
+
139
+ function readNow(workspaceRoot) {
140
+ const file = path.join(workspaceRoot, 'atris', 'now.md');
141
+ let text;
142
+ try {
143
+ text = fs.readFileSync(file, 'utf8');
144
+ } catch {
145
+ return null;
146
+ }
147
+ const lines = text.split(/\r?\n/).slice(0, NOW_LINES);
148
+ const updated = lines.map(l => /^last updated:\s*(.+)$/i.exec(l.trim())).find(Boolean);
149
+ const fact = updated ? `read now.md, last updated ${clip(updated[1], 40)}` : `read now.md, ${plural(lines.filter(l => l.trim()).length, 'line')}`;
150
+ return { fact, text: lines.join('\n') };
151
+ }
152
+
153
+ // The weekly target and the season record, read the same way the board reads them.
154
+ function defaultSeasonFacts(workspaceRoot) {
155
+ const season = require('./gm-season');
156
+ const loaded = season.loadSeason(workspaceRoot, { lastSeen: null });
157
+ const board = season.seasonBoard(loaded, { limit: 8, lastSeen: null });
158
+ if (board.error) return null;
159
+ const mood = board.mood || {};
160
+ return {
161
+ landed: (mood.landed_by_autoland || 0) + (mood.landed_by_you || 0),
162
+ target: board.target ? { landed: board.target.landed, goal: board.target.target.landed, verdict: board.target.verdict } : null,
163
+ record: board.record ? { won: board.record.won, lost: board.record.lost } : null,
164
+ waiting_on_review: mood.waiting_on_review || 0,
165
+ // the jobs behind that count, so a bet can be scored on these exact ones
166
+ waiting_jobs: (loaded.reviews || []).map(r => ({ id: r.task_id, title: r.title })),
167
+ stuck: mood.stuck || 0,
168
+ };
169
+ }
170
+
171
+ function seasonFact(s) {
172
+ const head = s.target
173
+ ? `this week: ${s.target.landed} of ${s.target.goal} landed, ${s.target.verdict}`
174
+ : `this week: ${plural(s.landed, 'job')} landed, no target set`;
175
+ const record = s.record ? `, season ${s.record.won} won ${s.record.lost} lost` : '';
176
+ return `${head}${record}, ${s.waiting_on_review} waiting on review, ${s.stuck} stuck`;
177
+ }
178
+
179
+ // One row per first-parent commit on origin/master in the last 7 days: a
180
+ // merged pull request or a direct commit, each counted once. A merge's subject
181
+ // is "Merge pull request #n from branch", so its title is the body's first line.
182
+ function landedRows(stdout) {
183
+ return String(stdout || '').split('\x1e').map((rec) => {
184
+ const [subject = '', body = ''] = rec.replace(/^\s+/, '').split('\x1f');
185
+ if (!subject.trim()) return null;
186
+ const title = /^Merge pull request #\d+/.test(subject) ? (body.split('\n').map(l => l.trim()).find(Boolean) || subject) : subject;
187
+ return title.trim();
188
+ }).filter(Boolean);
189
+ }
190
+
191
+ function gitOut(workspaceRoot, args) {
192
+ const { spawnSync } = require('child_process');
193
+ const res = spawnSync('git', args, { cwd: workspaceRoot, encoding: 'utf8', timeout: 10000, maxBuffer: 8 * 1024 * 1024 });
194
+ return res.status === 0 ? res.stdout : null;
195
+ }
196
+
197
+ function mainRef(workspaceRoot) {
198
+ return ['origin/master', 'origin/main'].find(ref => gitOut(workspaceRoot, ['rev-parse', '-q', '--verify', `${ref}^{commit}`]) !== null) || null;
199
+ }
200
+
201
+ function defaultGitLanded(workspaceRoot) {
202
+ const ref = mainRef(workspaceRoot);
203
+ if (!ref) return null;
204
+ const out = gitOut(workspaceRoot, ['log', ref, '--first-parent', '--since=7 days ago', '--format=%s%x1f%b%x1e']);
205
+ return out === null ? null : landedRows(out);
206
+ }
207
+
208
+ // ---- the best recent week: the bar an installs bet has to clear -----------------
209
+
210
+ // The biggest weekly total among the newest BEST_WEEKS. totals are newest first.
211
+ function bestWeek(totals) {
212
+ const t = (totals || []).filter(n => Number.isFinite(n)).slice(0, BEST_WEEKS);
213
+ if (!t.length) return null;
214
+ return { best: Math.max(...t), weeks: t.length };
215
+ }
216
+
217
+ // npm days, oldest first, cut into full 7-day blocks from the newest day back.
218
+ // Days before the package had any installs are not weeks it existed.
219
+ function installTotals(body, weeks = BEST_WEEKS) {
220
+ const days = (body && Array.isArray(body.downloads) ? body.downloads : [])
221
+ .filter(d => d && typeof d.downloads === 'number' && d.day)
222
+ .sort((a, b) => String(a.day).localeCompare(String(b.day)));
223
+ const first = days.findIndex(d => d.downloads > 0);
224
+ const live = first < 0 ? [] : days.slice(first);
225
+ const totals = [];
226
+ for (let end = live.length; end - 7 >= 0 && totals.length < weeks; end -= 7) {
227
+ totals.push(live.slice(end - 7, end).reduce((n, d) => n + d.downloads, 0));
228
+ }
229
+ return totals;
230
+ }
231
+
232
+ function bestSpan(weeks) {
233
+ return weeks === 8 ? '2 months' : plural(weeks, 'week');
234
+ }
235
+
236
+ // ---- the release gap: what is merged but not in the published package -----------
237
+
238
+ // How many first-parent commits on master are not in the v<version> tag.
239
+ // No tag here means the number is unknown, never a guess.
240
+ function defaultReleaseGap(workspaceRoot, version) {
241
+ const tag = `v${version}`;
242
+ if (gitOut(workspaceRoot, ['rev-parse', '-q', '--verify', `refs/tags/${tag}`]) === null) return { count: null, why: `no ${tag} tag here` };
243
+ const ref = mainRef(workspaceRoot);
244
+ if (!ref) return { count: null, why: 'could not read master' };
245
+ const out = gitOut(workspaceRoot, ['rev-list', '--first-parent', '--count', `${tag}..${ref}`]);
246
+ const count = out === null ? NaN : Number(out.trim());
247
+ return Number.isInteger(count) ? { count } : { count: null, why: `could not compare ${tag} with master` };
248
+ }
249
+
250
+ function releaseFact(version, gap) {
251
+ if (gap && Number.isInteger(gap.count)) {
252
+ return gap.count === 0 ? `published ${version}; everything on master is published` : `published ${version}; ${commas(gap.count)} ${gap.count === 1 ? 'change' : 'changes'} on master not published yet`;
253
+ }
254
+ return `published ${version}; changes on master not published yet: unknown${gap && gap.why ? ` (${gap.why})` : ''}`;
255
+ }
256
+
257
+ function npmName(pkg) {
258
+ return encodeURIComponent(pkg).replace('%40', '@');
259
+ }
260
+
261
+ function defaultFetchJson(url, { timeoutMs = 10000 } = {}) {
262
+ const https = require('https');
263
+ return new Promise((resolve, reject) => {
264
+ const req = https.get(url, { headers: { 'user-agent': 'atris-gm-bet' } }, (res) => {
265
+ if (res.statusCode !== 200) {
266
+ res.resume();
267
+ reject(new Error(`status ${res.statusCode}`));
268
+ return;
269
+ }
270
+ let body = '';
271
+ res.setEncoding('utf8');
272
+ res.on('data', (c) => { body += c; });
273
+ res.on('end', () => {
274
+ try {
275
+ resolve(JSON.parse(body));
276
+ } catch (err) {
277
+ reject(err);
278
+ }
279
+ });
280
+ });
281
+ req.setTimeout(timeoutMs, () => req.destroy(new Error('timeout')));
282
+ req.on('error', reject);
283
+ });
284
+ }
285
+
286
+ function packageName(workspaceRoot) {
287
+ try {
288
+ const pkg = JSON.parse(fs.readFileSync(path.join(workspaceRoot, 'package.json'), 'utf8'));
289
+ return pkg && typeof pkg.name === 'string' && pkg.name && !pkg.private ? pkg.name : null;
290
+ } catch {
291
+ return null;
292
+ }
293
+ }
294
+
295
+ // npm's range endpoint gives one row per day. Last week is the newest 7 days
296
+ // with data, the week before is the 7 before that.
297
+ function installWeeks(body) {
298
+ const days = (body && Array.isArray(body.downloads) ? body.downloads : [])
299
+ .filter(d => d && typeof d.downloads === 'number' && d.day)
300
+ .sort((a, b) => String(a.day).localeCompare(String(b.day)));
301
+ if (days.length < 7) return null;
302
+ const sum = rows => rows.reduce((n, d) => n + d.downloads, 0);
303
+ const last = sum(days.slice(-7));
304
+ const before = days.length >= 14 ? sum(days.slice(-14, -7)) : null;
305
+ return { last_week: last, week_before: before, through: days[days.length - 1].day };
306
+ }
307
+
308
+ // The real team: every atris/team/<name>/MEMBER.md, with its name and a
309
+ // one-line role from the frontmatter (role, then description).
310
+ function readTeam(workspaceRoot) {
311
+ const dir = path.join(workspaceRoot, 'atris', 'team');
312
+ let names;
313
+ try {
314
+ names = fs.readdirSync(dir).filter(n => !n.startsWith('_') && !n.startsWith('.'));
315
+ } catch {
316
+ return null;
317
+ }
318
+ const team = [];
319
+ for (const name of names.sort()) {
320
+ let text;
321
+ try {
322
+ text = fs.readFileSync(path.join(dir, name, 'MEMBER.md'), 'utf8');
323
+ } catch {
324
+ continue;
325
+ }
326
+ const head = (/^---\r?\n([\s\S]*?)\r?\n---/.exec(text) || [])[1] || '';
327
+ const field = k => ((new RegExp(`^${k}:\\s*(.+)$`, 'm').exec(head) || [])[1] || '').trim().replace(/^["']|["']$/g, '');
328
+ const role = field('role');
329
+ const description = field('description');
330
+ const line = [role, description].filter(Boolean).join('. ');
331
+ team.push({ name, role: clip(line || 'team member', 110) });
332
+ }
333
+ return team.length ? team : null;
334
+ }
335
+
336
+ // A verify command that always passes proves nothing. Binary checks only:
337
+ // every piece of it is one of these, or it swallows failure with || true.
338
+ const TRIVIAL_VERIFY = [
339
+ /^true$/, /^:$/, /^exit 0$/, /^echo\b/, /^printf\b/,
340
+ /^git diff --check$/,
341
+ /^test \d+ -(?:eq|ne|gt|ge|lt|le) \d+$/, /^\[ \d+ -(?:eq|ne|gt|ge|lt|le) \d+ \]$/,
342
+ ];
343
+
344
+ function verifyProblem(command) {
345
+ const text = String(command || '').trim().replace(/\s+/g, ' ');
346
+ if (!text) return 'verify is missing; give one command that proves the deliverable (a test file, a file that must exist, a count read back)';
347
+ if (/\|\|\s*(?:true|:|exit 0)\b/.test(text)) return `verify "${clip(text, 80)}" can never fail (it ends in || true); it must fail when the deliverable is missing`;
348
+ const parts = text.split(/\s*(?:&&|;|\|\|)\s*/).filter(Boolean);
349
+ if (parts.every(part => TRIVIAL_VERIFY.some(re => re.test(part)))) return `verify "${clip(text, 80)}" is always true or only checks formatting; it must prove the deliverable (a test file, a file that must exist, a count read back)`;
350
+ return null;
351
+ }
352
+
353
+ function readBriefs(workspaceRoot) {
354
+ const dir = path.join(workspaceRoot, 'atris', 'wiki', 'briefs');
355
+ let names;
356
+ try {
357
+ names = fs.readdirSync(dir).filter(n => n.endsWith('.md'));
358
+ } catch {
359
+ return null;
360
+ }
361
+ const rows = names.map((name) => {
362
+ const file = path.join(dir, name);
363
+ try {
364
+ return { name, file, mtime: fs.statSync(file).mtimeMs };
365
+ } catch {
366
+ return null;
367
+ }
368
+ }).filter(Boolean).sort((a, b) => b.mtime - a.mtime || a.name.localeCompare(b.name)).slice(0, BRIEF_COUNT);
369
+ if (!rows.length) return null;
370
+ return rows.map((r) => {
371
+ const head = fs.readFileSync(r.file, 'utf8').split(/\r?\n/).slice(0, BRIEF_LINES).join('\n');
372
+ const title = (/^#\s+(.+)$/m.exec(head) || [])[1] || r.name.replace(/\.md$/, '');
373
+ return { name: r.name, title: clip(title, 80), text: head };
374
+ });
375
+ }
376
+
377
+ // ---- scoring: did a bet move its number? -------------------------------------------
378
+
379
+ function daysBetween(from, to) {
380
+ const at = s => { const [y, m, d] = String(s).split('-').map(Number); return Date.UTC(y, m - 1, d); };
381
+ return Math.round((at(to) - at(from)) / (24 * 60 * 60 * 1000));
382
+ }
383
+
384
+ // How many of these job ids landed (a completed event whose action is
385
+ // accepted or merged, the season board's rule) at or before endMs. A job that
386
+ // was closed, dropped, archived, or parked has no such event and counts zero.
387
+ // Opens the task db read-only. Resolves { value } or { error, transient }.
388
+ function defaultLandedFromQueue(ids, endMs, { dbPath } = {}) {
389
+ const target = dbPath || require('./task-db').getDbPath();
390
+ if (!fs.existsSync(target)) return { error: 'there is no task history here', transient: true };
391
+ let db;
392
+ try {
393
+ const { DatabaseSync } = require('node:sqlite');
394
+ db = new DatabaseSync(target, { readOnly: true });
395
+ const marks = ids.map(() => '?').join(', ');
396
+ const rows = db.prepare(`SELECT task_id, payload FROM task_events WHERE event_type = 'completed' AND created_at <= ? AND task_id IN (${marks})`).all(endMs, ...ids);
397
+ const landed = new Set();
398
+ for (const row of rows) {
399
+ let payload = {};
400
+ try { payload = JSON.parse(row.payload || '{}') || {}; } catch { payload = {}; }
401
+ if (LANDED_ACTIONS.has(payload.action)) landed.add(row.task_id);
402
+ }
403
+ return { value: landed.size };
404
+ } catch (err) {
405
+ return { error: `could not read the task history: ${err.message}`, transient: true };
406
+ } finally {
407
+ try { if (db) db.close(); } catch { /* already closed */ }
408
+ }
409
+ }
410
+
411
+ // ---- which waiting jobs have real work behind them --------------------------------
412
+
413
+ // The test file a job's verify command runs, or null when the check is a
414
+ // generic suite run (or missing): such a job has no check of its own.
415
+ function verifyTestFile(verify) {
416
+ const text = Array.isArray(verify) ? verify.join(' ') : String(verify || '');
417
+ const hit = /(?:^|[\s'"])((?:[\w.-]+\/)*[\w.-]+\.test\.[cm]?[jt]s)\b/.exec(text);
418
+ return hit ? hit[1] : null;
419
+ }
420
+
421
+ const QUEUE_KINDS = ['branch', 'merged_open', 'no_work', 'placeholder', 'unknown'];
422
+ // jobs that can actually land: their work exists somewhere git can see
423
+ const REAL_WORK = new Set(['branch', 'merged_open']);
424
+
425
+ // Sorts the waiting jobs, read-only, by what git and the task db can show:
426
+ // merged_open a commit on master since the job was made touches its test file
427
+ // branch a commit on another branch since then touches it
428
+ // no_work it has its own check, but no commit touches the file yet
429
+ // placeholder no check of its own (no test file in verify)
430
+ // unknown the db or git could not tell
431
+ function defaultClassifyQueue(workspaceRoot, jobs, { dbPath } = {}) {
432
+ const meta = new Map();
433
+ const target = dbPath || require('./task-db').getDbPath();
434
+ let db;
435
+ try {
436
+ if (!fs.existsSync(target)) throw new Error('no task db');
437
+ const { DatabaseSync } = require('node:sqlite');
438
+ db = new DatabaseSync(target, { readOnly: true });
439
+ const ids = jobs.map(j => j.id);
440
+ const rows = ids.length ? db.prepare(`SELECT id, created_at, metadata FROM tasks WHERE id IN (${ids.map(() => '?').join(', ')})`).all(...ids) : [];
441
+ for (const row of rows) {
442
+ let m = {};
443
+ try { m = JSON.parse(row.metadata || '{}') || {}; } catch { m = {}; }
444
+ meta.set(row.id, { verify: m.verify, created_at: Number(row.created_at) });
445
+ }
446
+ } catch {
447
+ return jobs.map(j => ({ ...j, kind: 'unknown' }));
448
+ } finally {
449
+ try { if (db) db.close(); } catch { /* already closed */ }
450
+ }
451
+ const ref = mainRef(workspaceRoot);
452
+ return jobs.map((j) => {
453
+ const m = meta.get(j.id);
454
+ if (!m) return { ...j, kind: 'unknown' };
455
+ const file = verifyTestFile(m.verify);
456
+ if (!file) return { ...j, kind: 'placeholder' };
457
+ if (!ref || !Number.isFinite(m.created_at)) return { ...j, kind: 'unknown', file };
458
+ const since = `--since=@${Math.floor(m.created_at / 1000)}`;
459
+ const onMaster = gitOut(workspaceRoot, ['log', ref, '-1', '--format=%H', since, '--', file]);
460
+ if (onMaster === null) return { ...j, kind: 'unknown', file };
461
+ if (onMaster.trim()) return { ...j, kind: 'merged_open', file };
462
+ const anywhere = gitOut(workspaceRoot, ['log', '--branches', '--remotes', '-1', '--format=%H', since, '--', file]);
463
+ if (anywhere === null) return { ...j, kind: 'unknown', file };
464
+ return { ...j, kind: anywhere.trim() ? 'branch' : 'no_work', file };
465
+ });
466
+ }
467
+
468
+ function queueFact(split) {
469
+ const n = k => split.filter(j => j.kind === k).length;
470
+ const parts = [];
471
+ if (n('branch')) parts.push(`${n('branch')} with work on a branch`);
472
+ if (n('merged_open')) parts.push(`${n('merged_open')} already merged but open`);
473
+ if (n('no_work')) parts.push(`${n('no_work')} with a check but no work yet`);
474
+ if (n('placeholder')) parts.push(`${n('placeholder')} ${n('placeholder') === 1 ? 'placeholder' : 'placeholders'} with no check`);
475
+ if (n('unknown')) parts.push(`${n('unknown')} unknown`);
476
+ return `${split.length} waiting: ${parts.join(', ') || 'none'}`;
477
+ }
478
+
479
+ // The end of a local calendar day, as epoch ms.
480
+ function dayEndMs(day) {
481
+ const [y, m, d] = String(day).split('-').map(Number);
482
+ return new Date(y, m - 1, d + 1, 0, 0, 0).getTime() - 1;
483
+ }
484
+
485
+ // Reads one metric the same way the bundle reads it. installs: the 7 days
486
+ // that end on `end` (inclusive), from npm. queue_landed: how many of the job
487
+ // ids snapshotted when the bet was made landed by the end of `end`, from the
488
+ // task db. Resolves { value } or { error, transient }; transient means try
489
+ // again later (npm has not counted the days yet, or the db is busy), not
490
+ // transient means never.
491
+ async function defaultReadMetric(metric, { workspaceRoot, start, end, target = {}, deps = {} }) {
492
+ if (metric === 'installs') {
493
+ const pkg = deps.packageName !== undefined ? deps.packageName : packageName(workspaceRoot);
494
+ if (!pkg) return { error: 'no published package here to read installs from', transient: false };
495
+ let body;
496
+ try {
497
+ body = await (deps.fetchJson || defaultFetchJson)(`https://api.npmjs.org/downloads/range/${start}:${end}/${npmName(pkg)}`);
498
+ } catch (err) {
499
+ return { error: `could not reach npm: ${err.message}`, transient: true };
500
+ }
501
+ const days = (body && Array.isArray(body.downloads) ? body.downloads : []).filter(d => d && typeof d.downloads === 'number' && d.day >= start && d.day <= end);
502
+ if (days.length < 7 || !days.some(d => d.day === end)) return { error: `npm has not counted every day up to ${end} yet`, transient: true };
503
+ return { value: days.reduce((n, d) => n + d.downloads, 0) };
504
+ }
505
+ if (metric === 'queue_landed') {
506
+ const ids = Array.isArray(target.job_ids) ? target.job_ids.filter(Boolean) : [];
507
+ if (!ids.length) return { error: 'the bet has no snapshot of the waiting jobs to score', transient: false };
508
+ return (deps.landedFromQueue || defaultLandedFromQueue)(ids, dayEndMs(end), { dbPath: deps.taskDbPath });
509
+ }
510
+ return { error: `there is no way to read "${metric}"`, transient: false };
511
+ }
512
+
513
+ // Won when the number reached the goal.
514
+ function wonBet(metric, actual, goal) {
515
+ return METRICS[metric].up ? actual >= goal : actual <= goal;
516
+ }
517
+
518
+ // Scores every bet the team is doing once its check date is over: reads its
519
+ // target number and records won or lost with the real number. Proposed bets
520
+ // are never scored. A number that can never be read is marked unreadable
521
+ // with why (old "waiting" and "landed" bets, bets with no target); one that
522
+ // is only late (npm lags, the db is busy) waits up to SCORE_GRACE_DAYS.
523
+ async function scoreBets(workspaceRoot, { ledgerFile = ledgerPath(), today = dayKey(Date.now()), deps = {}, emit = () => {} } = {}) {
524
+ const due = readLedger(ledgerFile, workspaceRoot).filter(b => b.status === 'doing' && b.bet && /^\d{4}-\d{2}-\d{2}$/.test(String(b.bet.check_date || '')) && b.bet.check_date < today);
525
+ const read = deps.readMetric || defaultReadMetric;
526
+ const scored = [];
527
+ for (const b of due) {
528
+ const t = b.bet.target;
529
+ const metric = t && t.metric;
530
+ const end = b.bet.check_date;
531
+ const start = addDays(end, -6);
532
+ const late = daysBetween(end, today);
533
+ const row = { id: b.id, workspace: workspaceRoot, at: new Date().toISOString(), scored_on: today };
534
+ let out;
535
+ if (!t || !METRICS[metric] || !Number.isFinite(t.goal)) {
536
+ out = { error: metric && !METRICS[metric] ? `there is no way to read "${metric}"` : 'the bet has no target number to read', transient: false };
537
+ } else {
538
+ try {
539
+ out = await read(metric, { workspaceRoot, start, end, target: t, deps });
540
+ } catch (err) {
541
+ out = { error: String((err && err.message) || err), transient: true };
542
+ }
543
+ }
544
+ if (!out || !Number.isFinite(out.value)) {
545
+ const why = (out && out.error) || 'the number could not be read';
546
+ if (out && out.transient && late <= SCORE_GRACE_DAYS) continue;
547
+ Object.assign(row, { status: 'unreadable', why });
548
+ } else {
549
+ const window = metric === 'installs' ? `${start} to ${end}` : `landed by the end of ${end}, of ${plural(t.job_ids.length, 'job')} waiting at the start`;
550
+ Object.assign(row, { status: wonBet(metric, out.value, t.goal) ? 'won' : 'lost', actual: out.value, window });
551
+ }
552
+ appendLedger(ledgerFile, row);
553
+ scored.push({ ...row, title: b.bet.title });
554
+ const line = row.status === 'unreadable' ? `could not score a past bet: ${clip(b.bet.title, 60)} (${row.why})` : `scored a past bet: ${row.status}, ${clip(b.bet.title, 60)} (goal ${commas(t.goal)}, got ${commas(row.actual)})`;
555
+ emit({ kind: 'score', key: 'score', line });
556
+ }
557
+ return scored;
558
+ }
559
+
560
+ // The readable numbers in this bundle: today's value and the bar a goal must
561
+ // clear. installs: above the best week of the last 8. queue_landed: at least
562
+ // half of the jobs waiting on review today, counted from 0.
563
+ function bundleMetrics(bundle) {
564
+ const s = (bundle && bundle.sources) || {};
565
+ const out = {};
566
+ if (s.installs) {
567
+ const best = s.installs.best || null;
568
+ out.installs = { baseline: s.installs.data.last_week, best: best ? best.best : null, weeks: best ? best.weeks : 0 };
569
+ }
570
+ if (s.queue && Array.isArray(s.queue.jobs)) {
571
+ // placeholders, jobs with no work yet, and unknowns are left out of both
572
+ // the snapshot and the bar
573
+ const ids = s.queue.jobs.filter(j => REAL_WORK.has(j.kind)).map(j => j.id).filter(Boolean);
574
+ out.queue_landed = { baseline: 0, waiting: s.queue.jobs.length, real: ids.length, least: Math.ceil(ids.length / 2), ids };
575
+ }
576
+ return out;
577
+ }
578
+
579
+ // Reads every source, in a fixed order, and emits one event per source with
580
+ // a real fact (or says it was left out). A missing source is omitted, never
581
+ // invented. Past bets that are due are scored first, so the ledger the model
582
+ // reads already has their results.
583
+ async function readBundle(workspaceRoot, { deps = {}, emit = () => {}, ledgerFile, today = dayKey(Date.now()) } = {}) {
584
+ const ledger = ledgerFile || ledgerPath();
585
+ try {
586
+ await (deps.scoreBets || scoreBets)(workspaceRoot, { ledgerFile: ledger, today, deps, emit });
587
+ } catch {
588
+ // scoring is a side job; a failure never blocks the next bet
589
+ }
590
+ const bundle = { workspace: workspaceRoot, sources: {}, facts: [] };
591
+ const add = (key, fact, data) => {
592
+ bundle.sources[key] = { fact, ...data };
593
+ bundle.facts.push(fact);
594
+ emit({ kind: 'source', key, line: fact });
595
+ };
596
+ const skip = (key, why) => emit({ kind: 'skip', key, line: why });
597
+
598
+ const now = (deps.readNow || readNow)(workspaceRoot);
599
+ if (now) add('now', now.fact, { text: now.text });
600
+ else skip('now', 'no atris/now.md, left out');
601
+
602
+ let season = null;
603
+ try {
604
+ season = (deps.seasonFacts || defaultSeasonFacts)(workspaceRoot);
605
+ } catch {
606
+ season = null;
607
+ }
608
+ if (season) add('season', seasonFact(season), { data: season });
609
+ else skip('season', 'could not read the task list, left out');
610
+
611
+ // the review queue split by what is really behind each job: only jobs whose
612
+ // work git can see can land, so only those count toward a queue target
613
+ if (season && Array.isArray(season.waiting_jobs)) {
614
+ let split;
615
+ try {
616
+ split = (deps.classifyQueue || defaultClassifyQueue)(workspaceRoot, season.waiting_jobs, { dbPath: deps.taskDbPath });
617
+ } catch {
618
+ split = season.waiting_jobs.map(j => ({ ...j, kind: 'unknown' }));
619
+ }
620
+ // team plumbing: kept for the queue_landed metric code, never shown to the owner or the writer
621
+ bundle.sources.queue = { fact: queueFact(split), jobs: split };
622
+ }
623
+
624
+ let landed = null;
625
+ try {
626
+ landed = (deps.gitLanded || defaultGitLanded)(workspaceRoot);
627
+ } catch {
628
+ landed = null;
629
+ }
630
+ if (landed) add('landed', `changes merged this week: ${commas(landed.length)}`, { subjects: landed.slice(0, 40) });
631
+ else skip('landed', 'could not read what landed on master, left out');
632
+
633
+ const pkg = deps.packageName !== undefined ? deps.packageName : packageName(workspaceRoot);
634
+ const fetchJson = deps.fetchJson || defaultFetchJson;
635
+ let installs = null;
636
+ let installBody = null;
637
+ if (pkg) {
638
+ try {
639
+ // a year of days: last week, the week before, and the last 8 full weeks
640
+ installBody = await fetchJson(`https://api.npmjs.org/downloads/range/last-year/${npmName(pkg)}`);
641
+ installs = installWeeks(installBody);
642
+ } catch {
643
+ installs = null;
644
+ }
645
+ }
646
+ if (installs) {
647
+ const before = installs.week_before == null ? '' : ` (week before: ${commas(installs.week_before)})`;
648
+ add('installs', `installs last week: ${commas(installs.last_week)}${before}`, { package: pkg, data: installs });
649
+ const best = bestWeek(installTotals(installBody));
650
+ if (best) {
651
+ bundle.sources.installs.best = best;
652
+ add('installs_best', `best week for installs in the last ${bestSpan(best.weeks)}: ${commas(best.best)}`, { metric: 'installs', ...best });
653
+ } else {
654
+ skip('installs_best', 'no full week of installs yet, so there is no best week to beat');
655
+ }
656
+ } else {
657
+ skip('installs', pkg ? 'could not reach npm for installs, left out' : 'no published package here, installs left out');
658
+ }
659
+
660
+ // what is merged but not published: a post about an unpublished feature
661
+ // sends people to a version that does not have it
662
+ let version = null;
663
+ if (pkg) {
664
+ try {
665
+ const latest = await fetchJson(`https://registry.npmjs.org/${npmName(pkg)}/latest`);
666
+ version = latest && typeof latest.version === 'string' && /^\d+\.\d+\.\d+/.test(latest.version) ? latest.version : null;
667
+ } catch {
668
+ version = null;
669
+ }
670
+ }
671
+ if (version) {
672
+ let gap = null;
673
+ try {
674
+ gap = (deps.releaseGap || defaultReleaseGap)(workspaceRoot, version);
675
+ } catch {
676
+ gap = null;
677
+ }
678
+ add('release', releaseFact(version, gap), { version, unpublished: gap && Number.isInteger(gap.count) ? gap.count : null });
679
+ } else {
680
+ skip('release', pkg ? 'could not read the published version from npm, left out' : 'no published package here, release left out');
681
+ }
682
+
683
+ let team = null;
684
+ try {
685
+ team = (deps.readTeam || readTeam)(workspaceRoot);
686
+ } catch {
687
+ team = null;
688
+ }
689
+ if (team) add('team', `team: ${plural(team.length, 'member')} in atris/team`, { members: team });
690
+ else skip('team', 'no atris/team folder, steps name no member');
691
+
692
+ const briefs = (deps.readBriefs || readBriefs)(workspaceRoot);
693
+ if (briefs && briefs.length) add('briefs', `read ${plural(briefs.length, 'research brief')}, newest: ${briefs[0].title}`, { briefs });
694
+ else skip('briefs', 'no research briefs in atris/wiki/briefs, left out');
695
+
696
+ const bets = readLedger(ledger, workspaceRoot);
697
+ // every scored bet the model can learn from, then the newest of the rest
698
+ const isScored = b => ['won', 'lost', 'unreadable'].includes(b.status);
699
+ const keep = new Set([...bets.filter(isScored).slice(-10), ...bets.filter(b => !isScored(b)).slice(-10)]);
700
+ const shown = bets.filter(b => keep.has(b)).map(b => ({
701
+ title: b.bet && b.bet.title,
702
+ status: b.status,
703
+ expect: b.bet && b.bet.expect,
704
+ check_date: b.bet && b.bet.check_date,
705
+ ...(b.bet && b.bet.target ? { goal: b.bet.target.goal, metric: b.bet.target.metric } : {}),
706
+ ...(Number.isFinite(b.actual) ? { actual: b.actual } : {}),
707
+ ...(b.why ? { why: b.why } : {}),
708
+ }));
709
+ add('ledger', ledgerLine(bets), { bets: shown });
710
+ return bundle;
711
+ }
712
+
713
+ function pastBetLine(b) {
714
+ if ((b.status === 'won' || b.status === 'lost') && Number.isFinite(b.actual)) return `- ${b.status}: ${b.title} (goal ${commas(b.goal)}, got ${commas(b.actual)})`;
715
+ if (b.status === 'unreadable') return `- could not score: ${b.title} (${b.why || 'the number could not be read'})`;
716
+ return `- ${b.title} (${b.status}; expected ${b.expect}; check ${b.check_date})`;
717
+ }
718
+
719
+ function bundleText(bundle) {
720
+ const s = bundle.sources;
721
+ const parts = ['## facts', ...bundle.facts.map(f => `- ${f}`)];
722
+ if (s.now) parts.push('', '## atris/now.md', s.now.text);
723
+ if (s.landed && s.landed.subjects.length) parts.push('', '## changes merged on master in the last 7 days', ...s.landed.subjects.map(x => `- ${x}`));
724
+ if (s.installs) parts.push('', `## npm installs for ${s.installs.package}`, `last week: ${s.installs.data.last_week}`, `week before: ${s.installs.data.week_before == null ? 'unknown' : s.installs.data.week_before}`, `through: ${s.installs.data.through}`);
725
+ if (s.release) parts.push('', '## release', `published on npm: ${s.release.version}`, `changes on master not in it: ${s.release.unpublished == null ? 'unknown' : s.release.unpublished}`);
726
+ if (s.team) parts.push('', '## the team (a team step names one of these as its member)', ...s.team.members.map(m => `- ${m.name}: ${m.role}`));
727
+ if (s.briefs) for (const b of s.briefs.briefs) parts.push('', `## research brief: ${b.name}`, b.text);
728
+ if (s.ledger && s.ledger.bets.length) parts.push('', '## past bets', ...s.ledger.bets.map(pastBetLine));
729
+ return parts.join('\n');
730
+ }
731
+
732
+ // ---- the prompt ----------------------------------------------------------------------
733
+
734
+ // The bar a goal has to clear, in words, for the prompt and the reject reason.
735
+ function goalBar(metric, m) {
736
+ if (metric === 'installs') {
737
+ return m.best == null ? 'the goal must be above the baseline' : `the best week in the last ${bestSpan(m.weeks)} was ${commas(m.best)}, so the goal must be above ${commas(m.best)}`;
738
+ }
739
+ return `${commas(m.real)} of the ${commas(m.waiting)} waiting jobs have real work, so the goal must be at least ${commas(m.least)} of those landed`;
740
+ }
741
+
742
+ // metrics the team can use but an owner card never picks: they measure plumbing
743
+ const TEAM_ONLY_METRICS = new Set(['queue_landed']);
744
+
745
+ function metricChoices(bundle) {
746
+ const m = bundleMetrics(bundle);
747
+ return Object.keys(m).filter(k => !TEAM_ONLY_METRICS.has(k)).map((k) => {
748
+ return `"${k}" (${METRICS[k].what}; baseline ${m[k].baseline}; ${goalBar(k, m[k])})`;
749
+ });
750
+ }
751
+
752
+ function buildPrompt(bundle, { today, previousTitle = null, reason = null, previousAnswer = null, blocker = null, avoidTitles = [] } = {}) {
753
+ const choices = metricChoices(bundle);
754
+ const lines = [
755
+ 'You are the strategist for a small software business. Below is everything we can read about it right now.',
756
+ 'Propose exactly ONE bet: the most ambitious move this team can start this week, one that would beat our best installs week of the last 2 months if it works. It must be new, not already written down in these sources. Small safe bets get rejected.',
757
+ 'Propose only a business move: customers, revenue, reach, or product people use. Plumbing is the team\'s job and happens without asking: releases, review queues, unmerged or stuck jobs, test speed, and fixing counts are never the bet.',
758
+ '',
759
+ 'Answer with strict JSON only, no prose and no code fence, in this shape:',
760
+ '{"title": "...", "why_now": "...", "expect": "...", "target": {"metric": "installs", "baseline": 0, "goal": 0}, "check_in_days": 7, "metric_source": "...", "verify": "...", "cost": "...", "steps": [{"text": "...", "detail": "...", "who": "team", "member": "builder", "public": false}]}',
761
+ '',
762
+ 'Rules:',
763
+ '- title: one sentence that starts with an action verb and names a concrete deliverable (a page, a command, a post, an email, a demo, a price). Under 120 characters.',
764
+ `- never use these as the whole action: ship it, improve, optimize, leverage, enhance, boost, streamline, grow, scale, explore.`,
765
+ `- why_now: at most two short plain sentences, under ${WHY_MAX} characters in all, that say what is going on and quote at least one exact fact from the facts list below, with its number inside the sentence.`,
766
+ `- expect: one plain sentence, under ${EXPECT_MAX} characters, that follows why_now and says what we expect if it works, with the numbers inside it, for example "if it works, weekly installs pass 2,400 by nov 1, past our best week of 2,319." the card shows why_now and expect together as one short paragraph, so they must read as one.`,
767
+ `- target: the one number this bet moves. metric is one of: ${choices.length ? choices.join('; ') : 'none can be read right now'}. baseline is that number exactly as it is today. goal is the number you expect on the check date, and it must clear the bar named for its metric.`,
768
+ `- check_in_days: a whole number from ${MIN_CHECK_DAYS} to ${CHECK_WINDOW_DAYS}, how many days from today until we read the number. we work out the date.`,
769
+ '- metric_source: where we will read that number, in plain words (npm installs, or jobs landed from the review queue).',
770
+ '- cost: team time and money, for example "about 6 hours of agent time, $0".',
771
+ `- steps: ${MIN_STEPS} to ${MAX_STEPS} steps in order. text is one plain line under ${STEP_MAX} characters, with no file paths, no commands, and no flags. detail (optional, under ${DETAIL_MAX} characters) holds the exact how: files, commands, numbers. who is "team" for work the agents can do alone and "you" for anything the owner must approve. public is true when the step is visible outside the company.`,
772
+ '- any step that publishes, posts, releases, sends, launches, spends money, or cannot be undone must be who "you". at least one step must be who "team".',
773
+ '- member: every team step names the one member of the team below who does it, by the exact name in the team list. pick the member whose role fits the step. a "you" step has no member. the member of the first team step starts the job, and each later step is handed to its member.',
774
+ '- verify: one shell command, run from the repo root, that exits 0 only when the deliverable exists and works, for example a test file (node --test test/name.test.js), a file that must exist and not be empty (test -s path/to/file), or a count read back (test "$(wc -l < file)" -ge 20). never true, echo, exit 0, or only git diff --check, and never ending in || true. this replaces the formatting check, so it is how we know the job is really done.',
775
+ '- check what has to be true first. if the move shows people something that is merged but not published yet (see the release fact), the release is one step for "you" inside the move, never the move itself.',
776
+ '- past bets marked won or lost are real results, read from the same numbers. lean toward moves like the winners and away from the losers, and when you do, say so in why_now.',
777
+ '- write in plain lowercase-friendly words, no hype, no em dashes.',
778
+ ];
779
+ // a skeptic named what blocks the last idea: the blocker is the next bet,
780
+ // not one more step on the old idea
781
+ if (blocker) lines.push('', `The last idea ("${clip(blocker.title, 120)}") is blocked by: ${blocker.missing_first.replace(/[.\s]+$/, '')}. Don't patch that idea. Make clearing this blocker the bet, as big as this team can do in the window, with its own target. The blocker stops being a step and becomes the whole move. If the blocker is team plumbing (a release, a merge, a review, a fix to the build or a count), it is never the bet: keep a business move and put the plumbing in as one step.`);
782
+ if (previousTitle) lines.push('', `Make it sharper, more specific, and different from: ${previousTitle}`);
783
+ const avoid = avoidTitles.filter(t => t && t !== previousTitle);
784
+ if (avoid.length) lines.push('', 'It must be a different move from every one of these bets already shown or waiting:', ...avoid.map(t => `- ${t}`));
785
+ if (previousAnswer) lines.push('', 'Your last answer was:', clip(previousAnswer, 4000));
786
+ if (reason) lines.push('', `Your last answer was rejected: ${reason}. Fix that and answer again with strict JSON only.`);
787
+ lines.push('', `Today is ${today}.`, '', bundleText(bundle));
788
+ return lines.join('\n');
789
+ }
790
+
791
+ // ---- the model call --------------------------------------------------------------------
792
+
793
+ // Subscription only: the model never bills an API key.
794
+ function modelEnv(env = process.env) {
795
+ const out = { ...env };
796
+ delete out.ANTHROPIC_API_KEY;
797
+ delete out.ANTHROPIC_AUTH_TOKEN;
798
+ return out;
799
+ }
800
+
801
+ function claudeArgs(prompt, model = MODEL) {
802
+ return ['-p', prompt, '--model', model, '--output-format', 'stream-json', '--verbose', '--include-partial-messages', '--tools', '', '--no-session-persistence', '--strict-mcp-config'];
803
+ }
804
+
805
+ // Runs the model in its own process group. On timeout the whole group is
806
+ // killed, so nothing it started keeps running. Streams real progress (tokens
807
+ // thought, characters written) through onProgress.
808
+ function runModel(prompt, { command = { bin: 'claude', args: claudeArgs }, cwd, timeoutMs = MODEL_TIMEOUT_MS, env = process.env, onProgress = () => {}, spawnFn = require('child_process').spawn, kill = process.kill.bind(process) } = {}) {
809
+ return new Promise((resolve) => {
810
+ const args = typeof command.args === 'function' ? command.args(prompt) : [...(command.args || []), prompt];
811
+ let child;
812
+ try {
813
+ child = spawnFn(command.bin, args, { cwd, env: modelEnv(env), detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'] });
814
+ } catch (err) {
815
+ resolve({ ok: false, reason: `could not start the model: ${err.message}` });
816
+ return;
817
+ }
818
+ let buf = '';
819
+ let size = 0;
820
+ let stderr = '';
821
+ let text = '';
822
+ let result = null;
823
+ let thinking = 0;
824
+ let timedOut = false;
825
+ let done = false;
826
+ const timer = setTimeout(() => {
827
+ timedOut = true;
828
+ try {
829
+ kill(-child.pid, 'SIGKILL');
830
+ } catch {
831
+ try { child.kill('SIGKILL'); } catch { /* already gone */ }
832
+ }
833
+ }, timeoutMs);
834
+ const line = (raw) => {
835
+ if (!raw.trim()) return;
836
+ let ev;
837
+ try {
838
+ ev = JSON.parse(raw);
839
+ } catch {
840
+ return;
841
+ }
842
+ if (ev.type === 'system' && ev.subtype === 'thinking_tokens' && Number.isFinite(ev.estimated_tokens)) {
843
+ thinking = ev.estimated_tokens;
844
+ onProgress({ thinking, chars: text.length, text });
845
+ } else if (ev.type === 'stream_event' && ev.event && ev.event.type === 'content_block_delta' && ev.event.delta && ev.event.delta.type === 'text_delta') {
846
+ text += ev.event.delta.text || '';
847
+ onProgress({ thinking, chars: text.length, text });
848
+ } else if (ev.type === 'result') {
849
+ result = ev;
850
+ }
851
+ };
852
+ child.stdout.on('data', (chunk) => {
853
+ size += chunk.length;
854
+ if (size > OUTPUT_MAX) return;
855
+ buf += chunk.toString('utf8');
856
+ let i;
857
+ while ((i = buf.indexOf('\n')) >= 0) {
858
+ line(buf.slice(0, i));
859
+ buf = buf.slice(i + 1);
860
+ }
861
+ });
862
+ child.stderr.on('data', (chunk) => { if (stderr.length < 4000) stderr += chunk.toString('utf8'); });
863
+ const finish = (out) => {
864
+ if (done) return;
865
+ done = true;
866
+ clearTimeout(timer);
867
+ resolve(out);
868
+ };
869
+ child.on('error', err => finish({ ok: false, reason: `could not start the model: ${err.message}` }));
870
+ child.on('close', (code) => {
871
+ if (buf) line(buf);
872
+ if (timedOut) return finish({ ok: false, reason: `the model took longer than ${Math.round(timeoutMs / 1000)} seconds and was stopped`, timed_out: true });
873
+ if (result && result.is_error) return finish({ ok: false, reason: `the model said: ${clip(result.result || result.subtype, 160)}` });
874
+ const answer = result && typeof result.result === 'string' ? result.result : text;
875
+ if (code !== 0 && !answer) return finish({ ok: false, reason: `the model exited with code ${code}${stderr.trim() ? `: ${clip(stderr.trim().split('\n').pop(), 160)}` : ''}` });
876
+ if (!answer.trim()) return finish({ ok: false, reason: 'the model gave an empty answer' });
877
+ return finish({ ok: true, text: answer });
878
+ });
879
+ });
880
+ }
881
+
882
+ // ---- checking a bet -------------------------------------------------------------------
883
+
884
+ function parseBet(text) {
885
+ const s = String(text || '').trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
886
+ const start = s.indexOf('{');
887
+ const end = s.lastIndexOf('}');
888
+ if (start < 0 || end <= start) return { error: 'the answer was not JSON' };
889
+ try {
890
+ return { bet: JSON.parse(s.slice(start, end + 1)) };
891
+ } catch {
892
+ return { error: 'the answer was not valid JSON' };
893
+ }
894
+ }
895
+
896
+ function words(text) {
897
+ return String(text || '').toLowerCase().match(/[a-z0-9][a-z0-9'.-]*/g) || [];
898
+ }
899
+
900
+ function normTitle(text) {
901
+ return words(text).join(' ');
902
+ }
903
+
904
+ function titleProblem(title) {
905
+ const t = String(title || '').trim();
906
+ if (t.length < 20) return 'the title is too short to name a deliverable';
907
+ if (t.length > 140) return 'the title is longer than 140 characters';
908
+ if (/[.!?]\s+\S/.test(t)) return 'the title is more than one sentence';
909
+ const w = words(t);
910
+ if (VAGUE_VERBS.includes(w[0])) return `the title's action is the vague verb "${w[0]}"`;
911
+ const concrete = w.filter(x => x.length >= 4 && !STOP_WORDS.has(x) && !VAGUE_NOUNS.has(x) && !VAGUE_VERBS.includes(x) && !/^\d/.test(x));
912
+ if (concrete.length < 3) return 'the title has no concrete deliverable';
913
+ return null;
914
+ }
915
+
916
+ // why_now must quote the bundle: a number that appears in a fact, or four
917
+ // words in a row from the sources.
918
+ function citesBundle(whyNow, bundle) {
919
+ const nums = s => (String(s).match(/\d[\d,]*(?:\.\d+)?/g) || []).map(n => n.replace(/,/g, '')).filter(n => n.length >= 1);
920
+ const factNums = new Set(bundle.facts.flatMap(nums));
921
+ if (nums(whyNow).some(n => factNums.has(n))) return true;
922
+ const src = ` ${words(bundleText(bundle)).join(' ')} `;
923
+ const w = words(whyNow);
924
+ for (let i = 0; i + 4 <= w.length; i += 1) {
925
+ const gram = w.slice(i, i + 4);
926
+ if (gram.every(x => STOP_WORDS.has(x))) continue;
927
+ if (src.includes(` ${gram.join(' ')} `)) return true;
928
+ }
929
+ return false;
930
+ }
931
+
932
+ function stems(word) {
933
+ const w = String(word || '');
934
+ return [w, w.replace(/s$/, ''), w.replace(/es$/, ''), w.replace(/d$/, ''), w.replace(/ed$/, ''), w.replace(/ing$/, ''), w.replace(/ing$/, 'e')];
935
+ }
936
+
937
+ function ownerOnlyAction(text) {
938
+ const w = words(text);
939
+ const actions = w.filter((x, i) => i === 0 || w[i - 1] === 'and' || w[i - 1] === 'then');
940
+ return actions.some(a => stems(a).some(x => OWNER_ONLY_WORDS.has(x)));
941
+ }
942
+
943
+ // A step line is plain words: no file paths, file names, flags, or commands.
944
+ const STEP_CODE = [
945
+ /`/,
946
+ /(^|\s)[\w.~-]*\/[\w./-]+/,
947
+ /\b[\w-]+\.(js|ts|tsx|sh|md|json|py|yml|yaml|txt|html|css|jsonl)\b/i,
948
+ /(^|\s)--?[a-z]/i,
949
+ /\b(npm|npx|git|node|gh|atris)\s+(install|run|publish|test|view|push|commit|merge|tag|log|pr|task|mission|gm|slop|release|init)\b/i,
950
+ ];
951
+
952
+ function stepProblem(text) {
953
+ const t = String(text || '').trim();
954
+ if (t.length > STEP_MAX) return `is ${t.length} characters, over the ${STEP_MAX} that fit one line`;
955
+ if (STEP_CODE.some(re => re.test(t))) return 'has a file path or a command in it; put that in detail';
956
+ return null;
957
+ }
958
+
959
+ function sentenceCount(text) {
960
+ return String(text || '').split(/[.!?]+(?:\s+|$)/).filter(x => x.trim()).length;
961
+ }
962
+
963
+ // Two titles are near duplicates when half or more of their real words match.
964
+ function contentWords(text) {
965
+ return new Set(words(text).map(w => w.replace(/[.']/g, '')).filter(w => w.length >= 4 && !STOP_WORDS.has(w)));
966
+ }
967
+
968
+ function nearDuplicate(a, b) {
969
+ const x = contentWords(a);
970
+ const y = contentWords(b);
971
+ if (!x.size || !y.size) return normTitle(a) === normTitle(b);
972
+ let both = 0;
973
+ for (const w of x) if (y.has(w)) both += 1;
974
+ return both / (x.size + y.size - both) >= 0.5;
975
+ }
976
+
977
+ // The target is the bet's number: one we can read again on the check date,
978
+ // starting from today's real value, with a goal that says something either
979
+ // way: installs above our best week of the last 2 months, or the review queue
980
+ // cut to half or less. Each reason states the numbers so the retry can fix them.
981
+ function targetProblems(target, bundle, { allowTeamMetrics = false } = {}) {
982
+ const all = bundleMetrics(bundle);
983
+ const m = allowTeamMetrics ? all : Object.fromEntries(Object.entries(all).filter(([k]) => !TEAM_ONLY_METRICS.has(k)));
984
+ const names = Object.keys(m);
985
+ if (target && TEAM_ONLY_METRICS.has(target.metric) && !allowTeamMetrics) return [`target metric ${target.metric} measures team plumbing, not a business move; an owner bet moves ${names.join(' or ') || 'a business number'}`];
986
+ if (!target || typeof target !== 'object' || Array.isArray(target)) return ['target is missing; give {"metric", "baseline", "goal"}'];
987
+ if (!names.length) return ['there is no number we can read here for a target (no npm installs and no task list)'];
988
+ if (!m[target.metric]) return [`target metric must be one we can read here: ${names.join(' or ')}, got ${JSON.stringify(target.metric)}`];
989
+ const metric = target.metric;
990
+ const known = m[metric];
991
+ const real = known.baseline;
992
+ const label = METRICS[metric].now;
993
+ const out = [];
994
+ if (!Number.isFinite(target.baseline)) out.push(`target baseline must be a number: ${label} is ${commas(real)}`);
995
+ else if (target.baseline !== real) out.push(`target baseline ${commas(target.baseline)} does not match the real number: ${label} is ${commas(real)}`);
996
+ if (!Number.isFinite(target.goal)) {
997
+ out.push('target goal must be a number');
998
+ } else if (metric === 'installs') {
999
+ if (known.best !== null && !(target.goal > known.best)) out.push(`target goal ${commas(target.goal)} does not beat our best installs week of the last ${bestSpan(known.weeks)}, ${commas(known.best)}; the goal must be above ${commas(known.best)}`);
1000
+ else if (known.best === null && !(target.goal > real)) out.push(`target goal ${commas(target.goal)} must be above the baseline ${commas(real)}`);
1001
+ } else if (metric === 'queue_landed') {
1002
+ if (known.real === 0) out.push(known.waiting === 0 ? 'nothing is waiting on review, so there is no queue to land; pick another metric' : `none of the ${commas(known.waiting)} waiting jobs has real work behind it, so none can land; pick another metric`);
1003
+ else if (target.goal < known.least) out.push(`target goal ${commas(target.goal)} does not land half the waiting jobs with real work: ${commas(known.real)} have real work, so the goal must be at least ${commas(known.least)}`);
1004
+ else if (target.goal > known.real) out.push(`target goal ${commas(target.goal)} is more than the ${commas(known.real)} waiting jobs with real work`);
1005
+ }
1006
+ return out;
1007
+ }
1008
+
1009
+ function validateBet(bet, { bundle, today, previousTitle = null, avoidTitles = [], allowTeamMetrics = false } = {}) {
1010
+ const reasons = [];
1011
+ // format-only reasons: a field too long or with too many sentences. Only
1012
+ // these can be fixed by a trim turn that changes nothing else.
1013
+ const format = new Set();
1014
+ const fmt = (r) => { reasons.push(r); format.add(r); };
1015
+ if (!bet || typeof bet !== 'object' || Array.isArray(bet)) return { ok: false, reasons: ['the answer was not one JSON object'], formatOnly: false };
1016
+ const str = k => (typeof bet[k] === 'string' ? bet[k].trim() : '');
1017
+ for (const k of ['title', 'why_now', 'expect', 'metric_source', 'cost']) {
1018
+ if (!str(k)) reasons.push(`${k} is missing`);
1019
+ }
1020
+ if (str('title')) {
1021
+ const p = titleProblem(str('title'));
1022
+ if (p && (p === 'the title is longer than 140 characters' || p === 'the title is more than one sentence')) fmt(p);
1023
+ else if (p) reasons.push(p);
1024
+ if (previousTitle && normTitle(str('title')) === normTitle(previousTitle)) reasons.push('the title repeats the previous bet');
1025
+ else {
1026
+ const close = [previousTitle, ...avoidTitles].filter(Boolean).find(t => nearDuplicate(str('title'), t));
1027
+ if (close) reasons.push(`the title is too close to a bet already shown or waiting: "${clip(close, 80)}"`);
1028
+ }
1029
+ }
1030
+ if (str('why_now') && bundle && !citesBundle(str('why_now'), bundle)) reasons.push('why_now does not quote a fact from the sources');
1031
+ if (str('why_now').length > WHY_MAX) fmt(`why_now is ${str('why_now').length} characters, over ${WHY_MAX}`);
1032
+ if (sentenceCount(str('why_now')) > 2) fmt('why_now is more than two sentences');
1033
+ if (str('expect') && !(/\d/.test(str('expect')) && /[a-z]{3,}/i.test(str('expect')))) reasons.push('expect needs a number and a metric');
1034
+ if (str('expect').length > EXPECT_MAX) fmt(`expect is ${str('expect').length} characters, over ${EXPECT_MAX}`);
1035
+ if (sentenceCount(str('expect')) > 1) fmt('expect is more than one sentence');
1036
+ // the model picks a number of days; the date is ours, so a time zone or a
1037
+ // clock change can never push it out of the window. Out of range is a
1038
+ // reject with the reason, never a clamp.
1039
+ if (bet.check_in_days === undefined || bet.check_in_days === null) reasons.push('check_in_days is missing');
1040
+ else if (!Number.isInteger(bet.check_in_days) || bet.check_in_days < MIN_CHECK_DAYS || bet.check_in_days > CHECK_WINDOW_DAYS) {
1041
+ reasons.push(`check_in_days must be a whole number from ${MIN_CHECK_DAYS} to ${CHECK_WINDOW_DAYS}, got ${JSON.stringify(bet.check_in_days)}`);
1042
+ }
1043
+ if (bundle) reasons.push(...targetProblems(bet.target, bundle, { allowTeamMetrics }));
1044
+ if (str('metric_source') && !METRIC_SOURCES.some(k => str('metric_source').toLowerCase().includes(k))) reasons.push('metric_source is not a source we can read');
1045
+ if (str('cost').length > 200) fmt('cost is longer than 200 characters');
1046
+ const vp = verifyProblem(bet.verify);
1047
+ if (vp) reasons.push(vp);
1048
+ const team = bundle && bundle.sources && bundle.sources.team ? new Set(bundle.sources.team.members.map(m => m.name)) : null;
1049
+ const steps = Array.isArray(bet.steps) ? bet.steps : null;
1050
+ if (!steps) reasons.push('steps is missing');
1051
+ else if (steps.length < MIN_STEPS || steps.length > MAX_STEPS) reasons.push(`steps must have ${MIN_STEPS} to ${MAX_STEPS} items`);
1052
+ else {
1053
+ steps.forEach((s, i) => {
1054
+ const n = i + 1;
1055
+ if (!s || typeof s.text !== 'string' || !s.text.trim()) reasons.push(`step ${n} has no text`);
1056
+ else {
1057
+ const problem = stepProblem(s.text);
1058
+ if (problem && problem.startsWith('is ')) fmt(`step ${n} ${problem}`);
1059
+ else if (problem) reasons.push(`step ${n} ${problem}`);
1060
+ }
1061
+ if (s && s.detail != null && typeof s.detail === 'string' && s.detail.length > DETAIL_MAX) fmt(`step ${n} detail must be text under ${DETAIL_MAX} characters`);
1062
+ else if (s && s.detail != null && typeof s.detail !== 'string') reasons.push(`step ${n} detail must be text under ${DETAIL_MAX} characters`);
1063
+ if (!s || (s.who !== 'team' && s.who !== 'you')) reasons.push(`step ${n} who must be "team" or "you"`);
1064
+ if (!s || typeof s.public !== 'boolean') reasons.push(`step ${n} public must be true or false`);
1065
+ if (s && s.who === 'team' && (s.public === true || ownerOnlyAction(s.text))) reasons.push(`step ${n} reaches the outside world or cannot be undone, so it must be who "you"`);
1066
+ if (team && s && s.who === 'team') {
1067
+ if (typeof s.member !== 'string' || !s.member.trim()) reasons.push(`step ${n} has no member; name the team member who does it`);
1068
+ else if (!team.has(s.member.trim())) reasons.push(`step ${n} member "${clip(s.member, 40)}" is not on the team; use a name from the team list`);
1069
+ }
1070
+ });
1071
+ if (!steps.some(s => s && s.who === 'team')) reasons.push('no step is for the team');
1072
+ }
1073
+ return { ok: reasons.length === 0, reasons, formatOnly: reasons.length > 0 && reasons.every(r => format.has(r)) };
1074
+ }
1075
+
1076
+ // The owner's local calendar date n days after today (YYYY-MM-DD), counted
1077
+ // in calendar days so a clock change never shifts it.
1078
+ function addDays(today, n) {
1079
+ const [y, m, d] = String(today).split('-').map(Number);
1080
+ return dayKey(new Date(y, m - 1, d + n, 12, 0, 0).getTime());
1081
+ }
1082
+
1083
+ // "goal 2,400, best week in 2 months 2,319" or "goal 3 of the 6 jobs with work landed"
1084
+ function goalLine(target, known) {
1085
+ if (target.metric === 'queue_landed') return `goal ${commas(target.goal)} of the ${commas(target.job_ids.length)} jobs with work landed`;
1086
+ if (known && known.best !== null) return `goal ${commas(target.goal)}, best week in ${bestSpan(known.weeks)} ${commas(known.best)}`;
1087
+ return `goal ${commas(target.goal)}, ${commas(target.baseline)} last week`;
1088
+ }
1089
+
1090
+ function cleanBet(bet, today, bundle) {
1091
+ const target = { metric: bet.target.metric, baseline: bet.target.baseline, goal: bet.target.goal };
1092
+ // the jobs waiting right now, kept with the bet: only these can count
1093
+ if (target.metric === 'queue_landed') target.job_ids = [...bundleMetrics(bundle).queue_landed.ids];
1094
+ return {
1095
+ title: bet.title.trim(),
1096
+ why_now: bet.why_now.trim(),
1097
+ expect: bet.expect.trim(),
1098
+ target,
1099
+ // the one short line under expect on the card
1100
+ goal_line: goalLine(target, bundleMetrics(bundle)[target.metric]),
1101
+ check_in_days: bet.check_in_days,
1102
+ check_date: addDays(today, bet.check_in_days),
1103
+ metric_source: bet.metric_source.trim(),
1104
+ cost: bet.cost.trim(),
1105
+ verify: String(bet.verify).trim(),
1106
+ steps: bet.steps.map(s => ({ text: s.text.trim(), ...(s.detail && s.detail.trim() ? { detail: s.detail.trim() } : {}), who: s.who, ...(s.who === 'team' && typeof s.member === 'string' && s.member.trim() ? { member: s.member.trim() } : {}), public: s.public })),
1107
+ };
1108
+ }
1109
+
1110
+ // ---- one generation ----------------------------------------------------------------------
1111
+
1112
+ // Reads the sources, asks the model, checks the answer with plain rules, then
1113
+ // asks a skeptic model whether it makes sense; either can turn it down, and
1114
+ // there is one retry with the reasons. Resolves { ok, bet, bundle } or
1115
+ // { ok: false, reason }.
1116
+ // Every model attempt, writer and skeptic (the kind field), is kept (raw
1117
+ // answer, parse result, reasons, timing), so
1118
+ // a failed live run can be read back afterwards.
1119
+ function attemptsPath(stateDir = defaultStateDir()) {
1120
+ return path.join(stateDir, 'gm-bet-attempts.jsonl');
1121
+ }
1122
+
1123
+ function saveAttempt(file, row) {
1124
+ if (!file) return;
1125
+ try {
1126
+ appendLedger(file, row);
1127
+ } catch {
1128
+ // the attempts log is a receipt, never a reason to fail the bet
1129
+ }
1130
+ }
1131
+
1132
+ function wordCount(text) {
1133
+ return (String(text || '').match(/[A-Za-z0-9][A-Za-z0-9'-]*/g) || []).length;
1134
+ }
1135
+
1136
+ // ---- the skeptic: a second model judges whether the bet makes sense ----------------
1137
+
1138
+ // The writer's code checks are binary (a number, a date, a step that posts).
1139
+ // Whether the move actually moves its number, what has to be true first, and
1140
+ // whether a sharp founder would laugh at it is judgment, so a separate model
1141
+ // call owns it. It gets the bet and the same facts, never the writer's prompt.
1142
+ function buildSkepticPrompt(bet, bundle, { today } = {}) {
1143
+ return [
1144
+ '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.',
1145
+ '',
1146
+ 'Answer with strict JSON only, no prose and no code fence, in this shape:',
1147
+ '{"verdict": "pass", "missing_first": "...", "moves_metric": true, "reason": "..."}',
1148
+ '',
1149
+ 'Check these against the facts below:',
1150
+ '- moves_metric: if every step is done, would it actually move the target number (target.metric: installs are read over the 7 days that end on the check date; queue_landed counts only the waiting jobs with real work when the bet was made (target.job_ids; placeholders with no check are left out) that land, accepted or merged, by the check date, and a closed, dropped, or parked job counts zero) from the baseline to the goal? a post does not move installs if the thing it shows is not installable yet, and work nobody outside sees does not move installs at all.',
1151
+ '- missing_first: what must be true first and is not, and is not already a step: an unpublished feature (see the release fact), a page or command that does not exist, no audience that would see it. write "nothing" when nothing is missing.',
1152
+ '- would a sharp founder reading this find it obviously wrong, padded, or a waste of the week?',
1153
+ '- is the deliverable internal plumbing (a release, a review queue, merging or unsticking jobs, test speed, fixing a count or a dashboard)? that is the team\'s job and never an owner bet, so fail it. plumbing as one step inside a business move is fine.',
1154
+ 'verdict is "pass" only when moves_metric is true, nothing is missing that the steps do not cover, it is not obviously wrong, and it is not plumbing. otherwise "fail".',
1155
+ '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.',
1156
+ '',
1157
+ `Today is ${today}.`,
1158
+ '',
1159
+ '## the bet',
1160
+ JSON.stringify(bet, null, 2),
1161
+ '',
1162
+ bundleText(bundle),
1163
+ ].join('\n');
1164
+ }
1165
+
1166
+ // Shape only: a verdict with the four fields. No reading of the reason.
1167
+ function parseVerdict(text) {
1168
+ const parsed = parseBet(text);
1169
+ if (parsed.error) return { error: `the skeptic's answer: ${parsed.error}` };
1170
+ const v = parsed.bet;
1171
+ if (!v || (v.verdict !== 'pass' && v.verdict !== 'fail')) return { error: 'the skeptic gave no pass or fail verdict' };
1172
+ if (typeof v.moves_metric !== 'boolean') return { error: 'the skeptic did not say whether it moves the number' };
1173
+ if (typeof v.reason !== 'string' || !v.reason.trim()) return { error: 'the skeptic gave no reason' };
1174
+ return {
1175
+ verdict: {
1176
+ verdict: v.verdict,
1177
+ moves_metric: v.moves_metric,
1178
+ missing_first: typeof v.missing_first === 'string' ? clip(v.missing_first, 240) : '',
1179
+ reason: clip(v.reason, 400),
1180
+ },
1181
+ };
1182
+ }
1183
+
1184
+ function skepticCommand() {
1185
+ return { bin: 'claude', args: p => claudeArgs(p, SKEPTIC_MODEL) };
1186
+ }
1187
+
1188
+ // A pass needs both: the verdict and the skeptic's own yes on moving the number.
1189
+ function skepticPassed(v) {
1190
+ return v.verdict === 'pass' && v.moves_metric === true;
1191
+ }
1192
+
1193
+ // What the skeptic said must be true first, or null when it said nothing is.
1194
+ function blockerOf(v) {
1195
+ const m = String((v && v.missing_first) || '').trim();
1196
+ return m && !/^(nothing|none|n\/a)\.?$/i.test(m) ? m : null;
1197
+ }
1198
+
1199
+ function skepticReason(v) {
1200
+ const missing = blockerOf(v) ? ` missing first: ${v.missing_first.replace(/[.\s]+$/, '')}.` : '';
1201
+ const moves = v.moves_metric ? '' : ' it would not move the target number.';
1202
+ return `a skeptic turned it down: ${v.reason.replace(/[.\s]+$/, '')}.${moves}${missing} if the missing piece is something we can do, add it as a step (a release, or anything public, is a step for "you")`;
1203
+ }
1204
+
1205
+ // A free repair turn for an answer that broke only length or sentence limits:
1206
+ // the reasons and the last answer go back, and only those fields may change.
1207
+ // The text is never shortened in code.
1208
+ function buildTrimPrompt(previousAnswer, reasons) {
1209
+ return [
1210
+ 'Your last answer below broke only these length or sentence limits:',
1211
+ ...reasons.map(r => `- ${r}`),
1212
+ '',
1213
+ 'Shorten only those fields so they fit. Change nothing else: the same idea, the same target, the same check_in_days, the same steps in the same order with the same who and public. Keep the plain words, no em dashes.',
1214
+ 'Answer with the whole JSON object again, strict JSON only, no prose and no code fence.',
1215
+ '',
1216
+ 'Your last answer:',
1217
+ clip(previousAnswer, 4000),
1218
+ ].join('\n');
1219
+ }
1220
+
1221
+ // What a trim may not touch. Binary: the same values, or not.
1222
+ function trimChangedMore(before, after) {
1223
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
1224
+ if (!after || typeof after !== 'object') return true;
1225
+ if (!same(before.target, after.target) || before.check_in_days !== after.check_in_days) return true;
1226
+ const a = Array.isArray(before.steps) ? before.steps : [];
1227
+ const b = Array.isArray(after.steps) ? after.steps : [];
1228
+ return a.length !== b.length || a.some((s, i) => !b[i] || s.who !== b[i].who || s.public !== b[i].public);
1229
+ }
1230
+
1231
+ async function generateBet({ workspaceRoot, today, previousTitle = null, avoidTitles = [], emit = () => {}, deps = {}, ledgerFile, attemptsFile, allowTeamMetrics = false } = {}) {
1232
+ const bundle = await readBundle(workspaceRoot, { deps, emit, ledgerFile, today });
1233
+ const run = deps.runModel || runModel;
1234
+ // tests pass runSkeptic; a stubbed runModel without one is also used for the
1235
+ // skeptic, so a test can never reach the real model by accident
1236
+ const judge = deps.runSkeptic || deps.runModel || ((prompt, opts) => runModel(prompt, { ...opts, command: skepticCommand() }));
1237
+ const clock = deps.now || Date.now;
1238
+ // How each try is asked. first: a fresh bet. patch: fix the last answer
1239
+ // against the reasons (code rejects, or a skeptic fail that names no
1240
+ // blocker). blocker: the skeptic named what must be true first, so the
1241
+ // next bet is clearing that blocker, a new move with its own target.
1242
+ // trim: inside a try, a free turn that only shortens fields over a limit.
1243
+ let strategy = 'first';
1244
+ let reason = null;
1245
+ let previousAnswer = null;
1246
+ let blocker = null;
1247
+ const blocked = [];
1248
+ 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' };
1249
+
1250
+ // one model call and its code check, saved to the attempts log
1251
+ async function ask(prompt, attempt, how, avoid) {
1252
+ let lastSaid = 0;
1253
+ const started = clock();
1254
+ const answer = await run(prompt, {
1255
+ cwd: workspaceRoot,
1256
+ onProgress: ({ text = '' }) => {
1257
+ const at = clock();
1258
+ if (at - lastSaid < 1200) return;
1259
+ lastSaid = at;
1260
+ const words = wordCount(text);
1261
+ emit({ kind: 'thinking', line: words ? `thinking... ${commas(words)} words written` : 'thinking...', n: words });
1262
+ },
1263
+ });
1264
+ const base = { at: new Date(clock()).toISOString(), workspace: workspaceRoot, today, attempt, strategy: how, ...(how === 'blocker' ? { blocker: blocker.missing_first } : {}), previous_title: previousTitle, avoid_titles: avoid.length, ms: clock() - started };
1265
+ if (!answer.ok) {
1266
+ saveAttempt(attemptsFile, { ...base, kind: 'writer', model_ok: false, raw: null, parse: null, passed: false, reasons: [answer.reason] });
1267
+ return { failed: answer.reason, base };
1268
+ }
1269
+ const parsed = parseBet(answer.text);
1270
+ const check = parsed.error ? { ok: false, reasons: [parsed.error], formatOnly: false } : validateBet(parsed.bet, { bundle, today, previousTitle, avoidTitles: avoid, allowTeamMetrics });
1271
+ return { parsed, check, base, raw: answer.text };
1272
+ }
1273
+ const save = (r, extra = {}) => saveAttempt(attemptsFile, { ...r.base, kind: 'writer', model_ok: true, raw: String(r.raw).slice(0, 20000), parse: r.parsed.error || 'ok', passed: r.check.ok, reasons: r.check.reasons, ...extra });
1274
+
1275
+ for (let attempt = 1; attempt <= WRITER_ATTEMPTS; attempt += 1) {
1276
+ emit({ kind: 'model', line: MODEL_LINES[strategy] });
1277
+ // a blocked idea is not shown again as a patch
1278
+ const avoid = [...avoidTitles, ...blocked];
1279
+ const prompt = strategy === 'blocker'
1280
+ ? buildPrompt(bundle, { today, previousTitle, blocker, avoidTitles: avoid })
1281
+ : buildPrompt(bundle, { today, previousTitle, reason, previousAnswer, avoidTitles: avoid });
1282
+ let r = await ask(prompt, attempt, strategy, avoid);
1283
+ if (r.failed) return { ok: false, reason: r.failed, bundle };
1284
+ save(r);
1285
+
1286
+ // only length or sentence limits broken: one free trim turn, same try
1287
+ if (!r.check.ok && r.check.formatOnly) {
1288
+ emit({ kind: 'model', line: MODEL_LINES.trim });
1289
+ const original = r.parsed.bet;
1290
+ const trimmed = await ask(buildTrimPrompt(JSON.stringify(original), r.check.reasons), attempt, 'trim', avoid);
1291
+ if (trimmed.failed) return { ok: false, reason: trimmed.failed, bundle };
1292
+ if (!trimmed.parsed.error && trimChangedMore(original, trimmed.parsed.bet)) {
1293
+ trimmed.check = { ok: false, reasons: ['the trim changed more than the fields over a limit'], formatOnly: false };
1294
+ }
1295
+ save(trimmed);
1296
+ r = trimmed;
1297
+ }
1298
+
1299
+ previousAnswer = r.parsed.error ? null : JSON.stringify(r.parsed.bet);
1300
+ if (!r.check.ok) {
1301
+ strategy = 'patch';
1302
+ reason = r.check.reasons.slice(0, 4).join('; ');
1303
+ emit({ kind: 'reject', line: `${ORDINALS[attempt - 1]} answer turned down: ${reason}` });
1304
+ continue;
1305
+ }
1306
+
1307
+ // the code checks passed; now a second model judges whether it makes sense
1308
+ const bet = cleanBet(r.parsed.bet, today, bundle);
1309
+ emit({ kind: 'skeptic', line: 'a second model is checking that it makes sense' });
1310
+ const judgedAt = clock();
1311
+ const said = await judge(buildSkepticPrompt(bet, bundle, { today }), { cwd: workspaceRoot, onProgress: () => {} });
1312
+ const skepticBase = { ...r.base, kind: 'skeptic', at: new Date(clock()).toISOString(), ms: clock() - judgedAt, title: bet.title };
1313
+ if (!said.ok) {
1314
+ saveAttempt(attemptsFile, { ...skepticBase, model_ok: false, raw: null, parse: null, passed: false, reasons: [said.reason] });
1315
+ return { ok: false, reason: `the skeptic could not check the bet: ${said.reason}`, bundle };
1316
+ }
1317
+ const judged = parseVerdict(said.text);
1318
+ const passed = !judged.error && skepticPassed(judged.verdict);
1319
+ saveAttempt(attemptsFile, {
1320
+ ...skepticBase,
1321
+ model_ok: true,
1322
+ raw: String(said.text).slice(0, 20000),
1323
+ parse: judged.error || 'ok',
1324
+ verdict: judged.verdict || null,
1325
+ passed,
1326
+ reasons: judged.error ? [judged.error] : (passed ? [] : [judged.verdict.reason]),
1327
+ });
1328
+ if (judged.error) return { ok: false, reason: `the skeptic could not check the bet: ${judged.error}`, bundle };
1329
+ if (passed) {
1330
+ emit({ kind: 'check', line: 'checked the bet: every rule passed and the skeptic agreed' });
1331
+ return { ok: true, bet: { ...bet, skeptic: judged.verdict }, bundle, attempts: attempt };
1332
+ }
1333
+ reason = skepticReason(judged.verdict);
1334
+ const missing = blockerOf(judged.verdict);
1335
+ if (missing) {
1336
+ strategy = 'blocker';
1337
+ blocker = { title: bet.title, missing_first: missing };
1338
+ blocked.push(bet.title);
1339
+ } else {
1340
+ strategy = 'patch';
1341
+ }
1342
+ emit({ kind: 'reject', line: `${ORDINALS[attempt - 1]} answer turned down: ${reason}` });
1343
+ }
1344
+ return { ok: false, reason: `the model's answer was turned down ${WRITER_ATTEMPTS} times (${reason})`, bundle };
1345
+ }
1346
+
1347
+ // ---- do it: the mission -------------------------------------------------------------------
1348
+
1349
+ // Which member owns the work, by what the bet asks for. Code-shaped bets go
1350
+ // to the builder, launch-shaped bets to the launcher, anything else to the
1351
+ // mission lead. Only members that exist in this workspace are picked.
1352
+ function pickOwner(bet, workspaceRoot) {
1353
+ const text = `${bet.title} ${bet.steps.map(s => s.text).join(' ')}`.toLowerCase();
1354
+ const exists = name => fs.existsSync(path.join(workspaceRoot, 'atris', 'team', name, 'MEMBER.md'));
1355
+ const launch = /\b(post|posts|thread|tweet|launch|announce|newsletter|video|landing page|blog|changelog|demo|email|outreach|pricing page)\b/;
1356
+ const code = /\b(command|cli|endpoint|api|test|script|flag|feature|bug|refactor|build|code|install|onboarding|setup|integration)\b/;
1357
+ const title = bet.title.toLowerCase();
1358
+ const asCode = { owner: 'builder', lane: 'code' };
1359
+ const asLaunch = { owner: 'launcher', lane: 'launch' };
1360
+ // the lane: the title decides first, then the steps
1361
+ let pick = { owner: 'mission-lead', lane: 'workspace' };
1362
+ const lanes = [[code, asCode], [launch, asLaunch]];
1363
+ const hit = lanes.find(([re, p]) => re.test(title) && exists(p.owner)) || lanes.find(([re, p]) => re.test(text) && exists(p.owner));
1364
+ if (hit) pick = hit[1];
1365
+ // the owner: the member of the first team step, when that member is real here
1366
+ const first = bet.steps.find(st => st.who === 'team' && st.member && exists(st.member));
1367
+ return first ? { owner: first.member, lane: pick.lane } : pick;
1368
+ }
1369
+
1370
+ // The members after the owner, in step order, each named once.
1371
+ function handoffChain(bet, owner) {
1372
+ const chain = [];
1373
+ let last = owner;
1374
+ for (const st of bet.steps) {
1375
+ if (st.who !== 'team' || !st.member || st.member === last) continue;
1376
+ chain.push({ from: last, to: st.member, step: st.text });
1377
+ last = st.member;
1378
+ }
1379
+ return chain;
1380
+ }
1381
+
1382
+ // a handoff summary starts with this tag, so the desk can tell who has the job now
1383
+ function handoffTag(betId) {
1384
+ return `[${betId}]`;
1385
+ }
1386
+
1387
+ const YES_RULE = 'rule: a step marked "your yes" is locked until the owner says yes: publishing, pushing tags, releases, posting, sending, and merging to master are held by the guard on your PATH. when you reach that step, run the command once; it is held, and the owner gets a yes card. do the other steps, wait for the ping from gm-phone that says yes, then run it again. never work around the guard.';
1388
+
1389
+ // Missions have one owner. A step for another member is passed with the real
1390
+ // member handoff (atris member handoff <from> <to> "..."), which records the
1391
+ // baton and queues that member's next push; the objective names each one.
1392
+ function missionObjective(bet, { owner = null, betId = null } = {}) {
1393
+ const steps = bet.steps.map((s, i) => `${i + 1}. [${s.who === 'you' ? 'your yes' : (s.member || 'team')}] ${s.text}${s.detail ? ` (${s.detail})` : ''}`);
1394
+ const chain = owner ? handoffChain(bet, owner) : [];
1395
+ const handoffs = chain.length
1396
+ ? `handoffs: you are ${owner}; do your steps, then pass each step that belongs to another member with ${chain.map(h => `atris member handoff ${h.from} ${h.to} "${betId ? `${handoffTag(betId)} ` : ''}${h.step}"`).join(', then ')}.`
1397
+ : null;
1398
+ return [
1399
+ `${bet.title.replace(/[.\s]+$/, '')}.`,
1400
+ `why now: ${bet.why_now}`,
1401
+ `we expect ${bet.expect}, read from ${bet.metric_source} on ${bet.check_date}.`,
1402
+ `steps: ${steps.join(' ')}`,
1403
+ handoffs,
1404
+ bet.verify ? `done means this check passes: ${bet.verify}` : null,
1405
+ YES_RULE,
1406
+ ].filter(Boolean).join(' ');
1407
+ }
1408
+
1409
+ const MISSION_VERIFY = 'git diff --check';
1410
+ const MISSION_STOP = 'every team step is done with proof, and every your-yes step was asked, not done';
1411
+
1412
+ // The bet's own verify is the check; a bet made before verify existed keeps
1413
+ // the old formatting check.
1414
+ function missionStartArgs(bet, { owner, lane }, betId = null) {
1415
+ // the owner steps are sealed into the mission record at start
1416
+ const seal = require('./yes-lock').sealFor(bet.steps);
1417
+ return ['mission', 'start', missionObjective(bet, { owner, betId }), '--owner', owner, '--runner', 'claude', '--lane', lane, '--verify', bet.verify || MISSION_VERIFY, '--stop', MISSION_STOP, '--budget', 'long', ...(seal.length ? ['--yes-lock', JSON.stringify(seal)] : []), '--json'];
1418
+ }
1419
+
1420
+ function missionRunArgs(missionId) {
1421
+ return ['mission', 'run', missionId, '--detach', '--json'];
1422
+ }
1423
+
1424
+ function yesPingArgs(missionId, step) {
1425
+ return ['mission', 'ping', missionId, `the owner said yes to this step: ${step.text}. you may do it now.`, '--from', 'gm-phone'];
1426
+ }
1427
+
1428
+ function parseJsonOut(stdout) {
1429
+ const s = String(stdout || '').trim();
1430
+ const start = s.indexOf('{');
1431
+ if (start < 0) return null;
1432
+ try {
1433
+ return JSON.parse(s.slice(start));
1434
+ } catch {
1435
+ const id = /"mission_id"\s*:\s*"([^"]+)"/.exec(s) || /"id"\s*:\s*"(mission-[^"]+)"/.exec(s);
1436
+ return id ? { mission_id: id[1] } : null;
1437
+ }
1438
+ }
1439
+
1440
+ // Runs one atris command and captures its output (the mission runner tests stub).
1441
+ function makeCliRunner({ cliPath, cwd, env = process.env, timeoutMs = 60000 }) {
1442
+ const { execFile } = require('child_process');
1443
+ return args => new Promise((resolve) => {
1444
+ execFile(process.execPath, [cliPath, ...args], { cwd, env: { ...env, ATRIS_SKIP_UPDATE_CHECK: '1' }, timeout: timeoutMs, maxBuffer: OUTPUT_MAX }, (err, stdout, stderr) => {
1445
+ const code = err ? (Number.isInteger(err.code) ? err.code : 1) : 0;
1446
+ resolve({ code, stdout: String(stdout || ''), stderr: String(stderr || '') });
1447
+ });
1448
+ });
1449
+ }
1450
+
1451
+ // ---- jobs in flight: what the started bets are doing now ---------------------------
1452
+
1453
+ const JOB_POLL_MS = 25000;
1454
+ const OPEN_MISSION = new Set(['planning', 'running', 'ready', 'paused', 'blocked']);
1455
+
1456
+ // Every mission in this workspace, read-only, through the same reader the
1457
+ // mission command uses (it only reads .atris/state/missions.jsonl).
1458
+ function defaultReadMissions(workspaceRoot) {
1459
+ return require(path.join(__dirname, '..', 'commands', 'mission.js')).loadMissionMap(workspaceRoot);
1460
+ }
1461
+
1462
+ // A mission's last receipt, read-only. Null when there is none.
1463
+ function defaultReadReceipt(mission, workspaceRoot) {
1464
+ if (!mission || !mission.receipt_path) return null;
1465
+ const file = path.isAbsolute(mission.receipt_path) ? mission.receipt_path : path.join(mission.worktree_root || workspaceRoot, mission.receipt_path);
1466
+ try {
1467
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
1468
+ } catch {
1469
+ return null;
1470
+ }
1471
+ }
1472
+
1473
+ function ageLabel(ms) {
1474
+ const m = Math.max(0, Math.floor(ms / 60000));
1475
+ if (m < 1) return 'just started';
1476
+ if (m < 60) return `${m}m`;
1477
+ const h = Math.floor(m / 60);
1478
+ if (h < 24) return `${h}h ${m % 60}m`;
1479
+ return `${Math.floor(h / 24)}d ${h % 24}h`;
1480
+ }
1481
+
1482
+ // One started bet and its mission, as the deck sees it:
1483
+ // working the mission is open and asks nothing of the owner
1484
+ // yes the mission has open asks for the owner (one card per ask)
1485
+ // done the mission finished (complete) or halted (stopped)
1486
+ // gone no such mission here (it is not counted as working)
1487
+ // done carries what changed, the proof, and the check, only as the receipt
1488
+ // says them; with no receipt it says so.
1489
+ function jobState(row, mission, receipt, nowMs) {
1490
+ const title = (row.bet && row.bet.title) || row.id;
1491
+ if (!mission) return { kind: 'gone', bet_id: row.id, title };
1492
+ const owner = mission.owner || row.owner || 'the team';
1493
+ const since = Date.parse(mission.created_at || '') || null;
1494
+ const asks = (Array.isArray(mission.human_asks) ? mission.human_asks : [])
1495
+ .map((ask, index) => ({ index, text: String(typeof ask === 'string' ? ask : (ask && ask.text) || '').trim(), answered: Boolean(ask && typeof ask === 'object' && ask.answered_at) }))
1496
+ .filter(a => a.text && !a.answered);
1497
+ const status = String(mission.status || '');
1498
+ if (status === 'complete' || status === 'stopped') {
1499
+ const result = receipt && receipt.result && typeof receipt.result === 'object' ? receipt.result : null;
1500
+ const landing = result && result.landing && typeof result.landing === 'object' ? result.landing : null;
1501
+ const checked = result && typeof result.passed === 'boolean' ? (result.passed ? 'passed' : 'failed') : 'none';
1502
+ const stopped = status === 'stopped';
1503
+ const changed = landing && landing.changed ? clip(landing.changed, 160) : null;
1504
+ return {
1505
+ kind: 'done',
1506
+ bet_id: row.id,
1507
+ mission_id: mission.id,
1508
+ title,
1509
+ owner,
1510
+ halted: stopped,
1511
+ stop_reason: stopped && mission.stop_reason ? clip(mission.stop_reason, 140) : null,
1512
+ changed,
1513
+ proof: receipt ? (mission.receipt_path || null) : null,
1514
+ checked,
1515
+ // a check that failed, or a halt nothing proved, goes back to the team on request
1516
+ failed: checked === 'failed' || (stopped && checked !== 'passed'),
1517
+ };
1518
+ }
1519
+ const base = { bet_id: row.id, mission_id: mission.id, title, owner, since, running_for: since ? ageLabel(nowMs - since) : null, status };
1520
+ if (asks.length) return { ...base, kind: 'yes', asks };
1521
+ return { ...base, kind: OPEN_MISSION.has(status) ? 'working' : 'gone' };
1522
+ }
1523
+
1524
+ // Who reviews finished work: the lead of the roster's review job (atris
1525
+ // engine roster), read from saved state.
1526
+ function defaultReviewer(workspaceRoot) {
1527
+ const team = require('./engine-registry').resolveJobTeam('review', workspaceRoot);
1528
+ const lead = team && team.lead;
1529
+ return (lead && (lead.engine || lead.name)) || (typeof lead === 'string' ? lead : null) || null;
1530
+ }
1531
+
1532
+ const REVIEW_TRIES = 3;
1533
+ const REVIEW_TIMEOUT_S = 900;
1534
+
1535
+ // The reviewer reads the repo (engine ask is read-only) and says pass or fail.
1536
+ function reviewPrompt(bet, job) {
1537
+ const steps = (bet && Array.isArray(bet.steps) ? bet.steps : []).map((st, i) => `${i + 1}. [${st.who === 'you' ? 'owner' : (st.member || 'team')}] ${st.text}`).join(' ');
1538
+ return [
1539
+ 'You are reviewing finished work before it counts as done. You can read this repo; you cannot change anything.',
1540
+ `The bet: ${(bet && bet.title) || job.title}.`,
1541
+ steps ? `Its steps: ${steps}` : null,
1542
+ bet && bet.verify ? `The check that had to pass: ${bet.verify}` : null,
1543
+ `What the worker says changed: ${job.changed || 'it left no receipt'}.`,
1544
+ `The check result: ${job.checked === 'passed' ? 'passed' : (job.checked === 'failed' ? 'failed' : 'no check ran')}.`,
1545
+ 'Look at the repo yourself. Is the deliverable really there, and does it do what the bet says? Owner-only steps (publish, post, release) are not expected to be done.',
1546
+ 'Answer with strict JSON only, no prose and no code fence: {"verdict": "pass", "reason": "one or two plain sentences"} or {"verdict": "fail", "reason": "..."}',
1547
+ ].filter(Boolean).join('\n');
1548
+ }
1549
+
1550
+ // The reviewer's answer out of the engine ask receipt; null when there is none.
1551
+ function reviewVerdict(stdout) {
1552
+ const receipt = parseJsonOut(stdout);
1553
+ const answer = receipt && Array.isArray(receipt.answers) ? receipt.answers.find(a => a && a.ok && a.stdout) : null;
1554
+ if (!answer) return null;
1555
+ const parsed = parseBet(answer.stdout);
1556
+ const v = parsed.bet;
1557
+ if (!v || (v.verdict !== 'pass' && v.verdict !== 'fail') || typeof v.reason !== 'string') return null;
1558
+ return { verdict: v.verdict, reason: clip(v.reason, 240) };
1559
+ }
1560
+
1561
+ // The member who has a job right now: the newest handoff tagged with its bet,
1562
+ // else the mission's owner.
1563
+ function defaultReadHandoffs(workspaceRoot) {
1564
+ try {
1565
+ return fs.readFileSync(path.join(workspaceRoot, '.atris', 'state', 'member-handoffs.jsonl'), 'utf8')
1566
+ .split('\n').filter(l => l.trim()).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
1567
+ } catch {
1568
+ return [];
1569
+ }
1570
+ }
1571
+
1572
+ function currentMember(betId, owner, handoffs) {
1573
+ const tag = handoffTag(betId);
1574
+ const hits = handoffs.filter(h => h && typeof h.summary === 'string' && h.summary.includes(tag) && h.to);
1575
+ return hits.length ? hits[hits.length - 1].to : owner;
1576
+ }
1577
+
1578
+ // Plain lines for a done card: what changed, the proof, the check.
1579
+ function doneLines(job) {
1580
+ const lines = [];
1581
+ if (job.halted) lines.push(job.stop_reason ? `it stopped: ${job.stop_reason}` : 'it stopped before it finished');
1582
+ lines.push(job.changed || 'it left no receipt, so there is nothing to show for what changed');
1583
+ const r = job.review;
1584
+ return {
1585
+ lines: lines.slice(0, 2),
1586
+ proof: job.proof ? `the receipt is at ${job.proof}` : 'no receipt was saved, so there is no proof yet',
1587
+ check: job.checked === 'passed' ? 'it passed its check' : (job.checked === 'failed' ? 'it did not pass its check' : 'no check ran'),
1588
+ review: !r ? 'not reviewed' : (r.verdict === 'pass' ? `${r.engine} reviewed it: pass. ${r.reason}` : (r.verdict === 'fail' ? `${r.engine} reviewed it: fail. ${r.reason}` : `the review by ${r.engine || 'the reviewer'} could not run: ${r.reason}`)),
1589
+ };
1590
+ }
1591
+
1592
+ // Why a bet made under older rules can no longer be shown, or null. Only the
1593
+ // rules that do not need today's numbers re-run here: a real check, a member
1594
+ // from the team on every team step, and no team-only metric. The skeptic
1595
+ // does not re-run, so the plumbing metric is dropped by name.
1596
+ function queuedProblem(bet, team) {
1597
+ if (!bet || typeof bet !== 'object' || !Array.isArray(bet.steps)) return 'it is not a whole bet';
1598
+ const metric = bet.target && bet.target.metric;
1599
+ if (!metric || !METRICS[metric]) return 'it has no target number we can read';
1600
+ if (TEAM_ONLY_METRICS.has(metric)) return `its target ${metric} measures team plumbing, not a business move`;
1601
+ const vp = verifyProblem(bet.verify);
1602
+ if (vp) return vp;
1603
+ if (team) {
1604
+ const names = new Set(team.map(m => m.name));
1605
+ const i = bet.steps.findIndex(st => st && st.who === 'team' && !(typeof st.member === 'string' && names.has(st.member)));
1606
+ if (i >= 0) return `step ${i + 1} names no member on the team`;
1607
+ }
1608
+ return null;
1609
+ }
1610
+
1611
+ // ---- the desk: a queue of ready bets, one shown at a time --------------------------------
1612
+
1613
+ const QUEUE_TARGET = 3;
1614
+ const REFILL_BELOW = 2;
1615
+ // one generation at a time; the cap of 3 calls in flight per session is never reached
1616
+ const MAX_IN_FLIGHT = 1;
1617
+ const SESSION_IN_FLIGHT_CAP = 3;
1618
+ const STALE_MS = 6 * 60 * 60 * 1000;
1619
+ const RECENT_TITLES = 20;
1620
+ // two failed background generations in a row stop the prefetch until the next tap
1621
+ const MAX_BACKGROUND_FAILURES = 2;
1622
+
1623
+ // deps:
1624
+ // workspaceRoot, ledgerFile, attemptsFile where
1625
+ // generate(opts) -> generateBet result (tests stub)
1626
+ // runCli(args) -> Promise<{ code, stdout, stderr }>
1627
+ // log(row) receipt row for gm-phone-actions.jsonl
1628
+ // now() clock
1629
+ // readMissions(root), readReceipt(m, root) mission state, read-only (tests stub)
1630
+ // pollMs how often to read job status (off when unset)
1631
+ function createBetDesk(deps) {
1632
+ const now = deps.now || Date.now;
1633
+ const generate = deps.generate || (opts => generateBet({ ...opts, deps: deps.generatorDeps || {}, ledgerFile: deps.ledgerFile, attemptsFile: deps.attemptsFile }));
1634
+ const log = deps.log || (() => {});
1635
+ const state = {
1636
+ status: 'idle',
1637
+ current: null,
1638
+ queue: [],
1639
+ inFlight: 0,
1640
+ maxInFlight: 0,
1641
+ gen: null,
1642
+ waiting: null,
1643
+ pendingSharper: null,
1644
+ topping: false,
1645
+ failures: 0,
1646
+ run: 0,
1647
+ error: null,
1648
+ lastFailed: null,
1649
+ mission: null,
1650
+ started: [],
1651
+ // the started bets' missions as of the last read: working ones, and open
1652
+ // asks that need the owner's yes
1653
+ jobs: { working: [], yes: [], at: 0 },
1654
+ // yes cards passed over this session, so a re-tap moves on
1655
+ seenYes: new Set(),
1656
+ // reviews running now, by bet id
1657
+ reviews: new Map(),
1658
+ };
1659
+
1660
+ const iso = () => new Date(now()).toISOString();
1661
+ const record = (id, patch) => appendLedger(deps.ledgerFile, { id, workspace: deps.workspaceRoot, at: iso(), ...patch });
1662
+ const newId = () => `bet-${dayKey(now())}-${crypto.randomBytes(4).toString('hex')}`;
1663
+
1664
+ // queued bets live in the ledger, so a restart or a reload wastes nothing
1665
+ for (const row of readLedger(deps.ledgerFile, deps.workspaceRoot)) {
1666
+ if (row.status !== 'queued' || !row.bet) continue;
1667
+ state.queue.push({ id: row.id, bet: row.bet, created_at: Date.parse(row.created_at) || 0 });
1668
+ }
1669
+
1670
+ // the team, read once: a queued bet whose members are not on it is not shown
1671
+ let team = null;
1672
+ try {
1673
+ team = (deps.readTeam || readTeam)(deps.workspaceRoot);
1674
+ } catch {
1675
+ team = null;
1676
+ }
1677
+
1678
+ // Old bets go: older than 6 hours, or made under rules that have changed
1679
+ // since. Each is marked stale with why, and the queue refills behind them.
1680
+ function dropStale() {
1681
+ const keep = [];
1682
+ for (const item of state.queue) {
1683
+ const why = now() - item.created_at > STALE_MS ? 'older than 6 hours' : queuedProblem(item.bet, team);
1684
+ if (why) {
1685
+ record(item.id, { status: 'stale', stale_reason: why });
1686
+ log({ card: 'bet', choice: 'stale', command: null, ran: 'in process', exit_code: 0, bet_id: item.id, why });
1687
+ } else {
1688
+ keep.push(item);
1689
+ }
1690
+ }
1691
+ state.queue = keep;
1692
+ }
1693
+ dropStale();
1694
+
1695
+ // every title the next bet must differ from: the one on screen, the queue,
1696
+ // the one being made, and the newest ones in the ledger
1697
+ function avoidTitles() {
1698
+ const titles = [];
1699
+ if (state.current && state.current.bet) titles.push(state.current.bet.title);
1700
+ for (const item of state.queue) titles.push(item.bet.title);
1701
+ for (const row of readLedger(deps.ledgerFile, deps.workspaceRoot).slice(-RECENT_TITLES)) if (row.bet && row.bet.title) titles.push(row.bet.title);
1702
+ return [...new Set(titles)];
1703
+ }
1704
+
1705
+ function show(item) {
1706
+ state.current = item;
1707
+ state.status = 'ready';
1708
+ state.mission = null;
1709
+ state.error = null;
1710
+ record(item.id, { status: 'proposed', shown_at: iso() });
1711
+ log({ card: 'bet', choice: 'show', command: null, ran: 'in process', exit_code: 0, bet_id: item.id });
1712
+ }
1713
+
1714
+ // approvals live beside the ledger, in ~/.atris/state, outside the workspace
1715
+ const lockDir = path.dirname(deps.ledgerFile);
1716
+
1717
+ // The your-yes lock for a job: written before it runs, so the guard holds
1718
+ // each owner step from the first tick.
1719
+ function lockYesSteps(missionId, betId, steps) {
1720
+ try {
1721
+ const yesLock = require('./yes-lock');
1722
+ const locks = yesLock.writeYesLocks(lockDir, missionId, { betId, seal: yesLock.sealFor(steps) });
1723
+ log({ card: 'bet', choice: 'lock', command: null, ran: 'in process', exit_code: 0, bet_id: betId, mission_id: missionId, locked_steps: locks.map(l => l.step) });
1724
+ } catch (err) {
1725
+ log({ card: 'bet', choice: 'lock', command: null, ran: 'in process', exit_code: 1, bet_id: betId, mission_id: missionId, error: clip(String((err && err.message) || err), 160) });
1726
+ }
1727
+ }
1728
+
1729
+ // Reads every started bet's mission, read-only. A mission that finished or
1730
+ // halted gets one ledger row with what its receipt says (the bet stays
1731
+ // "doing" so it is still scored won or lost on its check date). Open asks
1732
+ // become yes cards; open missions are the working list.
1733
+ function pollJobs() {
1734
+ let missions;
1735
+ try {
1736
+ missions = (deps.readMissions || defaultReadMissions)(deps.workspaceRoot);
1737
+ } catch {
1738
+ return state.jobs;
1739
+ }
1740
+ const get = id => (missions instanceof Map ? missions.get(id) : (missions || {})[id]) || null;
1741
+ let handoffRows = null;
1742
+ const handoffs = () => {
1743
+ if (!handoffRows) handoffRows = (deps.readHandoffs || defaultReadHandoffs)(deps.workspaceRoot);
1744
+ return handoffRows;
1745
+ };
1746
+ let reviewer;
1747
+ const reviewerName = () => {
1748
+ if (reviewer === undefined) {
1749
+ try { reviewer = (deps.reviewer || defaultReviewer)(deps.workspaceRoot); } catch { reviewer = null; }
1750
+ }
1751
+ return reviewer;
1752
+ };
1753
+ const working = [];
1754
+ const yes = [];
1755
+ for (const row of readLedger(deps.ledgerFile, deps.workspaceRoot)) {
1756
+ if (row.status !== 'doing' || !row.mission_id || row.mission_result) continue;
1757
+ const mission = get(row.mission_id);
1758
+ const finished = mission && (mission.status === 'complete' || mission.status === 'stopped');
1759
+ const receipt = finished ? (deps.readReceipt || defaultReadReceipt)(mission, deps.workspaceRoot) : null;
1760
+ const job = jobState(row, mission, receipt, now());
1761
+ // finished with nothing failed: the roster's reviewer looks before it counts as done
1762
+ if (job.kind === 'done' && !job.failed && !job.halted) {
1763
+ const engine = reviewerName();
1764
+ startReview(row, job, engine);
1765
+ working.push({ ...job, kind: 'working', owner: `${engine || 'the reviewer'} (reviewing)`, running_for: null });
1766
+ continue;
1767
+ }
1768
+ if (job.kind === 'done') {
1769
+ record(row.id, {
1770
+ mission_result: job.failed ? 'failed' : 'done',
1771
+ mission_status: mission.status,
1772
+ mission_checked: job.checked,
1773
+ mission_changed: job.changed,
1774
+ mission_proof: job.proof,
1775
+ mission_halted: job.halted,
1776
+ mission_stop_reason: job.stop_reason,
1777
+ finished_seen_at: iso(),
1778
+ });
1779
+ log({ card: 'bet', choice: 'job_finished', command: null, ran: 'in process', exit_code: job.failed ? 1 : 0, bet_id: row.id, mission_id: mission.id });
1780
+ } else if (job.kind === 'working') {
1781
+ working.push({ ...job, owner: currentMember(row.id, job.owner, handoffs()) });
1782
+ // a step the job tried and the guard held is a yes card
1783
+ for (const lock of require('./yes-lock').askedYesLocks(lockDir, mission.id)) {
1784
+ yes.push({ ...job, kind: 'yes', ask: lock.text, command: lock.held_command || null, lock_step: lock.step, ask_index: null, key: `${mission.id}:step-${lock.step}` });
1785
+ }
1786
+ } else if (job.kind === 'yes') {
1787
+ for (const ask of job.asks) yes.push({ ...job, ask_index: ask.index, ask: ask.text, key: `${job.mission_id}:${ask.index}` });
1788
+ }
1789
+ }
1790
+ state.jobs = { working, yes, at: now() };
1791
+ return state.jobs;
1792
+ }
1793
+
1794
+ // One review per finished job at a time. A pass makes it done; a fail makes
1795
+ // it failed (send back is offered); a review that cannot run is tried again
1796
+ // on the next read, and after REVIEW_TRIES it counts as failed with why.
1797
+ function startReview(row, job, engine) {
1798
+ if (state.reviews.has(row.id)) return;
1799
+ const finish = (patch) => {
1800
+ record(row.id, { ...patch, mission_status: 'complete', mission_checked: job.checked, mission_changed: job.changed, mission_proof: job.proof, mission_halted: false, mission_stop_reason: null, finished_seen_at: iso() });
1801
+ log({ card: 'bet', choice: 'job_finished', command: null, ran: 'in process', exit_code: patch.mission_result === 'done' ? 0 : 1, bet_id: row.id, mission_id: job.mission_id });
1802
+ };
1803
+ const tries = (Number(row.review_tries) || 0) + 1;
1804
+ const gaveUp = reason => (tries >= REVIEW_TRIES
1805
+ ? finish({ mission_result: 'failed', review: { engine, verdict: 'could not run', reason }, review_tries: tries })
1806
+ : record(row.id, { review_tries: tries, review_error: reason }));
1807
+ if (!engine) { gaveUp('no reviewer is set in the engine roster'); return; }
1808
+ const args = ['engine', 'ask', reviewPrompt(row.bet, job), '--engine', engine, '--timeout', String(REVIEW_TIMEOUT_S), '--json'];
1809
+ const run = (deps.reviewCli || deps.runCli)(args).then((res) => {
1810
+ log({ card: 'bet', choice: 'review', command: `atris engine ask "<review of ${row.id}>" --engine ${engine} --timeout ${REVIEW_TIMEOUT_S} --json`, exit_code: res.code, bet_id: row.id, mission_id: job.mission_id });
1811
+ const v = res.code === 0 ? reviewVerdict(res.stdout) : null;
1812
+ if (!v) return gaveUp(res.code === 0 ? 'the reviewer gave no pass or fail' : `the review exited with code ${res.code}`);
1813
+ return finish({ mission_result: v.verdict === 'pass' ? 'done' : 'failed', review: { engine, ...v }, review_tries: tries });
1814
+ }).catch(err => gaveUp(String((err && err.message) || err))).finally(() => state.reviews.delete(row.id));
1815
+ state.reviews.set(row.id, run);
1816
+ }
1817
+
1818
+ // Finished jobs not yet seen, from the ledger, so they survive a restart.
1819
+ function doneJobs() {
1820
+ return readLedger(deps.ledgerFile, deps.workspaceRoot)
1821
+ .filter(row => (row.mission_result === 'done' || row.mission_result === 'failed') && !row.done_seen_at)
1822
+ .map(row => ({
1823
+ kind: 'done',
1824
+ bet_id: row.id,
1825
+ mission_id: row.mission_id,
1826
+ title: (row.bet && row.bet.title) || row.id,
1827
+ owner: row.owner || 'the team',
1828
+ halted: Boolean(row.mission_halted),
1829
+ stop_reason: row.mission_stop_reason || null,
1830
+ changed: row.mission_changed || null,
1831
+ proof: row.mission_proof || null,
1832
+ checked: row.mission_checked || 'none',
1833
+ failed: row.mission_result === 'failed',
1834
+ review: row.review || null,
1835
+ bet: row.bet,
1836
+ }));
1837
+ }
1838
+
1839
+ // The next card that is not a bet: a yes the owner owes comes first, then
1840
+ // a finished job. Nothing pops on its own; these only come on the next tap.
1841
+ function nextJobCard() {
1842
+ const ask = state.jobs.yes.find(j => !state.seenYes.has(j.key));
1843
+ if (ask) return { kind: 'yes', id: `yes:${ask.key}`, job: ask };
1844
+ const done = doneJobs()[0];
1845
+ if (done) return { kind: 'done', id: `done:${done.bet_id}`, job: done };
1846
+ return null;
1847
+ }
1848
+
1849
+ function showJob(item) {
1850
+ state.current = item;
1851
+ state.status = 'ready';
1852
+ state.mission = null;
1853
+ state.error = null;
1854
+ log({ card: 'bet', choice: 'show', command: null, ran: 'in process', exit_code: 0, kind: item.kind, bet_id: item.job.bet_id });
1855
+ }
1856
+
1857
+ // a re-tap on a job card moves on: a yes waits for a later session, a done is seen
1858
+ function passJob(item) {
1859
+ if (item.kind === 'yes') state.seenYes.add(item.job.key);
1860
+ if (item.kind === 'done') record(item.job.bet_id, { done_seen_at: iso() });
1861
+ log({ card: 'bet', choice: 'pass', command: null, ran: 'in process', exit_code: 0, kind: item.kind, bet_id: item.job.bet_id });
1862
+ }
1863
+
1864
+ pollJobs();
1865
+ if (deps.pollMs) {
1866
+ const timer = setInterval(pollJobs, deps.pollMs);
1867
+ if (timer.unref) timer.unref();
1868
+ }
1869
+
1870
+ function startGeneration({ sharperOf = null } = {}) {
1871
+ if (state.inFlight >= MAX_IN_FLIGHT || state.inFlight >= SESSION_IN_FLIGHT_CAP) return null;
1872
+ state.inFlight += 1;
1873
+ state.maxInFlight = Math.max(state.maxInFlight, state.inFlight);
1874
+ state.run += 1;
1875
+ const gen = { run: state.run, events: [], sharperOf };
1876
+ state.gen = gen;
1877
+ const emit = (ev) => { gen.events.push({ i: gen.events.length, at: now(), ...ev }); };
1878
+ // started now, not on a later tick, so the in-flight count is exact
1879
+ let started;
1880
+ try {
1881
+ started = Promise.resolve(generate({ workspaceRoot: deps.workspaceRoot, today: dayKey(now()), previousTitle: sharperOf, avoidTitles: avoidTitles(), emit }));
1882
+ } catch (err) {
1883
+ started = Promise.reject(err);
1884
+ }
1885
+ gen.done = started
1886
+ .catch(err => ({ ok: false, reason: String((err && err.message) || err) }))
1887
+ .then((out) => {
1888
+ state.inFlight -= 1;
1889
+ if (state.gen === gen) state.gen = null;
1890
+ const waited = state.waiting === gen;
1891
+ if (!out.ok) {
1892
+ log({ card: 'bet', choice: 'generate', command: null, ran: 'in process', exit_code: 1, error: clip(out.reason, 200) });
1893
+ emit({ kind: 'failed', line: out.reason });
1894
+ if (waited) {
1895
+ state.waiting = null;
1896
+ state.lastFailed = gen.events;
1897
+ state.status = 'failed';
1898
+ state.error = out.reason;
1899
+ } else {
1900
+ state.failures += 1;
1901
+ }
1902
+ return pump();
1903
+ }
1904
+ state.failures = 0;
1905
+ const item = { id: newId(), bet: out.bet, created_at: now() };
1906
+ record(item.id, { created_at: iso(), status: 'queued', bet: out.bet, previous_title: sharperOf });
1907
+ log({ card: 'bet', choice: 'generate', command: null, ran: 'in process', exit_code: 0, bet_id: item.id });
1908
+ emit({ kind: 'done', line: 'bet ready' });
1909
+ if (waited) {
1910
+ state.waiting = null;
1911
+ show(item);
1912
+ } else {
1913
+ state.queue.push(item);
1914
+ }
1915
+ return pump();
1916
+ });
1917
+ return gen;
1918
+ }
1919
+
1920
+ // Keeps the queue topped up: below 2 it refills to 3, one generation at a
1921
+ // time. A tap that is waiting, or a sharper idea, goes first.
1922
+ function pump() {
1923
+ dropStale();
1924
+ if (state.inFlight >= MAX_IN_FLIGHT) return state.gen && state.gen.done;
1925
+ if (state.pendingSharper && state.status === 'thinking') {
1926
+ const sharperOf = state.pendingSharper;
1927
+ state.pendingSharper = null;
1928
+ state.waiting = startGeneration({ sharperOf });
1929
+ return state.waiting && state.waiting.done;
1930
+ }
1931
+ if (state.status === 'thinking' && !state.waiting) {
1932
+ state.waiting = startGeneration();
1933
+ return state.waiting && state.waiting.done;
1934
+ }
1935
+ if (state.queue.length < REFILL_BELOW) state.topping = true;
1936
+ if (state.queue.length >= QUEUE_TARGET) state.topping = false;
1937
+ if (!state.topping || state.failures >= MAX_BACKGROUND_FAILURES) return null;
1938
+ const gen = startGeneration();
1939
+ return gen && gen.done;
1940
+ }
1941
+
1942
+ // The page opened: start filling the queue so the first tap does not wait.
1943
+ function warm() {
1944
+ pump();
1945
+ return view();
1946
+ }
1947
+
1948
+ // A tap on the orb: the bet on screen is passed over, the next one in the
1949
+ // queue shows at once, or the page waits on the generation in flight.
1950
+ function next({ sharper = false } = {}) {
1951
+ if (state.status === 'thinking' || state.status === 'starting') return { ok: true, busy: true, ...view() };
1952
+ state.failures = 0;
1953
+ const onScreen = state.status === 'ready' && state.current ? state.current : null;
1954
+ if (onScreen && onScreen.kind && onScreen.kind !== 'bet') {
1955
+ passJob(onScreen);
1956
+ } else if (onScreen) {
1957
+ record(onScreen.id, { status: 'passed', ...(sharper ? { note: 'asked for a sharper idea' } : {}) });
1958
+ log({ card: 'bet', choice: sharper ? 'sharper' : 'pass', command: null, ran: 'in process', exit_code: 0, bet_id: onScreen.id });
1959
+ } else {
1960
+ log({ card: 'bet', choice: 'think', command: null, ran: 'in process', exit_code: 0 });
1961
+ }
1962
+ dropStale();
1963
+ // a yes or a finished job comes before the next bet, never in the middle of one
1964
+ if (!sharper) {
1965
+ if (now() - state.jobs.at > 5000) pollJobs();
1966
+ const job = nextJobCard();
1967
+ if (job) {
1968
+ showJob(job);
1969
+ pump();
1970
+ return { ok: true, ...view() };
1971
+ }
1972
+ }
1973
+ if (sharper) {
1974
+ state.pendingSharper = onScreen && onScreen.bet ? onScreen.bet.title : null;
1975
+ state.current = null;
1976
+ state.status = 'thinking';
1977
+ state.waiting = null;
1978
+ pump();
1979
+ return { ok: true, ...view() };
1980
+ }
1981
+ if (state.queue.length) {
1982
+ show(state.queue.shift());
1983
+ pump();
1984
+ return { ok: true, ...view() };
1985
+ }
1986
+ state.current = null;
1987
+ state.status = 'thinking';
1988
+ state.error = null;
1989
+ // the generation already running is the one this tap waits on
1990
+ state.waiting = state.gen || null;
1991
+ pump();
1992
+ return { ok: true, ...view() };
1993
+ }
1994
+
1995
+ const isBet = () => state.status === 'ready' && state.current && (!state.current.kind || state.current.kind === 'bet');
1996
+
1997
+ function skip() {
1998
+ if (!isBet()) return { ok: false, reason: 'gone', message: 'there is no bet to skip' };
1999
+ record(state.current.id, { status: 'skipped' });
2000
+ log({ card: 'bet', choice: 'skip', command: null, ran: 'in process', exit_code: 0, bet_id: state.current.id });
2001
+ state.status = 'skipped';
2002
+ return { ok: true, ack: 'not now. all clear.' };
2003
+ }
2004
+
2005
+ async function doIt() {
2006
+ if (!isBet()) return { ok: false, reason: 'gone', message: 'there is no bet to start' };
2007
+ const { id, bet } = state.current;
2008
+ const pick = pickOwner(bet, deps.workspaceRoot);
2009
+ const startArgs = missionStartArgs(bet, pick, id);
2010
+ state.status = 'starting';
2011
+ const display = args => require('./gm-phone').displayCommand(args);
2012
+ let res = await deps.runCli(startArgs);
2013
+ log({ card: 'bet', choice: 'do', command: display(startArgs), exit_code: res.code, bet_id: id });
2014
+ const started = parseJsonOut(res.stdout);
2015
+ const missionId = started && (started.mission ? started.mission.id : started.mission_id);
2016
+ if (res.code !== 0 || !missionId) {
2017
+ state.status = 'ready';
2018
+ const why = clip((res.stderr || res.stdout || '').trim().split('\n').pop(), 160);
2019
+ return { ok: false, reason: 'start_failed', message: `the mission did not start${why ? `: ${why}` : ''}` };
2020
+ }
2021
+ const owner = (started.mission && started.mission.owner) || pick.owner;
2022
+ record(id, { status: 'doing', mission_id: missionId, owner });
2023
+ lockYesSteps(missionId, id, bet.steps);
2024
+ const runArgs = missionRunArgs(missionId);
2025
+ res = await deps.runCli(runArgs);
2026
+ log({ card: 'bet', choice: 'do', command: display(runArgs), exit_code: res.code, bet_id: id, mission_id: missionId });
2027
+ const ran = parseJsonOut(res.stdout) || {};
2028
+ let line;
2029
+ if (res.code === 0 && ran.action === 'mission_run_detached') line = `${owner} has it. the mission is running now.`;
2030
+ else if (ran.reason === 'human_asks_open') line = `${owner} has it, and is waiting on your yes before it starts.`;
2031
+ else line = `${owner} has the mission, but it did not start running (exit code ${res.code}). run it from the mac: atris mission run ${missionId}`;
2032
+ state.status = 'started';
2033
+ state.mission = { id: missionId, owner, line, commands: [display(startArgs), display(runArgs)] };
2034
+ state.started.push({ bet_id: id, mission_id: missionId, owner, steps: bet.steps, yes: {} });
2035
+ pollJobs();
2036
+ return { ok: true, ack: line, mission: state.mission };
2037
+ }
2038
+
2039
+ const onJob = kind => (state.status === 'ready' && state.current && state.current.kind === kind ? state.current : null);
2040
+
2041
+ // nice: a finished job was seen; nothing else happens
2042
+ function nice() {
2043
+ const item = onJob('done');
2044
+ if (!item) return { ok: false, reason: 'gone', message: 'there is no finished job on screen' };
2045
+ record(item.job.bet_id, { done_seen_at: iso() });
2046
+ log({ card: 'bet', choice: 'nice', command: null, ran: 'in process', exit_code: 0, bet_id: item.job.bet_id });
2047
+ state.status = 'cleared';
2048
+ return { ok: true, ack: 'nice.' };
2049
+ }
2050
+
2051
+ // send back: a job that did not pass goes to the team again as a new mission
2052
+ // on the same bet, with what went wrong in the objective
2053
+ async function sendBack() {
2054
+ const item = onJob('done');
2055
+ if (!item || !item.job.failed || !item.job.bet) return { ok: false, reason: 'gone', message: 'there is no failed job on screen' };
2056
+ const { job } = item;
2057
+ const why = doneLines(job);
2058
+ const again = { ...job.bet, why_now: `the last run ended with: ${why.check}${job.halted && job.stop_reason ? `, ${job.stop_reason}` : ''}. try again. ${job.bet.why_now}` };
2059
+ const pick = pickOwner(again, deps.workspaceRoot);
2060
+ const display = args => require('./gm-phone').displayCommand(args);
2061
+ state.status = 'starting';
2062
+ const startArgs = missionStartArgs(again, pick, job.bet_id);
2063
+ let res = await deps.runCli(startArgs);
2064
+ log({ card: 'bet', choice: 'send_back', command: display(startArgs), exit_code: res.code, bet_id: job.bet_id });
2065
+ const started = parseJsonOut(res.stdout);
2066
+ const missionId = started && (started.mission ? started.mission.id : started.mission_id);
2067
+ if (res.code !== 0 || !missionId) {
2068
+ state.status = 'ready';
2069
+ return { ok: false, reason: 'start_failed', message: 'it did not go back to the team; try again from the mac' };
2070
+ }
2071
+ const owner = (started.mission && started.mission.owner) || pick.owner;
2072
+ record(job.bet_id, { mission_id: missionId, owner, mission_result: null, done_seen_at: iso(), sent_back_at: iso() });
2073
+ lockYesSteps(missionId, job.bet_id, again.steps);
2074
+ const runArgs = missionRunArgs(missionId);
2075
+ res = await deps.runCli(runArgs);
2076
+ log({ card: 'bet', choice: 'send_back', command: display(runArgs), exit_code: res.code, bet_id: job.bet_id, mission_id: missionId });
2077
+ state.status = 'cleared';
2078
+ pollJobs();
2079
+ return { ok: true, ack: `sent back. ${owner} has it again.` };
2080
+ }
2081
+
2082
+ // yes: answers the mission's open ask through atris decide, matched by
2083
+ // mission and ask, never by its place in a list
2084
+ async function answerYes() {
2085
+ const item = onJob('yes');
2086
+ if (!item) return { ok: false, reason: 'gone', message: 'there is no question on screen' };
2087
+ const { job } = item;
2088
+ if (job.lock_step) {
2089
+ require('./yes-lock').approveYesLock(lockDir, job.mission_id, job.lock_step, null, iso());
2090
+ const args = ['mission', 'ping', job.mission_id, `the owner said yes to step ${job.lock_step}: ${job.ask}. the guard lets it through now; run it again.`, '--from', 'gm-phone'];
2091
+ const res = await deps.runCli(args);
2092
+ log({ card: 'bet', choice: 'yes', command: require('./gm-phone').displayCommand(args), exit_code: res.code, bet_id: job.bet_id, mission_id: job.mission_id, step: job.lock_step });
2093
+ state.seenYes.add(job.key);
2094
+ record(job.bet_id, { yes_answered: { mission_id: job.mission_id, step: job.lock_step, at: iso() } });
2095
+ state.status = 'cleared';
2096
+ pollJobs();
2097
+ return { ok: true, ack: `${job.owner}: got your yes` };
2098
+ }
2099
+ const listed = await deps.runCli(['decide', '--json']);
2100
+ const open = (parseJsonOut(listed.stdout) || {}).decisions || [];
2101
+ const match = open.find(d => d && d.mission_id === job.mission_id && d.ask_index === job.ask_index);
2102
+ if (!match) {
2103
+ state.seenYes.add(job.key);
2104
+ state.status = 'cleared';
2105
+ return { ok: true, ack: 'that question was already answered.' };
2106
+ }
2107
+ const args = ['decide', String(match.number), 'yes', '--json'];
2108
+ const res = await deps.runCli(args);
2109
+ log({ card: 'bet', choice: 'yes', command: require('./gm-phone').displayCommand(args), exit_code: res.code, bet_id: job.bet_id, mission_id: job.mission_id });
2110
+ if (res.code !== 0) return { ok: false, reason: 'answer_failed', message: 'your yes did not go through; try again' };
2111
+ state.seenYes.add(job.key);
2112
+ record(job.bet_id, { yes_answered: { mission_id: job.mission_id, ask_index: job.ask_index, at: iso() } });
2113
+ state.status = 'cleared';
2114
+ pollJobs();
2115
+ return { ok: true, ack: `${job.owner}: got your yes` };
2116
+ }
2117
+
2118
+ function publicJob(item) {
2119
+ const j = item.job;
2120
+ if (item.kind === 'yes') return { title: j.title, owner: j.owner, ask: j.ask, ...(j.command ? { command: j.command } : {}) };
2121
+ return { title: j.title, owner: j.owner, failed: j.failed, halted: j.halted, ...doneLines(j) };
2122
+ }
2123
+
2124
+ // Decision cards for the steps that need the owner's yes, for every bet
2125
+ // started this session. Saying yes tells the mission with a ping; it runs
2126
+ // nothing public.
2127
+ function yesCards() {
2128
+ const cards = [];
2129
+ state.started.forEach((m, n) => {
2130
+ m.steps.forEach((step, i) => {
2131
+ if (step.who !== 'you' || m.yes[i]) return;
2132
+ cards.push({
2133
+ id: `yes-${n}-${i}`,
2134
+ text: `your yes: ${step.text}`,
2135
+ moves: null,
2136
+ bet_yes: { n, i },
2137
+ choices: [
2138
+ {
2139
+ id: 'yes',
2140
+ label: 'yes, tell the mission',
2141
+ effect: 'records your yes. nothing goes out until the mission does this step.',
2142
+ member: m.owner,
2143
+ ack: `${m.owner}: got your yes`,
2144
+ confirm: true,
2145
+ steps: [yesPingArgs(m.mission_id, step)],
2146
+ },
2147
+ { id: 'later', label: 'later' },
2148
+ ],
2149
+ });
2150
+ });
2151
+ });
2152
+ return cards;
2153
+ }
2154
+
2155
+ function recordYes({ n, i }) {
2156
+ const m = state.started[n];
2157
+ if (!m) return;
2158
+ m.yes[i] = now();
2159
+ record(m.bet_id, { yes: { ...m.yes } });
2160
+ try { require('./yes-lock').approveYesLock(lockDir, m.mission_id, i + 1, null, iso()); } catch { /* no lock for this job */ }
2161
+ }
2162
+
2163
+ function view() {
2164
+ const events = state.waiting ? state.waiting.events : (state.status === 'failed' && state.lastFailed ? state.lastFailed : []);
2165
+ const cur = state.current;
2166
+ const kind = cur ? (cur.kind || 'bet') : null;
2167
+ return {
2168
+ status: state.status,
2169
+ id: cur ? cur.id : null,
2170
+ kind,
2171
+ bet: kind === 'bet' ? cur.bet : null,
2172
+ job: kind && kind !== 'bet' ? publicJob(cur) : null,
2173
+ working: state.jobs.working.map(j => ({ title: j.title, owner: j.owner, running_for: j.running_for })),
2174
+ events: events.map(e => ({ i: e.i, kind: e.kind, line: e.line, ...(e.key ? { key: e.key } : {}), ...(e.n != null ? { n: e.n } : {}) })),
2175
+ error: state.error,
2176
+ queue: state.queue.length,
2177
+ mission: state.mission ? { owner: state.mission.owner, line: state.mission.line } : null,
2178
+ };
2179
+ }
2180
+
2181
+ // resolves when every review running now has finished (tests and shutdown)
2182
+ const settle = () => Promise.all([...state.reviews.values()]);
2183
+
2184
+ return { warm, next, skip, doIt, nice, sendBack, answerYes, pollJobs, settle, yesCards, recordYes, view, state };
2185
+ }
2186
+
2187
+ module.exports = {
2188
+ JOB_POLL_MS,
2189
+ queuedProblem,
2190
+ MODEL_TIMEOUT_MS,
2191
+ STALE_MS,
2192
+ YES_RULE,
2193
+ buildPrompt,
2194
+ addDays,
2195
+ attemptsPath,
2196
+ bundleText,
2197
+ citesBundle,
2198
+ claudeArgs,
2199
+ createBetDesk,
2200
+ defaultReleaseGap,
2201
+ generateBet,
2202
+ bestWeek,
2203
+ installTotals,
2204
+ installWeeks,
2205
+ jobState,
2206
+ ledgerPath,
2207
+ makeCliRunner,
2208
+ missionObjective,
2209
+ modelEnv,
2210
+ parseVerdict,
2211
+ pickOwner,
2212
+ readBundle,
2213
+ readLedger,
2214
+ releaseFact,
2215
+ runModel,
2216
+ scoreBets,
2217
+ skepticCommand,
2218
+ validateBet,
2219
+ };