atris 3.72.5 → 3.73.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/README.md +2 -0
- package/atris/skills/commerce/SKILL.md +24 -3
- package/bin/atris.js +10 -0
- package/commands/gm.js +16 -5
- package/commands/member.js +59 -9
- package/commands/pay.js +329 -0
- package/commands/trust.js +117 -0
- package/commands/zen.js +17 -0
- package/lib/gm-bet.js +1443 -162
- package/lib/gm-phone-page.js +460 -357
- package/lib/gm-phone.js +58 -7
- package/lib/known-commands.js +1 -1
- package/lib/try-repo.js +442 -0
- package/lib/zen-apply.js +23 -3
- package/lib/zen-fleet.js +100 -0
- package/lib/zen.js +41 -6
- package/package.json +1 -1
package/lib/gm-bet.js
CHANGED
|
@@ -65,6 +65,7 @@ const METRIC_SOURCES = ['npm', 'install', 'download', 'git', 'master', 'landed',
|
|
|
65
65
|
// purpose: filler jobs inflate one, and closing jobs empties the other.
|
|
66
66
|
const METRICS = {
|
|
67
67
|
installs: { what: 'npm installs in the 7 days that end on the check date', now: 'installs last week', up: true },
|
|
68
|
+
scoreboard: { what: "the owner's own number for this project, as he reports it on the phone; read from his first report on or after the check date", now: 'the last reported value', up: true },
|
|
68
69
|
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
|
};
|
|
70
71
|
|
|
@@ -127,6 +128,345 @@ function readLedger(file, workspaceRoot) {
|
|
|
127
128
|
return [...byId.values()].sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)));
|
|
128
129
|
}
|
|
129
130
|
|
|
131
|
+
// ---- the owner's scoreboard: the one number that says this project is going well ----
|
|
132
|
+
|
|
133
|
+
// Beside the ledger, one row per change: a pick (its name, which way is good,
|
|
134
|
+
// and how it is read) or a report of its value. The newest pick wins; its
|
|
135
|
+
// reports are the ones after it.
|
|
136
|
+
function scoreboardPath(stateDir = defaultStateDir()) {
|
|
137
|
+
return path.join(stateDir, 'gm-scoreboard.jsonl');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function readScoreboard(file, workspaceRoot) {
|
|
141
|
+
let text = '';
|
|
142
|
+
try {
|
|
143
|
+
text = fs.readFileSync(file, 'utf8');
|
|
144
|
+
} catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
let board = null;
|
|
148
|
+
for (const line of text.split('\n')) {
|
|
149
|
+
if (!line.trim()) continue;
|
|
150
|
+
let row;
|
|
151
|
+
try { row = JSON.parse(line); } catch { continue; }
|
|
152
|
+
if (!row || row.workspace !== workspaceRoot) continue;
|
|
153
|
+
if (row.kind === 'pick' && row.name) board = { name: row.name, up: row.up !== false, source: row.source === 'installs' ? 'installs' : 'reported', at: row.at, reports: [] };
|
|
154
|
+
else if (row.kind === 'report' && board && Number.isFinite(row.value)) board.reports.push({ value: row.value, at: row.at });
|
|
155
|
+
}
|
|
156
|
+
return board;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// the best of the last 8 reports, once there are 3: the bar a goal must clear
|
|
160
|
+
const SCOREBOARD_REPORTS = 8;
|
|
161
|
+
const SCOREBOARD_MIN_REPORTS = 3;
|
|
162
|
+
|
|
163
|
+
// ---- the owner's focus: answers to the question cards ----------------------------
|
|
164
|
+
|
|
165
|
+
// Lives beside the ledger, one row per answer; the newest answer per question wins.
|
|
166
|
+
function focusPath(stateDir = defaultStateDir()) {
|
|
167
|
+
return path.join(stateDir, 'gm-focus.jsonl');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function readFocus(file, workspaceRoot) {
|
|
171
|
+
const out = {};
|
|
172
|
+
let text = '';
|
|
173
|
+
try {
|
|
174
|
+
text = fs.readFileSync(file, 'utf8');
|
|
175
|
+
} catch {
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
for (const line of text.split('\n')) {
|
|
179
|
+
if (!line.trim()) continue;
|
|
180
|
+
try {
|
|
181
|
+
const row = JSON.parse(line);
|
|
182
|
+
if (row && row.key && row.workspace === workspaceRoot) out[row.key] = row;
|
|
183
|
+
} catch {
|
|
184
|
+
// a torn line is skipped
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// An answer newer than this is still fresh, so its question is not asked again.
|
|
191
|
+
const FOCUS_FRESH_MS = 7 * 24 * 60 * 60 * 1000;
|
|
192
|
+
|
|
193
|
+
// Our own words, which the owner never reads: on a card, a button, the
|
|
194
|
+
// footer, a question, or in what the writer model says. A binary tell, so a
|
|
195
|
+
// word list is the right tool.
|
|
196
|
+
const OWNER_BANNED_WORDS = ['bet', 'bets', 'check date', 'started so far', 'the team takes on', 'mission', 'queue', 'landed', 'review', 'reviewed', 'receipt', 'skeptic', 'verify', 'target number'];
|
|
197
|
+
const OWNER_BANNED = /\b(bets?|check dates?|started so far|the team takes on|missions?|queues?|landed|review(?:ed|s)?|receipts?|skeptics?|verify|target numbers?)\b/i;
|
|
198
|
+
|
|
199
|
+
// When no idea is ready the desk asks the owner a question about his
|
|
200
|
+
// project. The questions are written ahead, in the background, from that
|
|
201
|
+
// project's own files and every earlier answer, like an interview. Until one
|
|
202
|
+
// is written, this one works for any project.
|
|
203
|
+
const FALLBACK_QUESTION = {
|
|
204
|
+
key: 'focus',
|
|
205
|
+
ask: 'what matters most for this project this month?',
|
|
206
|
+
choices: [
|
|
207
|
+
{ id: 'using', label: 'more people using it' },
|
|
208
|
+
{ id: 'money', label: 'making money from it' },
|
|
209
|
+
{ id: 'better', label: 'making it better' },
|
|
210
|
+
],
|
|
211
|
+
};
|
|
212
|
+
// questions kept written ahead, so a tap never waits on one
|
|
213
|
+
const QUESTIONS_READY = 2;
|
|
214
|
+
const QUESTION_ASK_MAX = 90;
|
|
215
|
+
const QUESTION_FACT_MAX = 160;
|
|
216
|
+
const QUESTION_CHOICE_MAX = 50;
|
|
217
|
+
|
|
218
|
+
function questionsPath(stateDir = defaultStateDir()) {
|
|
219
|
+
return path.join(stateDir, 'gm-questions.jsonl');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// installs said the way the owner would say them, when this project has npm numbers
|
|
223
|
+
function installsLine(bundle) {
|
|
224
|
+
const inst = bundle && bundle.sources && bundle.sources.installs;
|
|
225
|
+
const d = inst && inst.data;
|
|
226
|
+
if (!d || d.week_before == null) return null;
|
|
227
|
+
if (d.last_week === d.week_before) return `installs held at ${commas(d.last_week)} last week.`;
|
|
228
|
+
return d.last_week < d.week_before
|
|
229
|
+
? `installs dropped to ${commas(d.last_week)} last week, down from ${commas(d.week_before)}.`
|
|
230
|
+
: `installs rose to ${commas(d.last_week)} last week, up from ${commas(d.week_before)}.`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// What this project is, in its own words: the readme, the goals, the
|
|
234
|
+
// persona, and the top of the map and the wiki. Read-only; a missing file is
|
|
235
|
+
// left out.
|
|
236
|
+
const CONTEXT_FILES = [['README.md', 60], ['atris/goals.md', 40], ['atris/PERSONA.md', 30], ['atris/MAP.md', 30], ['atris/wiki/index.md', 30]];
|
|
237
|
+
function readProjectContext(workspaceRoot) {
|
|
238
|
+
const parts = [];
|
|
239
|
+
for (const [rel, lines] of CONTEXT_FILES) {
|
|
240
|
+
let text;
|
|
241
|
+
try {
|
|
242
|
+
text = fs.readFileSync(path.join(workspaceRoot, rel), 'utf8');
|
|
243
|
+
} catch {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
const body = text.split(/\r?\n/).slice(0, lines).join('\n').trim();
|
|
247
|
+
if (body) parts.push(`## ${rel}\n${body}`);
|
|
248
|
+
}
|
|
249
|
+
return parts.join('\n\n');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// A fact line is drawn from the project's files: it has a number from them,
|
|
253
|
+
// or four words in a row copied from them.
|
|
254
|
+
function citesText(fact, source) {
|
|
255
|
+
const nums = t => (String(t).match(/\d[\d,]*(?:\.\d+)?/g) || []).map(n => n.replace(/,/g, ''));
|
|
256
|
+
const have = new Set(nums(source));
|
|
257
|
+
if (nums(fact).some(n => have.has(n))) return true;
|
|
258
|
+
const src = ` ${words(source).join(' ')} `;
|
|
259
|
+
const w = words(fact);
|
|
260
|
+
for (let i = 0; i + 4 <= w.length; i += 1) {
|
|
261
|
+
const gram = w.slice(i, i + 4);
|
|
262
|
+
if (gram.every(x => STOP_WORDS.has(x))) continue;
|
|
263
|
+
if (src.includes(` ${gram.join(' ')} `)) return true;
|
|
264
|
+
}
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// a question about our own pipeline, not his product
|
|
269
|
+
const PROCESS_TALK = /\b(ideas?|the team)\b/i;
|
|
270
|
+
|
|
271
|
+
// The code checks on a written question. Our own words and lengths can be
|
|
272
|
+
// fixed by one rewrite; anything else drops the question.
|
|
273
|
+
function validateQuestion(q, { source = '', avoid = [] } = {}) {
|
|
274
|
+
const reasons = [];
|
|
275
|
+
const format = new Set();
|
|
276
|
+
const fmt = (r) => { reasons.push(r); format.add(r); };
|
|
277
|
+
if (!q || typeof q !== 'object' || Array.isArray(q)) return { ok: false, reasons: ['the answer was not one JSON object'], formatOnly: false };
|
|
278
|
+
const ask = typeof q.ask === 'string' ? q.ask.trim() : '';
|
|
279
|
+
const fact = typeof q.fact === 'string' ? q.fact.trim() : '';
|
|
280
|
+
const choices = Array.isArray(q.choices) ? q.choices.map(c => (typeof c === 'string' ? c.trim() : '')) : null;
|
|
281
|
+
if (!ask || !ask.endsWith('?')) reasons.push('ask must be one question ending with "?"');
|
|
282
|
+
else if (ask.length > QUESTION_ASK_MAX) fmt(`ask is ${ask.length} characters, over ${QUESTION_ASK_MAX}`);
|
|
283
|
+
if (ask && avoid.some(a => nearDuplicate(ask, a))) reasons.push('the ask was already asked');
|
|
284
|
+
// the fact is optional: a question with no fitting fact stands on its own
|
|
285
|
+
if (!fact) { /* no fact */ } else if (fact.length > QUESTION_FACT_MAX) fmt(`fact is ${fact.length} characters, over ${QUESTION_FACT_MAX}`);
|
|
286
|
+
else if (!citesText(fact, source)) reasons.push("the fact is not in this project's files");
|
|
287
|
+
if (!choices || choices.length < 2 || choices.length > 3 || choices.some(c => !c)) reasons.push('choices must be 2 or 3 short answers');
|
|
288
|
+
else {
|
|
289
|
+
choices.forEach((c, i) => { if (c.length > QUESTION_CHOICE_MAX) fmt(`choice ${i + 1} is ${c.length} characters, over ${QUESTION_CHOICE_MAX}`); });
|
|
290
|
+
if (new Set(choices.map(c => c.toLowerCase())).size !== choices.length) reasons.push('the choices must differ');
|
|
291
|
+
}
|
|
292
|
+
for (const [k, text] of [['ask', ask], ['fact', fact]]) {
|
|
293
|
+
if (PROCESS_TALK.test(text)) fmt(`${k} talks about our ideas or the team; ask about the product and its people`);
|
|
294
|
+
}
|
|
295
|
+
const said = [['ask', ask], ['fact', fact], ...(choices || []).map((c, i) => [`choice ${i + 1}`, c])];
|
|
296
|
+
for (const [k, text] of said) {
|
|
297
|
+
const word = ownerWord(text);
|
|
298
|
+
if (word) fmt(`${k} uses "${word}", a word the owner never reads; say it plainly`);
|
|
299
|
+
}
|
|
300
|
+
return { ok: reasons.length === 0, reasons, formatOnly: reasons.length > 0 && reasons.every(r => format.has(r)) };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function buildQuestionPrompt(bundle, context, { answers = [], avoid = [] } = {}) {
|
|
304
|
+
return [
|
|
305
|
+
'You are interviewing the owner of this project to learn what matters to him. Below is what this project\'s own files say.',
|
|
306
|
+
'Write exactly ONE question for the owner about the product or the business of this project: its people, money, what is working, what annoys him, or what he wants this month. Never ask about how the team works, its tools, or its process.',
|
|
307
|
+
'It is an interview: build on his earlier answers, go one step deeper, and never repeat a question already asked.',
|
|
308
|
+
'',
|
|
309
|
+
'Answer with strict JSON only, no prose and no code fence, in this shape:',
|
|
310
|
+
'{"ask": "...", "fact": "...", "choices": ["...", "...", "..."]}',
|
|
311
|
+
'',
|
|
312
|
+
'Rules:',
|
|
313
|
+
`- ask: one short plain question, under ${QUESTION_ASK_MAX} characters, ending with "?".`,
|
|
314
|
+
`- fact: the plain reason this question matters now, one sentence he would say himself, drawn from the files below, with a number or at least four words in a row copied from them, under ${QUESTION_FACT_MAX} characters. it must lead straight to the question. if nothing in the files gives a reason for this question, set fact to "".`,
|
|
315
|
+
`- choices: 2 or 3 short concrete answers he can tap, each under ${QUESTION_CHOICE_MAX} characters.`,
|
|
316
|
+
`- never use these words: ${OWNER_BANNED_WORDS.join(', ')}.`,
|
|
317
|
+
'- a section marked stale is old: prefer the fresh ones, and never present a stale line as what is happening now.',
|
|
318
|
+
'- ask about the product and the people who use it or pay for it, for example "who do you most want using this?". never mention ideas, the team, or what happens to his answer.',
|
|
319
|
+
'- plain lowercase-friendly words, no hype, no em dashes.',
|
|
320
|
+
'',
|
|
321
|
+
'## his answers so far',
|
|
322
|
+
...(answers.length ? answers.map(a => `- ${a.ask} ${a.label}`) : ['- none yet']),
|
|
323
|
+
'',
|
|
324
|
+
'## questions already asked (never repeat one)',
|
|
325
|
+
...(avoid.length ? avoid.map(a => `- ${a}`) : ['- none yet']),
|
|
326
|
+
'',
|
|
327
|
+
'## this project\'s files',
|
|
328
|
+
context || '(no readme or goals found)',
|
|
329
|
+
'',
|
|
330
|
+
bundleText(bundle),
|
|
331
|
+
].join('\n');
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function buildQuestionRewrite(previous, reasons) {
|
|
335
|
+
return [
|
|
336
|
+
'Your last question broke only these limits:',
|
|
337
|
+
...reasons.map(r => `- ${r}`),
|
|
338
|
+
'',
|
|
339
|
+
'Fix only those: shorten what is long, and swap each word named for a plain one. Keep the same question, fact, and choices otherwise.',
|
|
340
|
+
'Answer with the whole JSON object again, strict JSON only, no prose and no code fence.',
|
|
341
|
+
'',
|
|
342
|
+
'Your last question:',
|
|
343
|
+
clip(previous, 2000),
|
|
344
|
+
].join('\n');
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ---- reading free text: is it a question back, an answer, an instruction? ----
|
|
348
|
+
|
|
349
|
+
const SAY_READ_MS = 15000;
|
|
350
|
+
const SAY_KINDS = new Set(['question', 'answer', 'instruction', 'unclear']);
|
|
351
|
+
const SAY_FALLBACK = 'say that another way, or tap a button.';
|
|
352
|
+
// the last turns of a card's conversation the reader sees
|
|
353
|
+
const THREAD_TURNS = 6;
|
|
354
|
+
|
|
355
|
+
// the roster's model for quick reads (its search job), on the subscription
|
|
356
|
+
function readerModel(workspaceRoot) {
|
|
357
|
+
try {
|
|
358
|
+
const team = require('./engine-registry').resolveJobTeam('search', workspaceRoot);
|
|
359
|
+
const lead = team && team.lead;
|
|
360
|
+
if (lead && (lead.id === 'claude' || lead.bin === 'claude') && lead.roster_model) return lead.roster_model;
|
|
361
|
+
} catch {
|
|
362
|
+
// no roster here
|
|
363
|
+
}
|
|
364
|
+
return 'haiku';
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// One quick call: what kind of message the owner typed on the card on
|
|
368
|
+
// screen, and what to say back. The card, the conversation on it so far, and
|
|
369
|
+
// a short view of the project go in.
|
|
370
|
+
async function readSaid({ text, card, thread = [], hint = null, workspaceRoot, bundle = null, deps = {} } = {}) {
|
|
371
|
+
const context = clip((deps.readContext || readProjectContext)(workspaceRoot), 1500);
|
|
372
|
+
const goal = bundle && bundle.sources && bundle.sources.goal;
|
|
373
|
+
const prompt = [
|
|
374
|
+
'You read one short message the owner typed on a card in the app that runs his project, and say what kind it is.',
|
|
375
|
+
'Answer with strict JSON only, no prose and no code fence, in this shape:',
|
|
376
|
+
'{"kind": "question" | "answer" | "instruction" | "unclear", "reply": "..."}',
|
|
377
|
+
'- question: he asks something back, like "what do you mean?" or "why?". reply: one or two plain sentences that say why the card asks this and give a concrete example from this project. read the conversation so far, so a follow-up builds on it.',
|
|
378
|
+
'- answer: his words answer the card. reply: "".',
|
|
379
|
+
'- instruction: he wants something done, or has a new idea. reply: one plain sentence saying what will happen next.',
|
|
380
|
+
'- unclear: you cannot tell. reply: one short question back.',
|
|
381
|
+
'- a message that ends with "?" or asks why, what, or how is a question back, never an answer.',
|
|
382
|
+
'- you may add "suggestions": 0 to 3 short replies he could tap next, each under 40 characters.',
|
|
383
|
+
`- plain lowercase-friendly words, no em dashes, and never these words: ${OWNER_BANNED_WORDS.join(', ')}.`,
|
|
384
|
+
'',
|
|
385
|
+
`## the card on screen (${card.kind})`,
|
|
386
|
+
card.title || '',
|
|
387
|
+
card.detail || '',
|
|
388
|
+
card.buttons && card.buttons.length ? `buttons: ${card.buttons.join(' / ')}` : '',
|
|
389
|
+
'',
|
|
390
|
+
'## the conversation on this card so far',
|
|
391
|
+
...(thread.length ? thread.map(t => `- ${t.who === 'owner' ? 'owner' : 'you'}: ${t.text}`) : ['- none yet']),
|
|
392
|
+
'',
|
|
393
|
+
`## his message: "${clip(text, 280)}"`,
|
|
394
|
+
hint ? `note: ${hint}` : '',
|
|
395
|
+
'',
|
|
396
|
+
'## this project',
|
|
397
|
+
goal && goal.horizon ? `goal: ${goal.horizon.text}` : '',
|
|
398
|
+
context || '(no readme or goals found)',
|
|
399
|
+
].filter(l => l !== '').join('\n');
|
|
400
|
+
const model = (deps.readerModel || readerModel)(workspaceRoot);
|
|
401
|
+
const said = await (deps.runModel || runModel)(prompt, { cwd: workspaceRoot, timeoutMs: SAY_READ_MS, command: { bin: 'claude', args: p => claudeArgs(p, model) }, onProgress: () => {} });
|
|
402
|
+
if (!said.ok) return { ok: false, reason: said.reason };
|
|
403
|
+
const parsed = parseBet(said.text);
|
|
404
|
+
const v = parsed.bet;
|
|
405
|
+
if (!v || !SAY_KINDS.has(v.kind) || typeof v.reply !== 'string') return { ok: false, reason: 'the read was not the shape asked for' };
|
|
406
|
+
const suggestions = Array.isArray(v.suggestions) ? v.suggestions.filter(x => typeof x === 'string' && x.trim()).map(x => x.trim()) : [];
|
|
407
|
+
return { ok: true, kind: v.kind, reply: clip(v.reply, 280), ...(suggestions.length ? { suggestions } : {}) };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Two or three numbers that would tell the owner this project is going well,
|
|
411
|
+
// suggested from its own files. Each is a short plain name and which way is good.
|
|
412
|
+
async function generateScoreboardChoices({ workspaceRoot, bundle = null, deps = {}, ledgerFile, today = dayKey(Date.now()) } = {}) {
|
|
413
|
+
const b = bundle || await readBundle(workspaceRoot, { deps, ledgerFile, today });
|
|
414
|
+
const context = (deps.readContext || readProjectContext)(workspaceRoot);
|
|
415
|
+
const prompt = [
|
|
416
|
+
'You help the owner of this project pick the one number that tells him it is going well. Below is what its own files say.',
|
|
417
|
+
'Suggest 2 or 3 such numbers for THIS project, the kind he would check each week. For example: a web app, "people who used it this week"; a research lab, "experiments that worked"; a fund, "returns this month"; a journal, "days you wrote".',
|
|
418
|
+
'Answer with strict JSON only, no prose and no code fence: {"choices": [{"name": "...", "up": true}]}',
|
|
419
|
+
'- name: a short plain phrase under 40 characters. up: true when higher is better, false when lower is better.',
|
|
420
|
+
`- never use these words: ${OWNER_BANNED_WORDS.join(', ')}.`,
|
|
421
|
+
'',
|
|
422
|
+
"## this project's files",
|
|
423
|
+
context || '(no readme or goals found)',
|
|
424
|
+
'',
|
|
425
|
+
bundleText(b),
|
|
426
|
+
].join('\n');
|
|
427
|
+
const said = await (deps.runModel || runModel)(prompt, { cwd: workspaceRoot, onProgress: () => {} });
|
|
428
|
+
if (!said.ok) return { ok: false, reason: said.reason };
|
|
429
|
+
const parsed = parseBet(said.text);
|
|
430
|
+
const list = parsed.bet && Array.isArray(parsed.bet.choices) ? parsed.bet.choices : [];
|
|
431
|
+
const choices = list
|
|
432
|
+
.filter(c => c && typeof c.name === 'string' && c.name.trim() && c.name.trim().length <= 40 && !ownerWord(c.name) && !PROCESS_TALK.test(c.name))
|
|
433
|
+
.slice(0, 3)
|
|
434
|
+
.map(c => ({ name: c.name.trim(), up: c.up !== false }));
|
|
435
|
+
return choices.length >= 2 ? { ok: true, choices } : { ok: false, reason: 'fewer than two usable numbers' };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// One question for the owner, written from this project's files and his
|
|
439
|
+
// earlier answers. A question with our own words gets one rewrite; one that
|
|
440
|
+
// still fails any check is dropped.
|
|
441
|
+
async function generateQuestion({ workspaceRoot, answers = [], avoid = [], bundle = null, deps = {}, ledgerFile, today = dayKey(Date.now()) } = {}) {
|
|
442
|
+
const b = bundle || await readBundle(workspaceRoot, { deps, ledgerFile, today });
|
|
443
|
+
const context = (deps.readContext || readProjectContext)(workspaceRoot);
|
|
444
|
+
const source = `${context}\n${bundleText(b)}`;
|
|
445
|
+
const run = deps.runModel || runModel;
|
|
446
|
+
const ask = async (prompt) => {
|
|
447
|
+
const said = await run(prompt, { cwd: workspaceRoot, onProgress: () => {} });
|
|
448
|
+
if (!said.ok) return { failed: said.reason };
|
|
449
|
+
const parsed = parseBet(said.text);
|
|
450
|
+
return { parsed, check: parsed.error ? { ok: false, reasons: [parsed.error], formatOnly: false } : validateQuestion(parsed.bet, { source, avoid }) };
|
|
451
|
+
};
|
|
452
|
+
let r = await ask(buildQuestionPrompt(b, context, { answers, avoid }));
|
|
453
|
+
if (r.failed) return { ok: false, reason: r.failed };
|
|
454
|
+
if (!r.check.ok && r.check.formatOnly) {
|
|
455
|
+
r = await ask(buildQuestionRewrite(JSON.stringify(r.parsed.bet), r.check.reasons));
|
|
456
|
+
if (r.failed) return { ok: false, reason: r.failed };
|
|
457
|
+
}
|
|
458
|
+
if (!r.check.ok) return { ok: false, reason: `the question was dropped: ${r.check.reasons.slice(0, 3).join('; ')}` };
|
|
459
|
+
const q = r.parsed.bet;
|
|
460
|
+
const fact = typeof q.fact === 'string' && q.fact.trim() ? q.fact.trim() : null;
|
|
461
|
+
return { ok: true, question: { ask: q.ask.trim(), fact, choices: q.choices.map(c => c.trim()) } };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// The first of our own words in a line the owner reads, or null.
|
|
465
|
+
function ownerWord(text) {
|
|
466
|
+
const m = OWNER_BANNED.exec(String(text || ''));
|
|
467
|
+
return m ? m[0].toLowerCase() : null;
|
|
468
|
+
}
|
|
469
|
+
|
|
130
470
|
function ledgerLine(bets) {
|
|
131
471
|
if (!bets.length) return 'past bets: none yet';
|
|
132
472
|
const count = s => bets.filter(b => b.status === s).length;
|
|
@@ -150,6 +490,92 @@ function readNow(workspaceRoot) {
|
|
|
150
490
|
return { fact, text: lines.join('\n') };
|
|
151
491
|
}
|
|
152
492
|
|
|
493
|
+
// ---- what the project is about now: its goal, open missions, and journal ----------
|
|
494
|
+
|
|
495
|
+
// a source older than this is labeled stale, and the writers prefer fresher ones
|
|
496
|
+
const STALE_DAYS = 14;
|
|
497
|
+
const MISSIONS_SHOWN = 8;
|
|
498
|
+
const JOURNAL_LINES = 20;
|
|
499
|
+
const JOURNAL_CHARS = 1500;
|
|
500
|
+
|
|
501
|
+
// whole days from an ISO date (or "YYYY-MM-DD ...") to today, or null
|
|
502
|
+
function ageDays(when, today) {
|
|
503
|
+
const day = /(\d{4}-\d{2}-\d{2})/.exec(String(when || ''));
|
|
504
|
+
if (!day) return null;
|
|
505
|
+
return Math.round((Date.parse(`${today}T00:00:00Z`) - Date.parse(`${day[1]}T00:00:00Z`)) / 86400000);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function staleNote(age) {
|
|
509
|
+
return age != null && age > STALE_DAYS ? `stale: ${age} days old` : null;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// The goal: the endgame horizon in atris/TODO.md (what the boot shows as the
|
|
513
|
+
// goal) and the sentence set with atris goal, each with the day it was set.
|
|
514
|
+
function readGoal(workspaceRoot) {
|
|
515
|
+
const out = {};
|
|
516
|
+
try {
|
|
517
|
+
const todo = fs.readFileSync(path.join(workspaceRoot, 'atris', 'TODO.md'), 'utf8');
|
|
518
|
+
const section = (/##\s+Endgame\s*\n([\s\S]*?)(?=\n##|$)/.exec(todo) || [])[1] || '';
|
|
519
|
+
const horizon = (/\*\*Horizon:\*\*\s*(.+)/.exec(section) || [])[1];
|
|
520
|
+
const picked = (/\*\*Picked:\*\*\s*(.+)/.exec(section) || [])[1];
|
|
521
|
+
if (horizon && horizon.trim()) out.horizon = { text: clip(horizon, 240), at: picked ? picked.trim().slice(0, 10) : null };
|
|
522
|
+
} catch {
|
|
523
|
+
// no TODO.md here
|
|
524
|
+
}
|
|
525
|
+
try {
|
|
526
|
+
const board = JSON.parse(fs.readFileSync(path.join(workspaceRoot, '.atris', 'state', 'scoreboard.json'), 'utf8'));
|
|
527
|
+
if (board && typeof board.goal === 'string' && board.goal.trim()) out.goal = { text: clip(board.goal, 240), at: board.updated_at ? String(board.updated_at).slice(0, 10) : null };
|
|
528
|
+
} catch {
|
|
529
|
+
// no goal set
|
|
530
|
+
}
|
|
531
|
+
return out.horizon || out.goal ? out : null;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// a mission's objective as a plain title: its first sentence
|
|
535
|
+
function missionTitle(objective) {
|
|
536
|
+
return clip(String(objective || '').split(/\.\s/)[0].replace(/[.\s]+$/, ''), 100);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Open missions, freshest first, as plain titles with who owns each.
|
|
540
|
+
function openMissions(missions, today) {
|
|
541
|
+
const list = missions instanceof Map ? [...missions.values()] : Object.values(missions || {});
|
|
542
|
+
return list
|
|
543
|
+
.filter(m => m && OPEN_MISSION.has(String(m.status || '')) && m.objective)
|
|
544
|
+
.map((m) => {
|
|
545
|
+
const moved = [m.last_tick_at, m.updated_at, m.created_at].filter(Boolean).sort().pop();
|
|
546
|
+
return { title: missionTitle(m.objective), owner: m.owner || null, moved, age: ageDays(moved, today), running: m.status === 'running' };
|
|
547
|
+
})
|
|
548
|
+
// a run that says running but has not moved in a day is dead, not open work
|
|
549
|
+
.filter(m => !(m.running && m.age != null && m.age >= 1))
|
|
550
|
+
.sort((a, b) => String(b.moved).localeCompare(String(a.moved)))
|
|
551
|
+
.slice(0, MISSIONS_SHOWN);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// The newest day in atris/logs, its last lines, bounded.
|
|
555
|
+
function readJournal(workspaceRoot) {
|
|
556
|
+
const dir = path.join(workspaceRoot, 'atris', 'logs');
|
|
557
|
+
let newest = null;
|
|
558
|
+
try {
|
|
559
|
+
for (const year of fs.readdirSync(dir)) {
|
|
560
|
+
if (!/^\d{4}$/.test(year)) continue;
|
|
561
|
+
for (const f of fs.readdirSync(path.join(dir, year))) {
|
|
562
|
+
if (/^\d{4}-\d{2}-\d{2}\.md$/.test(f) && (!newest || f > newest.name)) newest = { name: f, file: path.join(dir, year, f) };
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
} catch {
|
|
566
|
+
return null;
|
|
567
|
+
}
|
|
568
|
+
if (!newest) return null;
|
|
569
|
+
let text;
|
|
570
|
+
try {
|
|
571
|
+
text = fs.readFileSync(newest.file, 'utf8');
|
|
572
|
+
} catch {
|
|
573
|
+
return null;
|
|
574
|
+
}
|
|
575
|
+
const lines = text.split(/\r?\n/).filter(l => l.trim()).slice(-JOURNAL_LINES);
|
|
576
|
+
return lines.length ? { day: newest.name.slice(0, 10), text: lines.join('\n').slice(-JOURNAL_CHARS) } : null;
|
|
577
|
+
}
|
|
578
|
+
|
|
153
579
|
// The weekly target and the season record, read the same way the board reads them.
|
|
154
580
|
function defaultSeasonFacts(workspaceRoot) {
|
|
155
581
|
const season = require('./gm-season');
|
|
@@ -488,7 +914,12 @@ function dayEndMs(day) {
|
|
|
488
914
|
// task db. Resolves { value } or { error, transient }; transient means try
|
|
489
915
|
// again later (npm has not counted the days yet, or the db is busy), not
|
|
490
916
|
// transient means never.
|
|
491
|
-
async function defaultReadMetric(metric, { workspaceRoot, start, end, target = {}, deps = {} }) {
|
|
917
|
+
async function defaultReadMetric(metric, { workspaceRoot, start, end, target = {}, deps = {}, ledgerFile = null }) {
|
|
918
|
+
if (metric === 'scoreboard') {
|
|
919
|
+
const board = readScoreboard(scoreboardPath(path.dirname(ledgerFile || ledgerPath())), workspaceRoot);
|
|
920
|
+
const hit = board && board.reports.find(r => String(r.at).slice(0, 10) >= end);
|
|
921
|
+
return hit ? { value: hit.value } : { error: 'the owner has not reported the number for this date yet', transient: true };
|
|
922
|
+
}
|
|
492
923
|
if (metric === 'installs') {
|
|
493
924
|
const pkg = deps.packageName !== undefined ? deps.packageName : packageName(workspaceRoot);
|
|
494
925
|
if (!pkg) return { error: 'no published package here to read installs from', transient: false };
|
|
@@ -511,8 +942,9 @@ async function defaultReadMetric(metric, { workspaceRoot, start, end, target = {
|
|
|
511
942
|
}
|
|
512
943
|
|
|
513
944
|
// Won when the number reached the goal.
|
|
514
|
-
function wonBet(metric, actual, goal) {
|
|
515
|
-
|
|
945
|
+
function wonBet(metric, actual, goal, target = {}) {
|
|
946
|
+
const up = metric === 'scoreboard' ? target.up !== false : METRICS[metric].up;
|
|
947
|
+
return up ? actual >= goal : actual <= goal;
|
|
516
948
|
}
|
|
517
949
|
|
|
518
950
|
// Scores every bet the team is doing once its check date is over: reads its
|
|
@@ -536,7 +968,7 @@ async function scoreBets(workspaceRoot, { ledgerFile = ledgerPath(), today = day
|
|
|
536
968
|
out = { error: metric && !METRICS[metric] ? `there is no way to read "${metric}"` : 'the bet has no target number to read', transient: false };
|
|
537
969
|
} else {
|
|
538
970
|
try {
|
|
539
|
-
out = await read(metric, { workspaceRoot, start, end, target: t, deps });
|
|
971
|
+
out = await read(metric, { workspaceRoot, start, end, target: t, deps, ledgerFile });
|
|
540
972
|
} catch (err) {
|
|
541
973
|
out = { error: String((err && err.message) || err), transient: true };
|
|
542
974
|
}
|
|
@@ -546,8 +978,8 @@ async function scoreBets(workspaceRoot, { ledgerFile = ledgerPath(), today = day
|
|
|
546
978
|
if (out && out.transient && late <= SCORE_GRACE_DAYS) continue;
|
|
547
979
|
Object.assign(row, { status: 'unreadable', why });
|
|
548
980
|
} 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 });
|
|
981
|
+
const window = metric === 'installs' ? `${start} to ${end}` : (metric === 'scoreboard' ? `reported on or after ${end}` : `landed by the end of ${end}, of ${plural(t.job_ids.length, 'job')} waiting at the start`);
|
|
982
|
+
Object.assign(row, { status: wonBet(metric, out.value, t.goal, t) ? 'won' : 'lost', actual: out.value, window });
|
|
551
983
|
}
|
|
552
984
|
appendLedger(ledgerFile, row);
|
|
553
985
|
scored.push({ ...row, title: b.bet.title });
|
|
@@ -563,7 +995,9 @@ async function scoreBets(workspaceRoot, { ledgerFile = ledgerPath(), today = day
|
|
|
563
995
|
function bundleMetrics(bundle) {
|
|
564
996
|
const s = (bundle && bundle.sources) || {};
|
|
565
997
|
const out = {};
|
|
566
|
-
if (s.
|
|
998
|
+
if (s.scoreboard) {
|
|
999
|
+
out.scoreboard = { baseline: s.scoreboard.baseline, best: s.scoreboard.best, reports: s.scoreboard.reports, up: s.scoreboard.up, name: s.scoreboard.name };
|
|
1000
|
+
} else if (s.installs) {
|
|
567
1001
|
const best = s.installs.best || null;
|
|
568
1002
|
out.installs = { baseline: s.installs.data.last_week, best: best ? best.best : null, weeks: best ? best.weeks : 0 };
|
|
569
1003
|
}
|
|
@@ -587,7 +1021,7 @@ async function readBundle(workspaceRoot, { deps = {}, emit = () => {}, ledgerFil
|
|
|
587
1021
|
} catch {
|
|
588
1022
|
// scoring is a side job; a failure never blocks the next bet
|
|
589
1023
|
}
|
|
590
|
-
const bundle = { workspace: workspaceRoot, sources: {}, facts: [] };
|
|
1024
|
+
const bundle = { workspace: workspaceRoot, today, sources: {}, facts: [] };
|
|
591
1025
|
const add = (key, fact, data) => {
|
|
592
1026
|
bundle.sources[key] = { fact, ...data };
|
|
593
1027
|
bundle.facts.push(fact);
|
|
@@ -596,8 +1030,11 @@ async function readBundle(workspaceRoot, { deps = {}, emit = () => {}, ledgerFil
|
|
|
596
1030
|
const skip = (key, why) => emit({ kind: 'skip', key, line: why });
|
|
597
1031
|
|
|
598
1032
|
const now = (deps.readNow || readNow)(workspaceRoot);
|
|
599
|
-
if (now)
|
|
600
|
-
|
|
1033
|
+
if (now) {
|
|
1034
|
+
const age = ageDays(/last updated:\s*(.+)$/im.exec(now.text || '') ? /last updated:\s*(.+)$/im.exec(now.text)[1] : null, today);
|
|
1035
|
+
const stale = staleNote(age);
|
|
1036
|
+
add('now', stale ? `${now.fact}, ${age} days ago, stale` : now.fact, { text: now.text, ...(stale ? { stale: age } : {}) });
|
|
1037
|
+
} else skip('now', 'no atris/now.md, left out');
|
|
601
1038
|
|
|
602
1039
|
let season = null;
|
|
603
1040
|
try {
|
|
@@ -707,6 +1144,42 @@ async function readBundle(workspaceRoot, { deps = {}, emit = () => {}, ledgerFil
|
|
|
707
1144
|
...(b.why ? { why: b.why } : {}),
|
|
708
1145
|
}));
|
|
709
1146
|
add('ledger', ledgerLine(bets), { bets: shown });
|
|
1147
|
+
|
|
1148
|
+
// what the project is about now: kept out of the facts list and the event
|
|
1149
|
+
// stream, read only when the files are there
|
|
1150
|
+
const goal = (deps.readGoal || readGoal)(workspaceRoot);
|
|
1151
|
+
if (goal) bundle.sources.goal = goal;
|
|
1152
|
+
let missionMap = null;
|
|
1153
|
+
try {
|
|
1154
|
+
missionMap = (deps.readMissions || defaultReadMissions)(workspaceRoot);
|
|
1155
|
+
} catch {
|
|
1156
|
+
missionMap = null;
|
|
1157
|
+
}
|
|
1158
|
+
const open = missionMap ? openMissions(missionMap, today) : [];
|
|
1159
|
+
if (open.length) bundle.sources.missions = { list: open };
|
|
1160
|
+
const journal = (deps.readJournal || readJournal)(workspaceRoot);
|
|
1161
|
+
if (journal) bundle.sources.journal = { ...journal, age: ageDays(journal.day, today) };
|
|
1162
|
+
|
|
1163
|
+
// the owner's own number: a reported one is the target, with the last
|
|
1164
|
+
// report as the baseline; an installs one keeps the npm numbers
|
|
1165
|
+
const board = readScoreboard(scoreboardPath(path.dirname(ledger)), workspaceRoot);
|
|
1166
|
+
if (board && board.source === 'reported' && board.reports.length) {
|
|
1167
|
+
const last = board.reports[board.reports.length - 1];
|
|
1168
|
+
const recent = board.reports.slice(-SCOREBOARD_REPORTS).map(r => r.value);
|
|
1169
|
+
const best = recent.length >= SCOREBOARD_MIN_REPORTS ? (board.up ? Math.max(...recent) : Math.min(...recent)) : null;
|
|
1170
|
+
add('scoreboard', `${board.name}: last reported ${commas(last.value)} on ${String(last.at).slice(0, 10)}`, { name: board.name, up: board.up, baseline: last.value, best, reports: recent.length });
|
|
1171
|
+
} else if (board) {
|
|
1172
|
+
bundle.sources.scoreboard_pick = { name: board.name, up: board.up, source: board.source };
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// what the owner said to aim at, from the question cards; kept out of the
|
|
1176
|
+
// facts list, so a why_now still has to quote a real number
|
|
1177
|
+
const focus = Object.values(readFocus(focusPath(path.dirname(ledger)), workspaceRoot))
|
|
1178
|
+
.sort((a, b) => String(a.at).localeCompare(String(b.at)));
|
|
1179
|
+
if (focus.length) {
|
|
1180
|
+
bundle.sources.focus = { answers: focus.map(a => ({ key: a.key, ask: a.ask, label: a.label, at: a.at })) };
|
|
1181
|
+
emit({ kind: 'source', key: 'focus', line: `the owner said: ${focus.map(a => a.label).join(', ')}` });
|
|
1182
|
+
}
|
|
710
1183
|
return bundle;
|
|
711
1184
|
}
|
|
712
1185
|
|
|
@@ -719,13 +1192,34 @@ function pastBetLine(b) {
|
|
|
719
1192
|
function bundleText(bundle) {
|
|
720
1193
|
const s = bundle.sources;
|
|
721
1194
|
const parts = ['## facts', ...bundle.facts.map(f => `- ${f}`)];
|
|
722
|
-
|
|
1195
|
+
const aged = (at, verb) => {
|
|
1196
|
+
const note = staleNote(ageDays(at, bundle.today));
|
|
1197
|
+
return at ? ` (${verb} ${at}${note ? `, ${note}` : ''})` : '';
|
|
1198
|
+
};
|
|
1199
|
+
if (s.goal) {
|
|
1200
|
+
parts.push('', '## the goal');
|
|
1201
|
+
if (s.goal.horizon) parts.push(`- horizon: ${s.goal.horizon.text}${aged(s.goal.horizon.at, 'picked')}`);
|
|
1202
|
+
if (s.goal.goal) parts.push(`- goal: ${s.goal.goal.text}${aged(s.goal.goal.at, 'set')}`);
|
|
1203
|
+
}
|
|
1204
|
+
if (s.now) parts.push('', `## atris/now.md${s.now.stale ? ` (stale: ${s.now.stale} days old, prefer the fresher sections)` : ''}`, s.now.text);
|
|
1205
|
+
if (s.missions) parts.push('', '## open missions', ...s.missions.list.map((m) => {
|
|
1206
|
+
const note = staleNote(m.age);
|
|
1207
|
+
const tail = [m.owner, note].filter(Boolean).join(', ');
|
|
1208
|
+
return `- ${m.title}${tail ? ` (${tail})` : ''}`;
|
|
1209
|
+
}));
|
|
1210
|
+
if (s.journal) parts.push('', `## latest journal, ${s.journal.day}${staleNote(s.journal.age) ? ` (${staleNote(s.journal.age)})` : ''}`, s.journal.text);
|
|
723
1211
|
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
1212
|
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
1213
|
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
|
-
|
|
1214
|
+
// what each member is on now: their freshest open mission
|
|
1215
|
+
const onNow = new Map();
|
|
1216
|
+
for (const m of (s.missions ? s.missions.list : [])) if (m.owner && !staleNote(m.age) && !onNow.has(m.owner)) onNow.set(m.owner, m.title);
|
|
1217
|
+
if (s.team) parts.push('', '## the team (a team step names one of these as its member)', ...s.team.members.map(m => (onNow.has(m.name) ? `- ${m.name}: ${m.role.replace(/[.\s]+$/, '')}. now on: ${onNow.get(m.name)}` : `- ${m.name}: ${m.role}`)));
|
|
727
1218
|
if (s.briefs) for (const b of s.briefs.briefs) parts.push('', `## research brief: ${b.name}`, b.text);
|
|
728
1219
|
if (s.ledger && s.ledger.bets.length) parts.push('', '## past bets', ...s.ledger.bets.map(pastBetLine));
|
|
1220
|
+
if (s.scoreboard) parts.push('', "## the owner's scoreboard", `- ${s.scoreboard.name} (${s.scoreboard.up ? 'higher' : 'lower'} is better); last reported ${commas(s.scoreboard.baseline)}`);
|
|
1221
|
+
else if (s.scoreboard_pick) parts.push('', "## the owner's scoreboard", `- ${s.scoreboard_pick.name} (${s.scoreboard_pick.up ? 'higher' : 'lower'} is better), read from ${s.scoreboard_pick.source === 'installs' ? 'npm installs' : 'his reports'}`);
|
|
1222
|
+
if (s.focus) parts.push('', '## what the owner asked for', ...s.focus.answers.map(a => `- ${a.ask} ${a.label} (said ${String(a.at).slice(0, 10)})`));
|
|
729
1223
|
return parts.join('\n');
|
|
730
1224
|
}
|
|
731
1225
|
|
|
@@ -733,6 +1227,12 @@ function bundleText(bundle) {
|
|
|
733
1227
|
|
|
734
1228
|
// The bar a goal has to clear, in words, for the prompt and the reject reason.
|
|
735
1229
|
function goalBar(metric, m) {
|
|
1230
|
+
if (metric === 'scoreboard') {
|
|
1231
|
+
const dir = m.up ? 'above' : 'below';
|
|
1232
|
+
return m.best == null
|
|
1233
|
+
? `with fewer than ${SCOREBOARD_MIN_REPORTS} reports any honest goal ${dir} the baseline ${commas(m.baseline)} passes`
|
|
1234
|
+
: `the best of the last ${m.reports} reports was ${commas(m.best)}, so the goal must be ${dir} ${commas(m.best)}`;
|
|
1235
|
+
}
|
|
736
1236
|
if (metric === 'installs') {
|
|
737
1237
|
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
1238
|
}
|
|
@@ -745,11 +1245,12 @@ const TEAM_ONLY_METRICS = new Set(['queue_landed']);
|
|
|
745
1245
|
function metricChoices(bundle) {
|
|
746
1246
|
const m = bundleMetrics(bundle);
|
|
747
1247
|
return Object.keys(m).filter(k => !TEAM_ONLY_METRICS.has(k)).map((k) => {
|
|
1248
|
+
if (k === 'scoreboard') return `"scoreboard" (${m[k].name}, the owner's own number; baseline ${m[k].baseline}; ${goalBar(k, m[k])})`;
|
|
748
1249
|
return `"${k}" (${METRICS[k].what}; baseline ${m[k].baseline}; ${goalBar(k, m[k])})`;
|
|
749
1250
|
});
|
|
750
1251
|
}
|
|
751
1252
|
|
|
752
|
-
function buildPrompt(bundle, { today, previousTitle = null, reason = null, previousAnswer = null, blocker = null, avoidTitles = [] } = {}) {
|
|
1253
|
+
function buildPrompt(bundle, { today, previousTitle = null, reason = null, previousAnswer = null, blocker = null, avoidTitles = [], reworkOf = null, ownerNote = null } = {}) {
|
|
753
1254
|
const choices = metricChoices(bundle);
|
|
754
1255
|
const lines = [
|
|
755
1256
|
'You are the strategist for a small software business. Below is everything we can read about it right now.',
|
|
@@ -775,11 +1276,18 @@ function buildPrompt(bundle, { today, previousTitle = null, reason = null, previ
|
|
|
775
1276
|
'- 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
1277
|
'- 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
1278
|
'- write in plain lowercase-friendly words, no hype, no em dashes.',
|
|
1279
|
+
'- a section marked stale is old: prefer the fresh ones, and never present a stale line as what is happening now.',
|
|
1280
|
+
'- never propose a trade, buying or selling stocks, shares, crypto, or any security, or moving money between accounts.',
|
|
778
1281
|
];
|
|
1282
|
+
lines.push(`- title, why_now, and expect are read by the owner, so they never use these words: ${OWNER_BANNED_WORDS.join(', ')}. say "idea" or a plain verb instead of bet, and say what changes for people, not how we work.`);
|
|
1283
|
+
if (bundle.sources && bundle.sources.focus) lines.push('- the bet must serve what the owner asked for (the last section below). when answers pull different ways, the newest wins.');
|
|
779
1284
|
// a skeptic named what blocks the last idea: the blocker is the next bet,
|
|
780
1285
|
// not one more step on the old idea
|
|
781
1286
|
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
1287
|
if (previousTitle) lines.push('', `Make it sharper, more specific, and different from: ${previousTitle}`);
|
|
1288
|
+
// what the owner typed on a card
|
|
1289
|
+
if (ownerNote && reworkOf) lines.push('', `The owner read the idea "${clip(reworkOf, 160)}" and said: "${clip(ownerNote, 280)}". Rework it so it fits what they said; it may keep the same deliverable.`);
|
|
1290
|
+
else if (ownerNote) lines.push('', `The owner said: "${clip(ownerNote, 280)}". The next idea must fit that.`);
|
|
783
1291
|
const avoid = avoidTitles.filter(t => t && t !== previousTitle);
|
|
784
1292
|
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
1293
|
if (previousAnswer) lines.push('', 'Your last answer was:', clip(previousAnswer, 4000));
|
|
@@ -805,6 +1313,19 @@ function claudeArgs(prompt, model = MODEL) {
|
|
|
805
1313
|
// Runs the model in its own process group. On timeout the whole group is
|
|
806
1314
|
// killed, so nothing it started keeps running. Streams real progress (tokens
|
|
807
1315
|
// thought, characters written) through onProgress.
|
|
1316
|
+
// Writers running now, by process group, so the server can end them when it
|
|
1317
|
+
// stops; otherwise a stopped server leaves them running on their own.
|
|
1318
|
+
const LIVE_WRITERS = new Map();
|
|
1319
|
+
|
|
1320
|
+
function stopWriters() {
|
|
1321
|
+
let n = 0;
|
|
1322
|
+
for (const [pid, kill] of LIVE_WRITERS) {
|
|
1323
|
+
try { kill(-pid, 'SIGTERM'); n += 1; } catch { /* already gone */ }
|
|
1324
|
+
}
|
|
1325
|
+
LIVE_WRITERS.clear();
|
|
1326
|
+
return n;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
808
1329
|
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
1330
|
return new Promise((resolve) => {
|
|
810
1331
|
const args = typeof command.args === 'function' ? command.args(prompt) : [...(command.args || []), prompt];
|
|
@@ -815,6 +1336,7 @@ function runModel(prompt, { command = { bin: 'claude', args: claudeArgs }, cwd,
|
|
|
815
1336
|
resolve({ ok: false, reason: `could not start the model: ${err.message}` });
|
|
816
1337
|
return;
|
|
817
1338
|
}
|
|
1339
|
+
if (child && child.pid) LIVE_WRITERS.set(child.pid, kill);
|
|
818
1340
|
let buf = '';
|
|
819
1341
|
let size = 0;
|
|
820
1342
|
let stderr = '';
|
|
@@ -864,6 +1386,7 @@ function runModel(prompt, { command = { bin: 'claude', args: claudeArgs }, cwd,
|
|
|
864
1386
|
if (done) return;
|
|
865
1387
|
done = true;
|
|
866
1388
|
clearTimeout(timer);
|
|
1389
|
+
LIVE_WRITERS.delete(child.pid);
|
|
867
1390
|
resolve(out);
|
|
868
1391
|
};
|
|
869
1392
|
child.on('error', err => finish({ ok: false, reason: `could not start the model: ${err.message}` }));
|
|
@@ -998,6 +1521,11 @@ function targetProblems(target, bundle, { allowTeamMetrics = false } = {}) {
|
|
|
998
1521
|
} else if (metric === 'installs') {
|
|
999
1522
|
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
1523
|
else if (known.best === null && !(target.goal > real)) out.push(`target goal ${commas(target.goal)} must be above the baseline ${commas(real)}`);
|
|
1524
|
+
} else if (metric === 'scoreboard') {
|
|
1525
|
+
const better = (a, b) => (known.up ? a > b : a < b);
|
|
1526
|
+
const dir = known.up ? 'above' : 'below';
|
|
1527
|
+
if (known.best != null && !better(target.goal, known.best)) out.push(`target goal ${commas(target.goal)} does not beat the best of the last ${known.reports} reports, ${commas(known.best)}; the goal must be ${dir} ${commas(known.best)}`);
|
|
1528
|
+
else if (known.best == null && !better(target.goal, real)) out.push(`target goal ${commas(target.goal)} must be ${dir} the baseline ${commas(real)}`);
|
|
1001
1529
|
} else if (metric === 'queue_landed') {
|
|
1002
1530
|
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
1531
|
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)}`);
|
|
@@ -1006,6 +1534,9 @@ function targetProblems(target, bundle, { allowTeamMetrics = false } = {}) {
|
|
|
1006
1534
|
return out;
|
|
1007
1535
|
}
|
|
1008
1536
|
|
|
1537
|
+
// a trade, buying or selling a security, or moving money: never an idea
|
|
1538
|
+
const TRADE = /\b(buy|sell|short|trade|trading)\b[^.]{0,40}\b(stocks?|shares|securit(?:y|ies)|options|crypto|bitcoin|tokens|bonds?|etfs?|index fund|positions?)\b|\b(wire|transfer|move|send)\b[^.]{0,30}\b(money|funds|cash)\b[^.]{0,30}\b(account|brokerage|bank|wallet)\b/i;
|
|
1539
|
+
|
|
1009
1540
|
function validateBet(bet, { bundle, today, previousTitle = null, avoidTitles = [], allowTeamMetrics = false } = {}) {
|
|
1010
1541
|
const reasons = [];
|
|
1011
1542
|
// format-only reasons: a field too long or with too many sentences. Only
|
|
@@ -1017,6 +1548,12 @@ function validateBet(bet, { bundle, today, previousTitle = null, avoidTitles = [
|
|
|
1017
1548
|
for (const k of ['title', 'why_now', 'expect', 'metric_source', 'cost']) {
|
|
1018
1549
|
if (!str(k)) reasons.push(`${k} is missing`);
|
|
1019
1550
|
}
|
|
1551
|
+
for (const k of ['title', 'why_now', 'expect']) {
|
|
1552
|
+
const word = ownerWord(str(k));
|
|
1553
|
+
if (word) fmt(`${k} uses "${word}", a word the owner never reads; say it plainly`);
|
|
1554
|
+
}
|
|
1555
|
+
const allText = [str('title'), str('why_now'), str('expect'), ...(Array.isArray(bet.steps) ? bet.steps.map(st => `${st && st.text} ${st && st.detail}`) : [])].join(' . ');
|
|
1556
|
+
if (TRADE.test(allText)) reasons.push('it proposes a trade or moving money; ideas never do that');
|
|
1020
1557
|
if (str('title')) {
|
|
1021
1558
|
const p = titleProblem(str('title'));
|
|
1022
1559
|
if (p && (p === 'the title is longer than 140 characters' || p === 'the title is more than one sentence')) fmt(p);
|
|
@@ -1082,6 +1619,7 @@ function addDays(today, n) {
|
|
|
1082
1619
|
|
|
1083
1620
|
// "goal 2,400, best week in 2 months 2,319" or "goal 3 of the 6 jobs with work landed"
|
|
1084
1621
|
function goalLine(target, known) {
|
|
1622
|
+
if (target.metric === 'scoreboard') return `goal ${commas(target.goal)}, last reported ${commas(target.baseline)}`;
|
|
1085
1623
|
if (target.metric === 'queue_landed') return `goal ${commas(target.goal)} of the ${commas(target.job_ids.length)} jobs with work landed`;
|
|
1086
1624
|
if (known && known.best !== null) return `goal ${commas(target.goal)}, best week in ${bestSpan(known.weeks)} ${commas(known.best)}`;
|
|
1087
1625
|
return `goal ${commas(target.goal)}, ${commas(target.baseline)} last week`;
|
|
@@ -1091,6 +1629,8 @@ function cleanBet(bet, today, bundle) {
|
|
|
1091
1629
|
const target = { metric: bet.target.metric, baseline: bet.target.baseline, goal: bet.target.goal };
|
|
1092
1630
|
// the jobs waiting right now, kept with the bet: only these can count
|
|
1093
1631
|
if (target.metric === 'queue_landed') target.job_ids = [...bundleMetrics(bundle).queue_landed.ids];
|
|
1632
|
+
// the owner's number keeps its name and which way is good, for scoring later
|
|
1633
|
+
if (target.metric === 'scoreboard') Object.assign(target, { name: bundleMetrics(bundle).scoreboard.name, up: bundleMetrics(bundle).scoreboard.up });
|
|
1094
1634
|
return {
|
|
1095
1635
|
title: bet.title.trim(),
|
|
1096
1636
|
why_now: bet.why_now.trim(),
|
|
@@ -1150,6 +1690,7 @@ function buildSkepticPrompt(bet, bundle, { today } = {}) {
|
|
|
1150
1690
|
'- 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
1691
|
'- 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
1692
|
'- would a sharp founder reading this find it obviously wrong, padded, or a waste of the week?',
|
|
1693
|
+
'- does it propose a trade, buying or selling stocks, shares, crypto, or any security, or moving money between accounts: fail it.',
|
|
1153
1694
|
'- 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
1695
|
'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
1696
|
'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.',
|
|
@@ -1206,11 +1747,14 @@ function skepticReason(v) {
|
|
|
1206
1747
|
// the reasons and the last answer go back, and only those fields may change.
|
|
1207
1748
|
// The text is never shortened in code.
|
|
1208
1749
|
function buildTrimPrompt(previousAnswer, reasons) {
|
|
1750
|
+
const worded = reasons.some(r => r.includes('a word the owner never reads'));
|
|
1209
1751
|
return [
|
|
1210
|
-
'Your last answer below broke only these length or sentence limits:',
|
|
1752
|
+
worded ? 'Your last answer below broke only these wording or length limits:' : 'Your last answer below broke only these length or sentence limits:',
|
|
1211
1753
|
...reasons.map(r => `- ${r}`),
|
|
1212
1754
|
'',
|
|
1213
|
-
|
|
1755
|
+
worded
|
|
1756
|
+
? 'Fix only those fields: shorten the long ones, and swap each word named for a plain one. 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.'
|
|
1757
|
+
: '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
1758
|
'Answer with the whole JSON object again, strict JSON only, no prose and no code fence.',
|
|
1215
1759
|
'',
|
|
1216
1760
|
'Your last answer:',
|
|
@@ -1228,8 +1772,12 @@ function trimChangedMore(before, after) {
|
|
|
1228
1772
|
return a.length !== b.length || a.some((s, i) => !b[i] || s.who !== b[i].who || s.public !== b[i].public);
|
|
1229
1773
|
}
|
|
1230
1774
|
|
|
1231
|
-
async function generateBet({ workspaceRoot, today, previousTitle = null, avoidTitles = [], emit = () => {}, deps = {}, ledgerFile, attemptsFile, allowTeamMetrics = false } = {}) {
|
|
1775
|
+
async function generateBet({ workspaceRoot, today, previousTitle = null, avoidTitles: avoidIn = [], reworkOf = null, ownerNote = null, emit = () => {}, onBundle = () => {}, deps = {}, ledgerFile, attemptsFile, allowTeamMetrics = false } = {}) {
|
|
1776
|
+
// a rework may stay close to the idea it reworks
|
|
1777
|
+
const avoidTitles = reworkOf ? avoidIn.filter(t => t !== reworkOf) : avoidIn;
|
|
1232
1778
|
const bundle = await readBundle(workspaceRoot, { deps, emit, ledgerFile, today });
|
|
1779
|
+
// the desk builds its question cards from these facts while the model works
|
|
1780
|
+
try { onBundle(bundle); } catch { /* the desk's copy is a convenience */ }
|
|
1233
1781
|
const run = deps.runModel || runModel;
|
|
1234
1782
|
// tests pass runSkeptic; a stubbed runModel without one is also used for the
|
|
1235
1783
|
// skeptic, so a test can never reach the real model by accident
|
|
@@ -1277,8 +1825,8 @@ async function generateBet({ workspaceRoot, today, previousTitle = null, avoidTi
|
|
|
1277
1825
|
// a blocked idea is not shown again as a patch
|
|
1278
1826
|
const avoid = [...avoidTitles, ...blocked];
|
|
1279
1827
|
const prompt = strategy === 'blocker'
|
|
1280
|
-
? buildPrompt(bundle, { today, previousTitle, blocker, avoidTitles: avoid })
|
|
1281
|
-
: buildPrompt(bundle, { today, previousTitle, reason, previousAnswer, avoidTitles: avoid });
|
|
1828
|
+
? buildPrompt(bundle, { today, previousTitle, blocker, avoidTitles: avoid, reworkOf, ownerNote })
|
|
1829
|
+
: buildPrompt(bundle, { today, previousTitle, reason, previousAnswer, avoidTitles: avoid, reworkOf, ownerNote });
|
|
1282
1830
|
let r = await ask(prompt, attempt, strategy, avoid);
|
|
1283
1831
|
if (r.failed) return { ok: false, reason: r.failed, bundle };
|
|
1284
1832
|
save(r);
|
|
@@ -1406,6 +1954,38 @@ function missionObjective(bet, { owner = null, betId = null } = {}) {
|
|
|
1406
1954
|
].filter(Boolean).join(' ');
|
|
1407
1955
|
}
|
|
1408
1956
|
|
|
1957
|
+
// How long a job may run: the hours its idea says it costs, plus a quarter
|
|
1958
|
+
// for slack, from 1 hour to a day, at 12 rounds of work an hour. An idea
|
|
1959
|
+
// that names no hours gets the deep tier's 3. Passed as --max-wall and
|
|
1960
|
+
// --max-ticks, so a long job is never cut off at the one-hour tier, with
|
|
1961
|
+
// --stop-when-done, so the hours are a ceiling and a job done early stops.
|
|
1962
|
+
const JOB_SLACK = 1.25;
|
|
1963
|
+
const JOB_MIN_HOURS = 1;
|
|
1964
|
+
const JOB_MAX_HOURS = 24;
|
|
1965
|
+
const JOB_DEFAULT_HOURS = 3;
|
|
1966
|
+
const TICKS_PER_HOUR = 12;
|
|
1967
|
+
|
|
1968
|
+
// a number as written: 5, 2.5, .5, or 1,000, never a tail of a longer one
|
|
1969
|
+
const NUM = '(?<![\\d.,])((?:\\d{1,3}(?:,\\d{3})+|\\d+)(?:\\.\\d+)?|\\.\\d+)';
|
|
1970
|
+
const numberOf = text => Number(String(text).replace(/,/g, ''));
|
|
1971
|
+
|
|
1972
|
+
function costHours(cost) {
|
|
1973
|
+
const t = String(cost || '').toLowerCase();
|
|
1974
|
+
const range = new RegExp(`${NUM}\\s*(?:-|to)\\s*${NUM}\\s*(?:hours?|hrs?)\\b`).exec(t);
|
|
1975
|
+
if (range) return numberOf(range[2]);
|
|
1976
|
+
const hours = new RegExp(`${NUM}\\s*(?:hours?|hrs?)\\b`).exec(t);
|
|
1977
|
+
if (hours) return numberOf(hours[1]);
|
|
1978
|
+
const minutes = new RegExp(`${NUM}\\s*(?:minutes?|mins?)\\b`).exec(t);
|
|
1979
|
+
if (minutes) return numberOf(minutes[1]) / 60;
|
|
1980
|
+
return null;
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
function jobTime(bet) {
|
|
1984
|
+
const hours = costHours(bet && bet.cost);
|
|
1985
|
+
const wall = hours ? Math.min(JOB_MAX_HOURS, Math.max(JOB_MIN_HOURS, hours * JOB_SLACK)) : JOB_DEFAULT_HOURS;
|
|
1986
|
+
return { seconds: Math.round(wall * 3600), ticks: Math.ceil(wall * TICKS_PER_HOUR) };
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1409
1989
|
const MISSION_VERIFY = 'git diff --check';
|
|
1410
1990
|
const MISSION_STOP = 'every team step is done with proof, and every your-yes step was asked, not done';
|
|
1411
1991
|
|
|
@@ -1414,11 +1994,15 @@ const MISSION_STOP = 'every team step is done with proof, and every your-yes ste
|
|
|
1414
1994
|
function missionStartArgs(bet, { owner, lane }, betId = null) {
|
|
1415
1995
|
// the owner steps are sealed into the mission record at start
|
|
1416
1996
|
const seal = require('./yes-lock').sealFor(bet.steps);
|
|
1417
|
-
|
|
1997
|
+
const time = jobTime(bet);
|
|
1998
|
+
return ['mission', 'start', missionObjective(bet, { owner, betId }), '--owner', owner, '--runner', 'claude', '--lane', lane, '--verify', bet.verify || MISSION_VERIFY, '--stop', MISSION_STOP, '--max-wall', String(time.seconds), '--max-ticks', String(time.ticks), '--stop-when-done', ...(seal.length ? ['--yes-lock', JSON.stringify(seal)] : []), '--json'];
|
|
1418
1999
|
}
|
|
1419
2000
|
|
|
1420
|
-
|
|
1421
|
-
|
|
2001
|
+
// the run gets the same time as the start, so the detached driver never
|
|
2002
|
+
// falls back to the one-hour default
|
|
2003
|
+
function missionRunArgs(missionId, bet) {
|
|
2004
|
+
const time = jobTime(bet);
|
|
2005
|
+
return ['mission', 'run', missionId, '--max-wall', String(time.seconds), '--max-ticks', String(time.ticks), '--stop-when-done', '--detach', '--json'];
|
|
1422
2006
|
}
|
|
1423
2007
|
|
|
1424
2008
|
function yesPingArgs(missionId, step) {
|
|
@@ -1452,6 +2036,8 @@ function makeCliRunner({ cliPath, cwd, env = process.env, timeoutMs = 60000 }) {
|
|
|
1452
2036
|
|
|
1453
2037
|
const JOB_POLL_MS = 25000;
|
|
1454
2038
|
const OPEN_MISSION = new Set(['planning', 'running', 'ready', 'paused', 'blocked']);
|
|
2039
|
+
// an open mission that has not moved in this long is not working
|
|
2040
|
+
const STALL_MS = 2 * 60 * 60 * 1000;
|
|
1455
2041
|
|
|
1456
2042
|
// Every mission in this workspace, read-only, through the same reader the
|
|
1457
2043
|
// mission command uses (it only reads .atris/state/missions.jsonl).
|
|
@@ -1470,6 +2056,39 @@ function defaultReadReceipt(mission, workspaceRoot) {
|
|
|
1470
2056
|
}
|
|
1471
2057
|
}
|
|
1472
2058
|
|
|
2059
|
+
// "14 hours", "3 days": how long a stalled job has been still
|
|
2060
|
+
function agoWords(ms) {
|
|
2061
|
+
const h = Math.max(1, Math.round(ms / 3600000));
|
|
2062
|
+
return h < 48 ? `${h} hours` : `${Math.round(h / 24)} days`;
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
// "oct 21": a day the way the owner says it
|
|
2066
|
+
const MONTH_NAMES = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
|
|
2067
|
+
function monthDay(at) {
|
|
2068
|
+
const d = new Date(at);
|
|
2069
|
+
return `${MONTH_NAMES[d.getMonth()]} ${d.getDate()}`;
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
// "10 minutes", "2 hours", "3 days": a plain span of time
|
|
2073
|
+
function spanWords(ms) {
|
|
2074
|
+
const m = Math.max(0, Math.round(ms / 60000));
|
|
2075
|
+
if (m < 1) return 'less than a minute';
|
|
2076
|
+
if (m < 60) return `${m} ${m === 1 ? 'minute' : 'minutes'}`;
|
|
2077
|
+
const h = Math.round(m / 60);
|
|
2078
|
+
if (h < 48) return `${h} ${h === 1 ? 'hour' : 'hours'}`;
|
|
2079
|
+
return `${Math.round(h / 24)} days`;
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
// "about 4 hours left", "about 20 minutes left"
|
|
2083
|
+
function leftWords(ms) {
|
|
2084
|
+
const hours = ms / 3600000;
|
|
2085
|
+
if (hours >= 1) { const h = Math.round(hours); return `about ${h} ${h === 1 ? 'hour' : 'hours'} left`; }
|
|
2086
|
+
return `about ${Math.max(5, Math.round(ms / 300000) * 5)} minutes left`;
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
// a job whose run hit its wall or its last round, as the mission records it
|
|
2090
|
+
const OUT_OF_TIME = /wall-exceeded|max-ticks-reached/;
|
|
2091
|
+
|
|
1473
2092
|
function ageLabel(ms) {
|
|
1474
2093
|
const m = Math.max(0, Math.floor(ms / 60000));
|
|
1475
2094
|
if (m < 1) return 'just started';
|
|
@@ -1480,9 +2099,10 @@ function ageLabel(ms) {
|
|
|
1480
2099
|
}
|
|
1481
2100
|
|
|
1482
2101
|
// One started bet and its mission, as the deck sees it:
|
|
1483
|
-
// working the mission is open
|
|
2102
|
+
// working the mission is open, asks nothing of the owner, and moved in the last 2 hours
|
|
1484
2103
|
// yes the mission has open asks for the owner (one card per ask)
|
|
1485
|
-
// done the mission finished (complete) or halted (stopped)
|
|
2104
|
+
// done the mission finished (complete) or halted (stopped), or it is
|
|
2105
|
+
// open but has not moved in over 2 hours (stalled, a halt)
|
|
1486
2106
|
// gone no such mission here (it is not counted as working)
|
|
1487
2107
|
// done carries what changed, the proof, and the check, only as the receipt
|
|
1488
2108
|
// says them; with no receipt it says so.
|
|
@@ -1495,6 +2115,9 @@ function jobState(row, mission, receipt, nowMs) {
|
|
|
1495
2115
|
.map((ask, index) => ({ index, text: String(typeof ask === 'string' ? ask : (ask && ask.text) || '').trim(), answered: Boolean(ask && typeof ask === 'object' && ask.answered_at) }))
|
|
1496
2116
|
.filter(a => a.text && !a.answered);
|
|
1497
2117
|
const status = String(mission.status || '');
|
|
2118
|
+
const outOfTime = OUT_OF_TIME.test([mission.last_tick_reason, mission.pause_reason, mission.stop_reason].join(' '));
|
|
2119
|
+
// a round that left a receipt made progress
|
|
2120
|
+
const progress = Boolean(mission.receipt_path);
|
|
1498
2121
|
if (status === 'complete' || status === 'stopped') {
|
|
1499
2122
|
const result = receipt && receipt.result && typeof receipt.result === 'object' ? receipt.result : null;
|
|
1500
2123
|
const landing = result && result.landing && typeof result.landing === 'object' ? result.landing : null;
|
|
@@ -1512,13 +2135,43 @@ function jobState(row, mission, receipt, nowMs) {
|
|
|
1512
2135
|
changed,
|
|
1513
2136
|
proof: receipt ? (mission.receipt_path || null) : null,
|
|
1514
2137
|
checked,
|
|
1515
|
-
// a check that failed, or a halt nothing proved, goes back to the team on
|
|
2138
|
+
// a check that failed, or a halt nothing proved, goes back to the team on its own
|
|
1516
2139
|
failed: checked === 'failed' || (stopped && checked !== 'passed'),
|
|
2140
|
+
out_of_time: stopped && outOfTime,
|
|
2141
|
+
progress,
|
|
1517
2142
|
};
|
|
1518
2143
|
}
|
|
1519
2144
|
const base = { bet_id: row.id, mission_id: mission.id, title, owner, since, running_for: since ? ageLabel(nowMs - since) : null, status };
|
|
1520
2145
|
if (asks.length) return { ...base, kind: 'yes', asks };
|
|
1521
|
-
|
|
2146
|
+
if (!OPEN_MISSION.has(status)) return { ...base, kind: 'gone' };
|
|
2147
|
+
// paused because its time or its rounds ran out: it goes on now, no two-hour wait
|
|
2148
|
+
if (status === 'paused' && OUT_OF_TIME.test(String(mission.pause_reason || ''))) {
|
|
2149
|
+
return { kind: 'done', bet_id: row.id, mission_id: mission.id, title, owner, halted: true, stop_reason: 'it ran out of time', changed: null, proof: mission.receipt_path || null, checked: 'none', failed: true, out_of_time: true, progress };
|
|
2150
|
+
}
|
|
2151
|
+
// the newest of its last tick and its last update is when it last moved
|
|
2152
|
+
const moved = Math.max(...[mission.last_tick_at, mission.updated_at, mission.created_at].map(t => Date.parse(t || '') || 0));
|
|
2153
|
+
if (moved && nowMs - moved > STALL_MS) {
|
|
2154
|
+
return {
|
|
2155
|
+
kind: 'done',
|
|
2156
|
+
bet_id: row.id,
|
|
2157
|
+
mission_id: mission.id,
|
|
2158
|
+
title,
|
|
2159
|
+
owner,
|
|
2160
|
+
halted: true,
|
|
2161
|
+
stalled: true,
|
|
2162
|
+
stop_reason: `stopped moving ${agoWords(nowMs - moved)} ago, nothing finished`,
|
|
2163
|
+
changed: null,
|
|
2164
|
+
// the last tick's receipt, when there is one; a stalled job never reached its final check
|
|
2165
|
+
proof: mission.receipt_path || null,
|
|
2166
|
+
checked: 'none',
|
|
2167
|
+
failed: true,
|
|
2168
|
+
out_of_time: outOfTime,
|
|
2169
|
+
progress,
|
|
2170
|
+
};
|
|
2171
|
+
}
|
|
2172
|
+
const wall = Number(mission.max_wall_seconds) || Number(mission.budget_contract && mission.budget_contract.requested_seconds) || 0;
|
|
2173
|
+
const leftMs = wall && since ? since + wall * 1000 - nowMs : 0;
|
|
2174
|
+
return { ...base, kind: 'working', ...(leftMs > 0 ? { left: leftWords(leftMs) } : {}) };
|
|
1522
2175
|
}
|
|
1523
2176
|
|
|
1524
2177
|
// Who reviews finished work: the lead of the roster's review job (atris
|
|
@@ -1575,18 +2228,37 @@ function currentMember(betId, owner, handoffs) {
|
|
|
1575
2228
|
return hits.length ? hits[hits.length - 1].to : owner;
|
|
1576
2229
|
}
|
|
1577
2230
|
|
|
1578
|
-
//
|
|
1579
|
-
function
|
|
1580
|
-
const
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
2231
|
+
// a line that ends like a sentence
|
|
2232
|
+
function sentence(text) {
|
|
2233
|
+
const t = String(text || '').trim();
|
|
2234
|
+
return t && !/[.!?]$/.test(t) ? `${t}.` : t;
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
// One short line of why for a choice: the idea's expected result, without
|
|
2238
|
+
// "if it works", ending at the first comma after about 40 characters, so it
|
|
2239
|
+
// is a whole clause and never cut mid word.
|
|
2240
|
+
function choiceWhy(expect) {
|
|
2241
|
+
const first = String(expect || '').trim().split(/\.\s/)[0].replace(/^if it works,\s*/i, '').replace(/[.\s]+$/, '');
|
|
2242
|
+
const cut = first.indexOf(', ', 40);
|
|
2243
|
+
return sentence(cut > 0 ? first.slice(0, cut) : clip(first, 110));
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
// A business move, not plumbing: its target is a number we can read and
|
|
2247
|
+
// not one that only measures the team.
|
|
2248
|
+
function isBusinessMove(bet) {
|
|
2249
|
+
const metric = bet && bet.target && bet.target.metric;
|
|
2250
|
+
return Boolean(metric && METRICS[metric] && !TEAM_ONLY_METRICS.has(metric));
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
// What went wrong on the last run, in words for the team's next mission.
|
|
2254
|
+
function failWhy(row) {
|
|
2255
|
+
if (row.mission_out_of_time) return 'ran out of time before its check passed; pick up where it left off';
|
|
2256
|
+
if (row.mission_stalled) return row.mission_stop_reason || 'stopped moving, nothing finished';
|
|
2257
|
+
if (row.mission_halted) return `halted: ${row.mission_stop_reason || 'it stopped before it finished'}`;
|
|
2258
|
+
const r = row.review;
|
|
2259
|
+
if (r && r.verdict === 'fail') return `did not pass: ${String(r.reason || '').replace(/[.\s]+$/, '')}`;
|
|
2260
|
+
if (r && r.verdict === 'could not run') return `could not be looked over: ${String(r.reason || '').replace(/[.\s]+$/, '')}`;
|
|
2261
|
+
return 'did not pass its check';
|
|
1590
2262
|
}
|
|
1591
2263
|
|
|
1592
2264
|
// Why a bet made under older rules can no longer be shown, or null. Only the
|
|
@@ -1600,6 +2272,10 @@ function queuedProblem(bet, team) {
|
|
|
1600
2272
|
if (TEAM_ONLY_METRICS.has(metric)) return `its target ${metric} measures team plumbing, not a business move`;
|
|
1601
2273
|
const vp = verifyProblem(bet.verify);
|
|
1602
2274
|
if (vp) return vp;
|
|
2275
|
+
for (const k of ['title', 'why_now', 'expect']) {
|
|
2276
|
+
const word = ownerWord(bet[k]);
|
|
2277
|
+
if (word) return `its ${k} uses "${word}", a word the owner never reads`;
|
|
2278
|
+
}
|
|
1603
2279
|
if (team) {
|
|
1604
2280
|
const names = new Set(team.map(m => m.name));
|
|
1605
2281
|
const i = bet.steps.findIndex(st => st && st.who === 'team' && !(typeof st.member === 'string' && names.has(st.member)));
|
|
@@ -1617,8 +2293,10 @@ const MAX_IN_FLIGHT = 1;
|
|
|
1617
2293
|
const SESSION_IN_FLIGHT_CAP = 3;
|
|
1618
2294
|
const STALE_MS = 6 * 60 * 60 * 1000;
|
|
1619
2295
|
const RECENT_TITLES = 20;
|
|
1620
|
-
// two failed background generations in a row
|
|
2296
|
+
// two failed background generations in a row pause the prefetch, and a timer
|
|
2297
|
+
// tries again after 10 minutes
|
|
1621
2298
|
const MAX_BACKGROUND_FAILURES = 2;
|
|
2299
|
+
const BACKOFF_MS = 10 * 60 * 1000;
|
|
1622
2300
|
|
|
1623
2301
|
// deps:
|
|
1624
2302
|
// workspaceRoot, ledgerFile, attemptsFile where
|
|
@@ -1627,10 +2305,39 @@ const MAX_BACKGROUND_FAILURES = 2;
|
|
|
1627
2305
|
// log(row) receipt row for gm-phone-actions.jsonl
|
|
1628
2306
|
// now() clock
|
|
1629
2307
|
// readMissions(root), readReceipt(m, root) mission state, read-only (tests stub)
|
|
1630
|
-
// pollMs how often to read job status (off when unset)
|
|
2308
|
+
// pollMs how often to read job status and top up the queue (off when unset)
|
|
2309
|
+
// warmOnCreate start filling the queue at once (default on)
|
|
2310
|
+
// setTimer(fn, ms) the backoff timer (tests stub; unref'd by default)
|
|
2311
|
+
// readBundle() -> Promise<bundle>, the facts for question cards when no generation reads them
|
|
2312
|
+
// choices 2 or more ideas ready show as one card to pick from (default on)
|
|
2313
|
+
// driverAlive(mission) is a process really running this mission (tests stub)
|
|
2314
|
+
// scoreboard ask for the owner's number and its weekly value (gm phone turns it on)
|
|
2315
|
+
// suggestScoreboard(opts) -> { ok, choices }, the numbers to offer (tests stub)
|
|
2316
|
+
// readSaid(opts) -> { ok, kind, reply, suggestions }, what the owner typed (tests stub)
|
|
2317
|
+
// sayTimeoutMs how long a read may take before the plain fallback (15 s)
|
|
1631
2318
|
function createBetDesk(deps) {
|
|
1632
2319
|
const now = deps.now || Date.now;
|
|
1633
|
-
const
|
|
2320
|
+
const setTimer = deps.setTimer || ((fn, ms) => { const t = setTimeout(fn, ms); if (t.unref) t.unref(); return t; });
|
|
2321
|
+
// is a process really running this mission: its driver's pid is alive
|
|
2322
|
+
const driverAlive = deps.driverAlive || ((m) => {
|
|
2323
|
+
try { return require(path.join(__dirname, '..', 'commands', 'mission.js')).missionDriverHealth(m, deps.workspaceRoot).alive === true; } catch { return false; }
|
|
2324
|
+
});
|
|
2325
|
+
const focusFile = focusPath(path.dirname(deps.ledgerFile));
|
|
2326
|
+
const questionsFile = questionsPath(path.dirname(deps.ledgerFile));
|
|
2327
|
+
const scoreboardFile = scoreboardPath(path.dirname(deps.ledgerFile));
|
|
2328
|
+
// a desk with a stubbed idea writer never reaches the real reader either
|
|
2329
|
+
const readFn = deps.readSaid || (deps.generate
|
|
2330
|
+
? (async () => ({ ok: false, reason: 'no reader here' }))
|
|
2331
|
+
: (opts => readSaid({ ...opts, workspaceRoot: deps.workspaceRoot, bundle: state.bundle, deps: deps.generatorDeps || {} })));
|
|
2332
|
+
const suggestNumbers = deps.suggestScoreboard || (deps.generate
|
|
2333
|
+
? (() => new Promise(() => {}))
|
|
2334
|
+
: (opts => generateScoreboardChoices({ ...opts, deps: deps.generatorDeps || {}, ledgerFile: deps.ledgerFile, today: dayKey(now()) })));
|
|
2335
|
+
// a desk with a stubbed idea writer never reaches the real question model
|
|
2336
|
+
const writeQuestion = deps.generateQuestion || (deps.generate
|
|
2337
|
+
? (() => new Promise(() => {}))
|
|
2338
|
+
: (opts => generateQuestion({ ...opts, deps: deps.generatorDeps || {}, ledgerFile: deps.ledgerFile, today: dayKey(now()) })));
|
|
2339
|
+
// the real writer starts on the next turn, so its first reads never hold up a tap
|
|
2340
|
+
const generate = deps.generate || (opts => new Promise(resolve => setImmediate(resolve)).then(() => generateBet({ ...opts, deps: deps.generatorDeps || {}, ledgerFile: deps.ledgerFile, attemptsFile: deps.attemptsFile })));
|
|
1634
2341
|
const log = deps.log || (() => {});
|
|
1635
2342
|
const state = {
|
|
1636
2343
|
status: 'idle',
|
|
@@ -1639,13 +2346,28 @@ function createBetDesk(deps) {
|
|
|
1639
2346
|
inFlight: 0,
|
|
1640
2347
|
maxInFlight: 0,
|
|
1641
2348
|
gen: null,
|
|
1642
|
-
|
|
2349
|
+
// a sharper idea asked for, made in the background like any other
|
|
1643
2350
|
pendingSharper: null,
|
|
1644
2351
|
topping: false,
|
|
1645
2352
|
failures: 0,
|
|
2353
|
+
backoffUntil: 0,
|
|
2354
|
+
retry: null,
|
|
2355
|
+
// the last bundle a generation read, for the question cards
|
|
2356
|
+
bundle: deps.bundle || null,
|
|
2357
|
+
// questions passed over this session, so a re-tap asks the next one
|
|
2358
|
+
passedQuestions: new Set(),
|
|
2359
|
+
// the question writer: one at a time, resting after failures like the idea writer
|
|
2360
|
+
qInFlight: null,
|
|
2361
|
+
// the numbers the model suggests for the scoreboard question
|
|
2362
|
+
suggestions: null,
|
|
2363
|
+
// the conversation on the card or member talk on screen: { id, turns }
|
|
2364
|
+
thread: { id: null, turns: [] },
|
|
2365
|
+
sInFlight: null,
|
|
2366
|
+
qFailures: 0,
|
|
2367
|
+
qBackoffUntil: 0,
|
|
2368
|
+
qRetry: null,
|
|
1646
2369
|
run: 0,
|
|
1647
2370
|
error: null,
|
|
1648
|
-
lastFailed: null,
|
|
1649
2371
|
mission: null,
|
|
1650
2372
|
started: [],
|
|
1651
2373
|
// the started bets' missions as of the last read: working ones, and open
|
|
@@ -1655,6 +2377,8 @@ function createBetDesk(deps) {
|
|
|
1655
2377
|
seenYes: new Set(),
|
|
1656
2378
|
// reviews running now, by bet id
|
|
1657
2379
|
reviews: new Map(),
|
|
2380
|
+
// failed jobs being sent back to the team now, by bet id
|
|
2381
|
+
retrying: new Map(),
|
|
1658
2382
|
};
|
|
1659
2383
|
|
|
1660
2384
|
const iso = () => new Date(now()).toISOString();
|
|
@@ -1758,21 +2482,34 @@ function createBetDesk(deps) {
|
|
|
1758
2482
|
const finished = mission && (mission.status === 'complete' || mission.status === 'stopped');
|
|
1759
2483
|
const receipt = finished ? (deps.readReceipt || defaultReadReceipt)(mission, deps.workspaceRoot) : null;
|
|
1760
2484
|
const job = jobState(row, mission, receipt, now());
|
|
2485
|
+
// its process is alive (a laptop that slept makes the times look old): working, never resent
|
|
2486
|
+
if (job.kind === 'done' && (job.failed || job.halted) && driverAlive(mission)) {
|
|
2487
|
+
working.push({ kind: 'working', bet_id: row.id, mission_id: mission.id, title: job.title, owner: currentMember(row.id, job.owner, handoffs()) });
|
|
2488
|
+
continue;
|
|
2489
|
+
}
|
|
2490
|
+
// still because the guard holds a step for the owner's yes: that is a yes card, not a halt
|
|
2491
|
+
const held = job.stalled ? require('./yes-lock').askedYesLocks(lockDir, mission.id) : [];
|
|
2492
|
+
if (held.length) {
|
|
2493
|
+
for (const lock of held) 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}` });
|
|
2494
|
+
continue;
|
|
2495
|
+
}
|
|
1761
2496
|
// finished with nothing failed: the roster's reviewer looks before it counts as done
|
|
1762
2497
|
if (job.kind === 'done' && !job.failed && !job.halted) {
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
working.push({ ...job, kind: 'working', owner: `${engine || 'the reviewer'} (reviewing)`, running_for: null });
|
|
2498
|
+
startReview(row, job, reviewerName());
|
|
2499
|
+
working.push({ ...job, kind: 'working', owner: currentMember(row.id, job.owner, handoffs()) });
|
|
1766
2500
|
continue;
|
|
1767
2501
|
}
|
|
1768
2502
|
if (job.kind === 'done') {
|
|
1769
2503
|
record(row.id, {
|
|
1770
2504
|
mission_result: job.failed ? 'failed' : 'done',
|
|
1771
|
-
mission_status: mission.status,
|
|
2505
|
+
mission_status: job.stalled ? 'stalled' : mission.status,
|
|
1772
2506
|
mission_checked: job.checked,
|
|
1773
2507
|
mission_changed: job.changed,
|
|
1774
2508
|
mission_proof: job.proof,
|
|
1775
2509
|
mission_halted: job.halted,
|
|
2510
|
+
mission_stalled: Boolean(job.stalled),
|
|
2511
|
+
mission_out_of_time: Boolean(job.out_of_time),
|
|
2512
|
+
mission_progress: Boolean(job.progress),
|
|
1776
2513
|
mission_stop_reason: job.stop_reason,
|
|
1777
2514
|
finished_seen_at: iso(),
|
|
1778
2515
|
});
|
|
@@ -1787,12 +2524,53 @@ function createBetDesk(deps) {
|
|
|
1787
2524
|
for (const ask of job.asks) yes.push({ ...job, ask_index: ask.index, ask: ask.text, key: `${job.mission_id}:${ask.index}` });
|
|
1788
2525
|
}
|
|
1789
2526
|
}
|
|
1790
|
-
|
|
2527
|
+
const expectOf = new Map(readLedger(deps.ledgerFile, deps.workspaceRoot).map(r => [r.id, r.bet && r.bet.expect]));
|
|
2528
|
+
state.jobs = { working, yes: yes.map(y => ({ ...y, expect: expectOf.get(y.bet_id) || null })), at: now() };
|
|
2529
|
+
retryFailed();
|
|
1791
2530
|
return state.jobs;
|
|
1792
2531
|
}
|
|
1793
2532
|
|
|
2533
|
+
// A job that halted, stalled, or did not pass is never a card: it goes back
|
|
2534
|
+
// to the team once on its own, and the second time it is closed quietly
|
|
2535
|
+
// with why. A bet made under older rules (plumbing) is closed at once.
|
|
2536
|
+
function retryFailed() {
|
|
2537
|
+
let missionMap;
|
|
2538
|
+
const missionOf = (id) => {
|
|
2539
|
+
if (missionMap === undefined) {
|
|
2540
|
+
try { missionMap = (deps.readMissions || defaultReadMissions)(deps.workspaceRoot); } catch { missionMap = null; }
|
|
2541
|
+
}
|
|
2542
|
+
return missionMap instanceof Map ? missionMap.get(id) : (missionMap || {})[id];
|
|
2543
|
+
};
|
|
2544
|
+
for (const row of readLedger(deps.ledgerFile, deps.workspaceRoot)) {
|
|
2545
|
+
if (row.mission_result !== 'failed' || row.done_seen_at || state.retrying.has(row.id)) continue;
|
|
2546
|
+
// its process turned out to be alive: it is working, so put it back and never resend it
|
|
2547
|
+
const live = row.mission_id ? missionOf(row.mission_id) : null;
|
|
2548
|
+
if (live && driverAlive(live)) {
|
|
2549
|
+
record(row.id, { mission_result: null, mission_halted: null, mission_stalled: null, mission_out_of_time: null, mission_progress: null, mission_stop_reason: null });
|
|
2550
|
+
log({ card: 'bet', choice: 'still_running', command: null, ran: 'in process', exit_code: 0, bet_id: row.id, mission_id: row.mission_id });
|
|
2551
|
+
continue;
|
|
2552
|
+
}
|
|
2553
|
+
const why = failWhy(row);
|
|
2554
|
+
const problem = row.bet ? queuedProblem(row.bet, team) : 'it is not a whole bet';
|
|
2555
|
+
if (problem) { close(row, problem); continue; }
|
|
2556
|
+
// up to two more rounds while each one makes progress; one with none closes
|
|
2557
|
+
const rounds = Number(row.auto_sent_back) || 0;
|
|
2558
|
+
if (rounds >= 2) { close(row, `${why}, after two more rounds`); continue; }
|
|
2559
|
+
if (rounds === 1 && !row.mission_progress) { close(row, `${why}, after one try again`); continue; }
|
|
2560
|
+
const run = sendBackJob(row, why)
|
|
2561
|
+
.catch(err => close(row, `it could not go back to the team: ${clip(String((err && err.message) || err), 120)}`))
|
|
2562
|
+
.finally(() => state.retrying.delete(row.id));
|
|
2563
|
+
state.retrying.set(row.id, run);
|
|
2564
|
+
}
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2567
|
+
function close(row, reason) {
|
|
2568
|
+
record(row.id, { mission_result: 'closed', closed_reason: reason, closed_at: iso(), done_seen_at: iso() });
|
|
2569
|
+
log({ card: 'bet', choice: 'close', command: null, ran: 'in process', exit_code: 0, bet_id: row.id, mission_id: row.mission_id, why: reason });
|
|
2570
|
+
}
|
|
2571
|
+
|
|
1794
2572
|
// One review per finished job at a time. A pass makes it done; a fail makes
|
|
1795
|
-
// it failed (
|
|
2573
|
+
// it failed (it goes back to the team on its own); a review that cannot run is tried again
|
|
1796
2574
|
// on the next read, and after REVIEW_TRIES it counts as failed with why.
|
|
1797
2575
|
function startReview(row, job, engine) {
|
|
1798
2576
|
if (state.reviews.has(row.id)) return;
|
|
@@ -1811,29 +2589,20 @@ function createBetDesk(deps) {
|
|
|
1811
2589
|
const v = res.code === 0 ? reviewVerdict(res.stdout) : null;
|
|
1812
2590
|
if (!v) return gaveUp(res.code === 0 ? 'the reviewer gave no pass or fail' : `the review exited with code ${res.code}`);
|
|
1813
2591
|
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(() =>
|
|
2592
|
+
}).catch(err => gaveUp(String((err && err.message) || err))).finally(() => {
|
|
2593
|
+
state.reviews.delete(row.id);
|
|
2594
|
+
// a fail goes back to the team now, not on the next read
|
|
2595
|
+
retryFailed();
|
|
2596
|
+
});
|
|
1815
2597
|
state.reviews.set(row.id, run);
|
|
1816
2598
|
}
|
|
1817
2599
|
|
|
1818
|
-
// Finished
|
|
2600
|
+
// Finished business moves not yet seen, from the ledger, so they survive a
|
|
2601
|
+
// restart. Plumbing and failed work never become a card.
|
|
1819
2602
|
function doneJobs() {
|
|
1820
2603
|
return readLedger(deps.ledgerFile, deps.workspaceRoot)
|
|
1821
|
-
.filter(row =>
|
|
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
|
-
}));
|
|
2604
|
+
.filter(row => row.mission_result === 'done' && !row.done_seen_at && isBusinessMove(row.bet))
|
|
2605
|
+
.map(row => ({ kind: 'done', bet_id: row.id, mission_id: row.mission_id, title: row.bet.title, owner: row.owner || 'the team', bet: row.bet }));
|
|
1837
2606
|
}
|
|
1838
2607
|
|
|
1839
2608
|
// The next card that is not a bet: a yes the owner owes comes first, then
|
|
@@ -1854,20 +2623,231 @@ function createBetDesk(deps) {
|
|
|
1854
2623
|
log({ card: 'bet', choice: 'show', command: null, ran: 'in process', exit_code: 0, kind: item.kind, bet_id: item.job.bet_id });
|
|
1855
2624
|
}
|
|
1856
2625
|
|
|
2626
|
+
// ---- the scoreboard: the owner's own number ----
|
|
2627
|
+
|
|
2628
|
+
const SCOREBOARD_ASK = 'what number tells you this is going well?';
|
|
2629
|
+
// questions kept here, not in the questions file
|
|
2630
|
+
const LOCAL_QUESTIONS = new Set([FALLBACK_QUESTION.key, 'scoreboard']);
|
|
2631
|
+
const LOWER_IS_BETTER = /\b(fewer|less|lower|reduce|down|churn|bugs?|costs?|spend|refunds?|complaints?)\b/i;
|
|
2632
|
+
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
2633
|
+
|
|
2634
|
+
function fallbackNumbers() {
|
|
2635
|
+
const list = [];
|
|
2636
|
+
if (state.bundle && state.bundle.sources && state.bundle.sources.installs) list.push({ name: 'installs each week', up: true });
|
|
2637
|
+
list.push({ name: 'people who used it this week', up: true }, { name: 'money that came in this month', up: true });
|
|
2638
|
+
return list.slice(0, 3);
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
// the model's suggestions, written once in the background while none is saved
|
|
2642
|
+
function pumpSuggestions() {
|
|
2643
|
+
if (!deps.scoreboard || state.suggestions || state.sInFlight || readScoreboard(scoreboardFile, deps.workspaceRoot)) return;
|
|
2644
|
+
state.sInFlight = Promise.resolve()
|
|
2645
|
+
.then(() => suggestNumbers({ workspaceRoot: deps.workspaceRoot, bundle: state.bundle }))
|
|
2646
|
+
.catch(err => ({ ok: false, reason: String((err && err.message) || err) }))
|
|
2647
|
+
.then((out) => {
|
|
2648
|
+
if (out && out.ok && Array.isArray(out.choices)) state.suggestions = out.choices;
|
|
2649
|
+
else log({ card: 'bet', choice: 'scoreboard', command: null, ran: 'in process', exit_code: 1, error: clip((out && out.reason) || 'no numbers', 160) });
|
|
2650
|
+
});
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
// The card the owner owes before any idea: which number to watch, while
|
|
2654
|
+
// none is saved; then, for a number nobody can read on its own, its value
|
|
2655
|
+
// once a week, and whenever an idea is waiting on it to be scored.
|
|
2656
|
+
function askCard() {
|
|
2657
|
+
if (!deps.scoreboard) return null;
|
|
2658
|
+
const board = readScoreboard(scoreboardFile, deps.workspaceRoot);
|
|
2659
|
+
if (!board) {
|
|
2660
|
+
if (state.passedQuestions.has('scoreboard')) return null;
|
|
2661
|
+
const list = (state.suggestions || fallbackNumbers()).slice(0, 3);
|
|
2662
|
+
return {
|
|
2663
|
+
kind: 'question',
|
|
2664
|
+
id: 'question:scoreboard',
|
|
2665
|
+
question: { key: 'scoreboard', ask: SCOREBOARD_ASK, line: null, choices: [...list.map((c, i) => ({ id: `s${i + 1}`, label: c.name })), { id: 'other', label: 'something else', other: true }], numbers: list },
|
|
2666
|
+
};
|
|
2667
|
+
}
|
|
2668
|
+
if (board.source !== 'reported' || state.passedQuestions.has('report')) return null;
|
|
2669
|
+
const last = board.reports[board.reports.length - 1] || null;
|
|
2670
|
+
const weekly = !last || now() - (Date.parse(last.at) || 0) >= WEEK_MS;
|
|
2671
|
+
const today = dayKey(now());
|
|
2672
|
+
const waiting = readLedger(deps.ledgerFile, deps.workspaceRoot).some(r => r.status === 'doing' && r.bet && r.bet.target && r.bet.target.metric === 'scoreboard'
|
|
2673
|
+
&& String(r.bet.check_date || '') < today && !board.reports.some(x => String(x.at).slice(0, 10) >= r.bet.check_date));
|
|
2674
|
+
if (!weekly && !waiting) return null;
|
|
2675
|
+
return { kind: 'report', id: `report:${today}`, board, last };
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
function showAsk(item) {
|
|
2679
|
+
if (item.kind === 'question') { showQuestion(item); return; }
|
|
2680
|
+
state.current = item;
|
|
2681
|
+
state.status = 'ready';
|
|
2682
|
+
state.mission = null;
|
|
2683
|
+
state.error = null;
|
|
2684
|
+
log({ card: 'bet', choice: 'show', command: null, ran: 'in process', exit_code: 0, kind: 'report' });
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
// the number for the card on screen, saved beside the ledger
|
|
2688
|
+
function report({ value } = {}) {
|
|
2689
|
+
const item = onJob('report');
|
|
2690
|
+
if (!item) return { ok: false, reason: 'gone', message: 'there is no number asked for on screen' };
|
|
2691
|
+
const raw = String(value == null ? '' : value).replace(/,/g, '').trim();
|
|
2692
|
+
const v = Number(raw);
|
|
2693
|
+
if (!raw || !Number.isFinite(v)) return { ok: false, reason: 'bad_number', message: 'that is not a number' };
|
|
2694
|
+
appendLedger(scoreboardFile, { workspace: deps.workspaceRoot, kind: 'report', value: v, at: iso() });
|
|
2695
|
+
log({ card: 'bet', choice: 'report', command: null, ran: 'in process', exit_code: 0 });
|
|
2696
|
+
state.status = 'cleared';
|
|
2697
|
+
return { ok: true, ack: `saved: ${commas(v)}.` };
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2700
|
+
// The questions written ahead, oldest first, not yet shown.
|
|
2701
|
+
function readyQuestions() {
|
|
2702
|
+
return readLedger(questionsFile, deps.workspaceRoot).filter(r => r.status === 'ready' && r.question);
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
const answerCount = () => Object.keys(readFocus(focusFile, deps.workspaceRoot)).length;
|
|
2706
|
+
|
|
2707
|
+
// Every answer so far, oldest first, for both writers.
|
|
2708
|
+
function answersSoFar() {
|
|
2709
|
+
return Object.values(readFocus(focusFile, deps.workspaceRoot))
|
|
2710
|
+
.sort((a, b) => String(a.at).localeCompare(String(b.at)))
|
|
2711
|
+
.slice(-12)
|
|
2712
|
+
.map(a => ({ ask: a.ask, label: a.label }));
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
// Keeps QUESTIONS_READY questions written ahead, one writer at a time.
|
|
2716
|
+
// After MAX_BACKGROUND_FAILURES in a row it rests for BACKOFF_MS.
|
|
2717
|
+
function pumpQuestions() {
|
|
2718
|
+
if (state.qInFlight) return state.qInFlight;
|
|
2719
|
+
if (readyQuestions().length >= QUESTIONS_READY) return null;
|
|
2720
|
+
if (state.qFailures >= MAX_BACKGROUND_FAILURES) {
|
|
2721
|
+
if (now() < state.qBackoffUntil) return null;
|
|
2722
|
+
state.qFailures = 0;
|
|
2723
|
+
}
|
|
2724
|
+
const avoid = readLedger(questionsFile, deps.workspaceRoot).slice(-20).map(r => r.question && r.question.ask).filter(Boolean);
|
|
2725
|
+
const opts = { workspaceRoot: deps.workspaceRoot, answers: answersSoFar(), avoid, bundle: state.bundle };
|
|
2726
|
+
const seen = answerCount();
|
|
2727
|
+
const run = Promise.resolve()
|
|
2728
|
+
.then(() => writeQuestion(opts))
|
|
2729
|
+
.catch(err => ({ ok: false, reason: String((err && err.message) || err) }))
|
|
2730
|
+
.then((out) => {
|
|
2731
|
+
state.qInFlight = null;
|
|
2732
|
+
// written before the newest answer while another is ready: write it again, knowing the answer
|
|
2733
|
+
if (out && out.ok && seen < answerCount() && readyQuestions().length >= 1) {
|
|
2734
|
+
log({ card: 'bet', choice: 'question', command: null, ran: 'in process', exit_code: 0, stale: 'written before the newest answer' });
|
|
2735
|
+
return pumpQuestions();
|
|
2736
|
+
}
|
|
2737
|
+
if (out && out.ok) {
|
|
2738
|
+
state.qFailures = 0;
|
|
2739
|
+
const id = `q-${dayKey(now())}-${crypto.randomBytes(4).toString('hex')}`;
|
|
2740
|
+
appendLedger(questionsFile, { id, workspace: deps.workspaceRoot, created_at: iso(), status: 'ready', answers_seen: seen, question: out.question });
|
|
2741
|
+
log({ card: 'bet', choice: 'question', command: null, ran: 'in process', exit_code: 0, question: id });
|
|
2742
|
+
} else {
|
|
2743
|
+
state.qFailures += 1;
|
|
2744
|
+
log({ card: 'bet', choice: 'question', command: null, ran: 'in process', exit_code: 1, error: clip((out && out.reason) || 'no question', 200) });
|
|
2745
|
+
if (state.qFailures >= MAX_BACKGROUND_FAILURES) {
|
|
2746
|
+
state.qBackoffUntil = now() + BACKOFF_MS;
|
|
2747
|
+
if (!state.qRetry) state.qRetry = setTimer(() => { state.qRetry = null; pumpQuestions(); }, BACKOFF_MS);
|
|
2748
|
+
return null;
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
return pumpQuestions();
|
|
2752
|
+
});
|
|
2753
|
+
state.qInFlight = run;
|
|
2754
|
+
return run;
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
// A question when no idea is ready: the next one written ahead from this
|
|
2758
|
+
// project, else the one generic question if it was not answered this week;
|
|
2759
|
+
// null when there is none to ask.
|
|
2760
|
+
function nextQuestion() {
|
|
2761
|
+
const row = readyQuestions().find(r => !state.passedQuestions.has(r.id));
|
|
2762
|
+
if (row) {
|
|
2763
|
+
const q = row.question;
|
|
2764
|
+
return { kind: 'question', id: `question:${row.id}`, question: { key: row.id, ask: q.ask, line: q.fact, choices: q.choices.map((label, i) => ({ id: `a${i + 1}`, label })) } };
|
|
2765
|
+
}
|
|
2766
|
+
const F = FALLBACK_QUESTION;
|
|
2767
|
+
const said = readFocus(focusFile, deps.workspaceRoot)[F.key];
|
|
2768
|
+
if (state.passedQuestions.has(F.key) || (said && now() - (Date.parse(said.at) || 0) < FOCUS_FRESH_MS)) return null;
|
|
2769
|
+
return { kind: 'question', id: `question:${F.key}`, question: { key: F.key, ask: F.ask, line: installsLine(state.bundle), choices: F.choices.map(c => ({ ...c })) } };
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2772
|
+
// Two or three ideas ready: one card to pick from. Each is shown, so each
|
|
2773
|
+
// is recorded as proposed; the ones not picked are passed.
|
|
2774
|
+
const CHOICE_MAX = 3;
|
|
2775
|
+
function showChoice(items) {
|
|
2776
|
+
state.current = { kind: 'choice', id: `choice:${items.map(i => i.id).join(',')}`, items };
|
|
2777
|
+
state.status = 'ready';
|
|
2778
|
+
state.mission = null;
|
|
2779
|
+
state.error = null;
|
|
2780
|
+
for (const item of items) record(item.id, { status: 'proposed', shown_at: iso() });
|
|
2781
|
+
log({ card: 'bet', choice: 'show', command: null, ran: 'in process', exit_code: 0, kind: 'choice', bet_ids: items.map(i => i.id) });
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
// one tap on a choice: that idea's full card, with do it as before
|
|
2785
|
+
function pick({ id } = {}) {
|
|
2786
|
+
const cur = onJob('choice');
|
|
2787
|
+
const item = cur && cur.items.find(i => i.id === id);
|
|
2788
|
+
if (!item) return { ok: false, reason: 'gone', message: 'there is nothing to pick on screen' };
|
|
2789
|
+
for (const other of cur.items) if (other !== item) record(other.id, { status: 'passed', note: 'not picked' });
|
|
2790
|
+
log({ card: 'bet', choice: 'pick', command: null, ran: 'in process', exit_code: 0, bet_id: item.id });
|
|
2791
|
+
state.current = item;
|
|
2792
|
+
state.status = 'ready';
|
|
2793
|
+
return { ok: true, ...view() };
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2796
|
+
// nothing ready and nothing left to ask: one plain card, shown at once
|
|
2797
|
+
const WAIT_CARD = { what: 'the next idea is being written.', why: 'tap the orb again in a minute to see it.' };
|
|
2798
|
+
function showWait() {
|
|
2799
|
+
state.current = { kind: 'wait', id: `wait:${now()}` };
|
|
2800
|
+
state.status = 'ready';
|
|
2801
|
+
state.mission = null;
|
|
2802
|
+
state.error = null;
|
|
2803
|
+
log({ card: 'bet', choice: 'show', command: null, ran: 'in process', exit_code: 0, kind: 'wait' });
|
|
2804
|
+
}
|
|
2805
|
+
|
|
2806
|
+
function showQuestion(item) {
|
|
2807
|
+
if (!LOCAL_QUESTIONS.has(item.question.key)) appendLedger(questionsFile, { id: item.question.key, workspace: deps.workspaceRoot, status: 'shown', shown_at: iso() });
|
|
2808
|
+
state.current = item;
|
|
2809
|
+
state.status = 'ready';
|
|
2810
|
+
state.mission = null;
|
|
2811
|
+
state.error = null;
|
|
2812
|
+
log({ card: 'bet', choice: 'show', command: null, ran: 'in process', exit_code: 0, kind: 'question', question: item.question.key });
|
|
2813
|
+
}
|
|
2814
|
+
|
|
1857
2815
|
// a re-tap on a job card moves on: a yes waits for a later session, a done is seen
|
|
1858
2816
|
function passJob(item) {
|
|
2817
|
+
if (item.kind === 'wait') return;
|
|
2818
|
+
if (item.kind === 'report') { state.passedQuestions.add('report'); return; }
|
|
2819
|
+
if (item.kind === 'choice') {
|
|
2820
|
+
for (const i of item.items) record(i.id, { status: 'passed', note: 'not picked' });
|
|
2821
|
+
log({ card: 'bet', choice: 'pass', command: null, ran: 'in process', exit_code: 0, kind: 'choice' });
|
|
2822
|
+
return;
|
|
2823
|
+
}
|
|
2824
|
+
if (item.kind === 'question') {
|
|
2825
|
+
state.passedQuestions.add(item.question.key);
|
|
2826
|
+
if (!LOCAL_QUESTIONS.has(item.question.key)) appendLedger(questionsFile, { id: item.question.key, workspace: deps.workspaceRoot, status: 'passed' });
|
|
2827
|
+
log({ card: 'bet', choice: 'pass', command: null, ran: 'in process', exit_code: 0, kind: 'question', question: item.question.key });
|
|
2828
|
+
return;
|
|
2829
|
+
}
|
|
1859
2830
|
if (item.kind === 'yes') state.seenYes.add(item.job.key);
|
|
1860
2831
|
if (item.kind === 'done') record(item.job.bet_id, { done_seen_at: iso() });
|
|
1861
2832
|
log({ card: 'bet', choice: 'pass', command: null, ran: 'in process', exit_code: 0, kind: item.kind, bet_id: item.job.bet_id });
|
|
1862
2833
|
}
|
|
1863
2834
|
|
|
1864
2835
|
pollJobs();
|
|
2836
|
+
// the same read also tops up the queue, so it stays full with no page open
|
|
1865
2837
|
if (deps.pollMs) {
|
|
1866
|
-
const timer = setInterval(pollJobs, deps.pollMs);
|
|
2838
|
+
const timer = setInterval(() => { pollJobs(); pump(); }, deps.pollMs);
|
|
1867
2839
|
if (timer.unref) timer.unref();
|
|
1868
2840
|
}
|
|
1869
2841
|
|
|
1870
|
-
|
|
2842
|
+
// after MAX_BACKGROUND_FAILURES in a row the prefetch rests, and one timer
|
|
2843
|
+
// tries again; a tap in the meantime still gets a card at once
|
|
2844
|
+
function backOff() {
|
|
2845
|
+
state.backoffUntil = now() + BACKOFF_MS;
|
|
2846
|
+
if (state.retry) return;
|
|
2847
|
+
state.retry = setTimer(() => { state.retry = null; pump(); }, BACKOFF_MS);
|
|
2848
|
+
}
|
|
2849
|
+
|
|
2850
|
+
function startGeneration({ sharperOf = null, reworkOf = null, ownerNote = null } = {}) {
|
|
1871
2851
|
if (state.inFlight >= MAX_IN_FLIGHT || state.inFlight >= SESSION_IN_FLIGHT_CAP) return null;
|
|
1872
2852
|
state.inFlight += 1;
|
|
1873
2853
|
state.maxInFlight = Math.max(state.maxInFlight, state.inFlight);
|
|
@@ -1878,7 +2858,7 @@ function createBetDesk(deps) {
|
|
|
1878
2858
|
// started now, not on a later tick, so the in-flight count is exact
|
|
1879
2859
|
let started;
|
|
1880
2860
|
try {
|
|
1881
|
-
started = Promise.resolve(generate({ workspaceRoot: deps.workspaceRoot, today: dayKey(now()), previousTitle: sharperOf, avoidTitles: avoidTitles(), emit }));
|
|
2861
|
+
started = Promise.resolve(generate({ workspaceRoot: deps.workspaceRoot, today: dayKey(now()), previousTitle: sharperOf, avoidTitles: avoidTitles(), ...(ownerNote ? { reworkOf, ownerNote } : {}), emit, onBundle: (b) => { state.bundle = b; } }));
|
|
1882
2862
|
} catch (err) {
|
|
1883
2863
|
started = Promise.reject(err);
|
|
1884
2864
|
}
|
|
@@ -1887,54 +2867,48 @@ function createBetDesk(deps) {
|
|
|
1887
2867
|
.then((out) => {
|
|
1888
2868
|
state.inFlight -= 1;
|
|
1889
2869
|
if (state.gen === gen) state.gen = null;
|
|
1890
|
-
|
|
2870
|
+
if (out.bundle) state.bundle = out.bundle;
|
|
1891
2871
|
if (!out.ok) {
|
|
2872
|
+
// a failure stays in the log; the owner never waits on it or sees it
|
|
1892
2873
|
log({ card: 'bet', choice: 'generate', command: null, ran: 'in process', exit_code: 1, error: clip(out.reason, 200) });
|
|
1893
2874
|
emit({ kind: 'failed', line: out.reason });
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
state.lastFailed = gen.events;
|
|
1897
|
-
state.status = 'failed';
|
|
1898
|
-
state.error = out.reason;
|
|
1899
|
-
} else {
|
|
1900
|
-
state.failures += 1;
|
|
1901
|
-
}
|
|
2875
|
+
state.failures += 1;
|
|
2876
|
+
if (state.failures >= MAX_BACKGROUND_FAILURES) backOff();
|
|
1902
2877
|
return pump();
|
|
1903
2878
|
}
|
|
1904
2879
|
state.failures = 0;
|
|
1905
2880
|
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 });
|
|
2881
|
+
record(item.id, { created_at: iso(), status: 'queued', bet: out.bet, previous_title: sharperOf, ...(ownerNote ? { rework_of: reworkOf, owner_note: ownerNote } : {}) });
|
|
1907
2882
|
log({ card: 'bet', choice: 'generate', command: null, ran: 'in process', exit_code: 0, bet_id: item.id });
|
|
1908
2883
|
emit({ kind: 'done', line: 'bet ready' });
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
} else {
|
|
1913
|
-
state.queue.push(item);
|
|
1914
|
-
}
|
|
2884
|
+
// the sharper or reworked idea the owner asked for is the next shown
|
|
2885
|
+
if (sharperOf || ownerNote) state.queue.unshift(item);
|
|
2886
|
+
else state.queue.push(item);
|
|
1915
2887
|
return pump();
|
|
1916
2888
|
});
|
|
1917
2889
|
return gen;
|
|
1918
2890
|
}
|
|
1919
2891
|
|
|
1920
2892
|
// Keeps the queue topped up: below 2 it refills to 3, one generation at a
|
|
1921
|
-
// time. A
|
|
2893
|
+
// time. A sharper idea the owner asked for goes first.
|
|
1922
2894
|
function pump() {
|
|
2895
|
+
pumpQuestions();
|
|
2896
|
+
pumpSuggestions();
|
|
1923
2897
|
dropStale();
|
|
1924
2898
|
if (state.inFlight >= MAX_IN_FLIGHT) return state.gen && state.gen.done;
|
|
1925
|
-
if (state.pendingSharper
|
|
1926
|
-
const
|
|
2899
|
+
if (state.pendingSharper) {
|
|
2900
|
+
const asked = state.pendingSharper;
|
|
1927
2901
|
state.pendingSharper = null;
|
|
1928
|
-
|
|
1929
|
-
return
|
|
1930
|
-
}
|
|
1931
|
-
if (state.status === 'thinking' && !state.waiting) {
|
|
1932
|
-
state.waiting = startGeneration();
|
|
1933
|
-
return state.waiting && state.waiting.done;
|
|
2902
|
+
const gen = startGeneration(asked);
|
|
2903
|
+
return gen && gen.done;
|
|
1934
2904
|
}
|
|
1935
2905
|
if (state.queue.length < REFILL_BELOW) state.topping = true;
|
|
1936
2906
|
if (state.queue.length >= QUEUE_TARGET) state.topping = false;
|
|
1937
|
-
if (!state.topping
|
|
2907
|
+
if (!state.topping) return null;
|
|
2908
|
+
if (state.failures >= MAX_BACKGROUND_FAILURES) {
|
|
2909
|
+
if (now() < state.backoffUntil) return null;
|
|
2910
|
+
state.failures = 0;
|
|
2911
|
+
}
|
|
1938
2912
|
const gen = startGeneration();
|
|
1939
2913
|
return gen && gen.done;
|
|
1940
2914
|
}
|
|
@@ -1945,49 +2919,39 @@ function createBetDesk(deps) {
|
|
|
1945
2919
|
return view();
|
|
1946
2920
|
}
|
|
1947
2921
|
|
|
1948
|
-
// A tap on the orb: the
|
|
1949
|
-
//
|
|
2922
|
+
// A tap on the orb: the card on screen is passed over, and the next one
|
|
2923
|
+
// shows at once: a yes, a finished job, a queued bet, a question, or a
|
|
2924
|
+
// plain card that says the next idea is being written. A tap never waits.
|
|
2925
|
+
// sharper passes the bet over and asks the background for a sharper one.
|
|
1950
2926
|
function next({ sharper = false } = {}) {
|
|
1951
|
-
if (state.status === '
|
|
1952
|
-
state.failures = 0;
|
|
2927
|
+
if (state.status === 'starting') return { ok: true, busy: true, ...view() };
|
|
1953
2928
|
const onScreen = state.status === 'ready' && state.current ? state.current : null;
|
|
1954
2929
|
if (onScreen && onScreen.kind && onScreen.kind !== 'bet') {
|
|
1955
2930
|
passJob(onScreen);
|
|
1956
2931
|
} else if (onScreen) {
|
|
1957
2932
|
record(onScreen.id, { status: 'passed', ...(sharper ? { note: 'asked for a sharper idea' } : {}) });
|
|
1958
2933
|
log({ card: 'bet', choice: sharper ? 'sharper' : 'pass', command: null, ran: 'in process', exit_code: 0, bet_id: onScreen.id });
|
|
2934
|
+
if (sharper) state.pendingSharper = { sharperOf: onScreen.bet.title };
|
|
1959
2935
|
} else {
|
|
1960
2936
|
log({ card: 'bet', choice: 'think', command: null, ran: 'in process', exit_code: 0 });
|
|
1961
2937
|
}
|
|
1962
2938
|
dropStale();
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
2939
|
+
if (now() - state.jobs.at > 5000) pollJobs();
|
|
2940
|
+
const job = nextJobCard();
|
|
2941
|
+
const ask = job ? null : askCard();
|
|
2942
|
+
if (job) showJob(job);
|
|
2943
|
+
else if (ask) showAsk(ask);
|
|
2944
|
+
else if (deps.choices !== false && state.queue.length >= 2) showChoice(state.queue.splice(0, CHOICE_MAX));
|
|
2945
|
+
else if (state.queue.length) show(state.queue.shift());
|
|
2946
|
+
else {
|
|
2947
|
+
const question = nextQuestion();
|
|
2948
|
+
if (question) showQuestion(question);
|
|
2949
|
+
else {
|
|
2950
|
+
showWait();
|
|
2951
|
+
// the card says an idea is being written, so one is, even mid backoff
|
|
2952
|
+
if (!state.gen) { state.failures = 0; state.backoffUntil = 0; }
|
|
1971
2953
|
}
|
|
1972
2954
|
}
|
|
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
2955
|
pump();
|
|
1992
2956
|
return { ok: true, ...view() };
|
|
1993
2957
|
}
|
|
@@ -1995,7 +2959,7 @@ function createBetDesk(deps) {
|
|
|
1995
2959
|
const isBet = () => state.status === 'ready' && state.current && (!state.current.kind || state.current.kind === 'bet');
|
|
1996
2960
|
|
|
1997
2961
|
function skip() {
|
|
1998
|
-
if (!isBet()) return { ok: false, reason: 'gone', message: 'there is no
|
|
2962
|
+
if (!isBet()) return { ok: false, reason: 'gone', message: 'there is no idea on screen to skip' };
|
|
1999
2963
|
record(state.current.id, { status: 'skipped' });
|
|
2000
2964
|
log({ card: 'bet', choice: 'skip', command: null, ran: 'in process', exit_code: 0, bet_id: state.current.id });
|
|
2001
2965
|
state.status = 'skipped';
|
|
@@ -2003,7 +2967,7 @@ function createBetDesk(deps) {
|
|
|
2003
2967
|
}
|
|
2004
2968
|
|
|
2005
2969
|
async function doIt() {
|
|
2006
|
-
if (!isBet()) return { ok: false, reason: 'gone', message: 'there is no
|
|
2970
|
+
if (!isBet()) return { ok: false, reason: 'gone', message: 'there is no idea on screen to start' };
|
|
2007
2971
|
const { id, bet } = state.current;
|
|
2008
2972
|
const pick = pickOwner(bet, deps.workspaceRoot);
|
|
2009
2973
|
const startArgs = missionStartArgs(bet, pick, id);
|
|
@@ -2016,19 +2980,21 @@ function createBetDesk(deps) {
|
|
|
2016
2980
|
if (res.code !== 0 || !missionId) {
|
|
2017
2981
|
state.status = 'ready';
|
|
2018
2982
|
const why = clip((res.stderr || res.stdout || '').trim().split('\n').pop(), 160);
|
|
2019
|
-
|
|
2983
|
+
log({ card: 'bet', choice: 'do', command: null, ran: 'in process', exit_code: res.code, bet_id: id, error: why || null });
|
|
2984
|
+
return { ok: false, reason: 'start_failed', message: 'it did not get going. try again in a minute.' };
|
|
2020
2985
|
}
|
|
2021
2986
|
const owner = (started.mission && started.mission.owner) || pick.owner;
|
|
2022
2987
|
record(id, { status: 'doing', mission_id: missionId, owner });
|
|
2023
2988
|
lockYesSteps(missionId, id, bet.steps);
|
|
2024
|
-
const runArgs = missionRunArgs(missionId);
|
|
2989
|
+
const runArgs = missionRunArgs(missionId, bet);
|
|
2025
2990
|
res = await deps.runCli(runArgs);
|
|
2026
2991
|
log({ card: 'bet', choice: 'do', command: display(runArgs), exit_code: res.code, bet_id: id, mission_id: missionId });
|
|
2027
2992
|
const ran = parseJsonOut(res.stdout) || {};
|
|
2028
2993
|
let line;
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
else line = `${owner} has
|
|
2994
|
+
// what the owner reads; the command and exit code stay in the log
|
|
2995
|
+
if (res.code === 0 && ran.action === 'mission_run_detached') line = `${owner} has it and is starting now.`;
|
|
2996
|
+
else if (ran.reason === 'human_asks_open') line = `${owner} has it, and is waiting on your yes before starting.`;
|
|
2997
|
+
else line = `${owner} has it, but has not started yet. it can be started from the mac.`;
|
|
2032
2998
|
state.status = 'started';
|
|
2033
2999
|
state.mission = { id: missionId, owner, line, commands: [display(startArgs), display(runArgs)] };
|
|
2034
3000
|
state.started.push({ bet_id: id, mission_id: missionId, owner, steps: bet.steps, yes: {} });
|
|
@@ -2048,35 +3014,67 @@ function createBetDesk(deps) {
|
|
|
2048
3014
|
return { ok: true, ack: 'nice.' };
|
|
2049
3015
|
}
|
|
2050
3016
|
|
|
2051
|
-
//
|
|
2052
|
-
//
|
|
2053
|
-
|
|
2054
|
-
const item = onJob('
|
|
2055
|
-
if (!item
|
|
2056
|
-
const
|
|
2057
|
-
|
|
2058
|
-
const
|
|
3017
|
+
// an answer to the question on screen is saved to the focus file, where
|
|
3018
|
+
// the writer reads it for every bet after this one
|
|
3019
|
+
function answer({ choice, text } = {}) {
|
|
3020
|
+
const item = onJob('question');
|
|
3021
|
+
if (!item) return { ok: false, reason: 'gone', message: 'there is no question on screen' };
|
|
3022
|
+
const picked = item.question.choices.find(c => c.id === choice);
|
|
3023
|
+
if (!picked) return { ok: false, reason: 'bad_choice', message: 'that is not one of the answers' };
|
|
3024
|
+
const q = item.question;
|
|
3025
|
+
if (q.key === 'scoreboard') {
|
|
3026
|
+
let name;
|
|
3027
|
+
let up;
|
|
3028
|
+
if (picked.other) {
|
|
3029
|
+
name = String(text || '').trim();
|
|
3030
|
+
if (!name) return { ok: false, reason: 'empty', message: 'say the number in a few words' };
|
|
3031
|
+
if (name.length > 60) return { ok: false, reason: 'too_long', message: 'keep it under 60 characters' };
|
|
3032
|
+
up = !LOWER_IS_BETTER.test(name);
|
|
3033
|
+
} else {
|
|
3034
|
+
const pick = q.numbers[Number(picked.id.slice(1)) - 1];
|
|
3035
|
+
name = pick.name;
|
|
3036
|
+
up = pick.up !== false;
|
|
3037
|
+
}
|
|
3038
|
+
// installs on a workspace with npm numbers are read on their own
|
|
3039
|
+
const source = /\b(installs?|downloads?)\b/i.test(name) && state.bundle && state.bundle.sources && state.bundle.sources.installs ? 'installs' : 'reported';
|
|
3040
|
+
appendLedger(scoreboardFile, { workspace: deps.workspaceRoot, kind: 'pick', name, up, source, at: iso() });
|
|
3041
|
+
log({ card: 'bet', choice: 'scoreboard', command: null, ran: 'in process', exit_code: 0, source });
|
|
3042
|
+
state.status = 'cleared';
|
|
3043
|
+
return { ok: true, ack: `got it: ${name}.` };
|
|
3044
|
+
}
|
|
3045
|
+
appendLedger(focusFile, { workspace: deps.workspaceRoot, key: q.key, answer: picked.id, label: picked.label, ask: q.ask, at: iso() });
|
|
3046
|
+
if (q.key !== FALLBACK_QUESTION.key) appendLedger(questionsFile, { id: q.key, workspace: deps.workspaceRoot, status: 'answered' });
|
|
3047
|
+
log({ card: 'bet', choice: 'answer', command: null, ran: 'in process', exit_code: 0, question: q.key, answer: picked.id });
|
|
3048
|
+
state.status = 'cleared';
|
|
3049
|
+
// the next questions are written knowing this answer: any written before
|
|
3050
|
+
// it are retired, except the oldest, so the next tap is still instant
|
|
3051
|
+
const count = answerCount();
|
|
3052
|
+
readyQuestions().slice(1).filter(r => (Number(r.answers_seen) || 0) < count).forEach((r) => {
|
|
3053
|
+
appendLedger(questionsFile, { id: r.id, workspace: deps.workspaceRoot, status: 'stale' });
|
|
3054
|
+
});
|
|
3055
|
+
pumpQuestions();
|
|
3056
|
+
return { ok: true, ack: `got it: ${picked.label}.` };
|
|
3057
|
+
}
|
|
3058
|
+
|
|
3059
|
+
// Send back: a job that did not pass goes to the team again as a new
|
|
3060
|
+
// mission on the same bet, with what went wrong in the objective.
|
|
3061
|
+
async function sendBackJob(row, why) {
|
|
3062
|
+
const again = { ...row.bet, why_now: `the last run ${why}. try again. ${row.bet.why_now}` };
|
|
2059
3063
|
const pick = pickOwner(again, deps.workspaceRoot);
|
|
2060
3064
|
const display = args => require('./gm-phone').displayCommand(args);
|
|
2061
|
-
|
|
2062
|
-
const startArgs = missionStartArgs(again, pick, job.bet_id);
|
|
3065
|
+
const startArgs = missionStartArgs(again, pick, row.id);
|
|
2063
3066
|
let res = await deps.runCli(startArgs);
|
|
2064
|
-
log({ card: 'bet', choice: 'send_back', command: display(startArgs), exit_code: res.code, bet_id:
|
|
3067
|
+
log({ card: 'bet', choice: 'send_back', command: display(startArgs), exit_code: res.code, bet_id: row.id });
|
|
2065
3068
|
const started = parseJsonOut(res.stdout);
|
|
2066
3069
|
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
|
-
}
|
|
3070
|
+
if (res.code !== 0 || !missionId) return close(row, `it could not go back to the team (exit code ${res.code})`);
|
|
2071
3071
|
const owner = (started.mission && started.mission.owner) || pick.owner;
|
|
2072
|
-
record(
|
|
2073
|
-
lockYesSteps(missionId,
|
|
2074
|
-
const runArgs = missionRunArgs(missionId);
|
|
3072
|
+
record(row.id, { mission_id: missionId, owner, mission_result: null, mission_halted: null, mission_stalled: null, mission_out_of_time: null, mission_progress: null, mission_stop_reason: null, review_tries: 0, done_seen_at: null, sent_back_at: iso(), auto_sent_back: (Number(row.auto_sent_back) || 0) + 1 });
|
|
3073
|
+
lockYesSteps(missionId, row.id, again.steps);
|
|
3074
|
+
const runArgs = missionRunArgs(missionId, again);
|
|
2075
3075
|
res = await deps.runCli(runArgs);
|
|
2076
|
-
log({ card: 'bet', choice: 'send_back', command: display(runArgs), exit_code: res.code, bet_id:
|
|
2077
|
-
|
|
2078
|
-
pollJobs();
|
|
2079
|
-
return { ok: true, ack: `sent back. ${owner} has it again.` };
|
|
3076
|
+
log({ card: 'bet', choice: 'send_back', command: display(runArgs), exit_code: res.code, bet_id: row.id, mission_id: missionId });
|
|
3077
|
+
return null;
|
|
2080
3078
|
}
|
|
2081
3079
|
|
|
2082
3080
|
// yes: answers the mission's open ask through atris decide, matched by
|
|
@@ -2115,10 +3113,14 @@ function createBetDesk(deps) {
|
|
|
2115
3113
|
return { ok: true, ack: `${job.owner}: got your yes` };
|
|
2116
3114
|
}
|
|
2117
3115
|
|
|
3116
|
+
// Two sentences for the owner: what is going on, and why it matters to
|
|
3117
|
+
// the business. Nothing about how the work was checked.
|
|
2118
3118
|
function publicJob(item) {
|
|
2119
3119
|
const j = item.job;
|
|
2120
|
-
|
|
2121
|
-
|
|
3120
|
+
const expect = item.kind === 'yes' ? j.expect : j.bet && j.bet.expect;
|
|
3121
|
+
const why = expect ? ` ${sentence(expect)}` : '';
|
|
3122
|
+
if (item.kind === 'yes') return { what: j.ask, why: `${j.owner} is waiting on your yes for this.${why}` };
|
|
3123
|
+
return { what: j.title, why: `this is done.${why}` };
|
|
2122
3124
|
}
|
|
2123
3125
|
|
|
2124
3126
|
// Decision cards for the steps that need the owner's yes, for every bet
|
|
@@ -2161,7 +3163,8 @@ function createBetDesk(deps) {
|
|
|
2161
3163
|
}
|
|
2162
3164
|
|
|
2163
3165
|
function view() {
|
|
2164
|
-
|
|
3166
|
+
// the writer's progress stays in the log; the page never reads it
|
|
3167
|
+
const events = [];
|
|
2165
3168
|
const cur = state.current;
|
|
2166
3169
|
const kind = cur ? (cur.kind || 'bet') : null;
|
|
2167
3170
|
return {
|
|
@@ -2169,8 +3172,19 @@ function createBetDesk(deps) {
|
|
|
2169
3172
|
id: cur ? cur.id : null,
|
|
2170
3173
|
kind,
|
|
2171
3174
|
bet: kind === 'bet' ? cur.bet : null,
|
|
2172
|
-
job: kind
|
|
2173
|
-
|
|
3175
|
+
job: kind === 'yes' || kind === 'done' ? publicJob(cur) : (kind === 'wait' ? { ...WAIT_CARD } : null),
|
|
3176
|
+
question: kind === 'question' ? { ask: cur.question.ask, line: cur.question.line, choices: cur.question.choices } : null,
|
|
3177
|
+
report: kind === 'report' ? {
|
|
3178
|
+
what: `${cur.board.name}: what is it this week?`,
|
|
3179
|
+
why: cur.last ? `last time it was ${commas(cur.last.value)}, on ${monthDay(cur.last.at)}.` : 'one number a week shows if this is going well.',
|
|
3180
|
+
} : null,
|
|
3181
|
+
// each option: its title, and one plain line of why, the result it expects
|
|
3182
|
+
choice: kind === 'choice' ? { options: cur.items.map(i => ({ id: i.id, title: i.bet.title, why: choiceWhy(i.bet.expect) })) } : null,
|
|
3183
|
+
working: state.jobs.working.map(j => ({ title: j.title, owner: j.owner, ...(j.left ? { left: j.left } : {}) })),
|
|
3184
|
+
// the conversation on this card so far
|
|
3185
|
+
thread: cur && state.thread.id === cur.id ? state.thread.turns.slice() : [],
|
|
3186
|
+
// the footer count: only members really working
|
|
3187
|
+
team_working: teamTable().rows.filter(r => r.status === 'working').length,
|
|
2174
3188
|
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
3189
|
error: state.error,
|
|
2176
3190
|
queue: state.queue.length,
|
|
@@ -2178,14 +3192,280 @@ function createBetDesk(deps) {
|
|
|
2178
3192
|
};
|
|
2179
3193
|
}
|
|
2180
3194
|
|
|
2181
|
-
//
|
|
2182
|
-
|
|
3195
|
+
// ---- free text: each card is one turn in a conversation ----
|
|
3196
|
+
|
|
3197
|
+
function threadFor(id) {
|
|
3198
|
+
if (state.thread.id !== id) state.thread = { id, turns: [] };
|
|
3199
|
+
return state.thread;
|
|
3200
|
+
}
|
|
3201
|
+
const endThread = () => { state.thread = { id: null, turns: [] }; };
|
|
3202
|
+
function addTurns(t, ...turns) {
|
|
3203
|
+
t.turns = [...t.turns, ...turns].slice(-THREAD_TURNS);
|
|
3204
|
+
}
|
|
3205
|
+
const plainReply = text => (text && !ownerWord(text) ? text : null);
|
|
3206
|
+
const plainSuggestions = list => (Array.isArray(list) ? list : [])
|
|
3207
|
+
.filter(x => typeof x === 'string' && x.trim() && x.length <= 40 && !ownerWord(x) && !PROCESS_TALK.test(x))
|
|
3208
|
+
.slice(0, 3);
|
|
3209
|
+
|
|
3210
|
+
// a read, within its time; a slow or failed one is a failed read
|
|
3211
|
+
function readWithin(opts) {
|
|
3212
|
+
let timer;
|
|
3213
|
+
const slow = new Promise((resolve) => {
|
|
3214
|
+
timer = setTimeout(() => resolve({ ok: false, reason: 'the read took too long' }), deps.sayTimeoutMs || SAY_READ_MS);
|
|
3215
|
+
if (timer.unref) timer.unref();
|
|
3216
|
+
});
|
|
3217
|
+
return Promise.race([Promise.resolve().then(() => readFn(opts)).catch(err => ({ ok: false, reason: String((err && err.message) || err) })), slow])
|
|
3218
|
+
.finally(() => clearTimeout(timer));
|
|
3219
|
+
}
|
|
3220
|
+
|
|
3221
|
+
// the card as the reader sees it
|
|
3222
|
+
function cardForReader(v) {
|
|
3223
|
+
if (v.kind === 'question') return { kind: 'question', title: v.question.ask, detail: v.question.line, buttons: v.question.choices.map(c => c.label) };
|
|
3224
|
+
if (v.kind === 'bet') return { kind: 'idea', title: v.bet.title, detail: [v.bet.why_now, v.bet.expect].join(' '), buttons: ['do it', 'sharper idea', 'not now'] };
|
|
3225
|
+
if (v.kind === 'choice') return { kind: 'choice', title: 'which one next?', detail: v.choice.options.map(o => o.title).join(' / '), buttons: [] };
|
|
3226
|
+
if (v.kind === 'report') return { kind: 'number', title: v.report.what, detail: v.report.why, buttons: [] };
|
|
3227
|
+
return { kind: v.kind, title: v.job ? v.job.what : '', detail: v.job ? v.job.why : '', buttons: [] };
|
|
3228
|
+
}
|
|
3229
|
+
|
|
3230
|
+
// Read first. A question back or an unclear message is answered on the
|
|
3231
|
+
// same card and saves nothing; an answer or an instruction is acted on.
|
|
3232
|
+
// A failed or slow read falls back to the plain line and saves nothing.
|
|
3233
|
+
const QUESTION_HINT = 'his message ends with a question mark: it is a question back, not an answer';
|
|
3234
|
+
async function readTurn(t, text, card) {
|
|
3235
|
+
const thread = t.turns.slice(-THREAD_TURNS);
|
|
3236
|
+
let read = await readWithin({ text, card, thread });
|
|
3237
|
+
// words that end in a question mark are never saved as an answer: ask once
|
|
3238
|
+
// more with that said, and if it still reads as an answer, fall back
|
|
3239
|
+
if (read && read.ok && read.kind === 'answer' && /\?\s*$/.test(text)) {
|
|
3240
|
+
read = await readWithin({ text, card, thread, hint: QUESTION_HINT });
|
|
3241
|
+
if (read && read.ok && read.kind === 'answer') read = { ok: false, reason: 'a question read as an answer' };
|
|
3242
|
+
}
|
|
3243
|
+
if (read && read.ok && (read.kind === 'answer' || read.kind === 'instruction')) return { act: read };
|
|
3244
|
+
const kind = read && read.ok ? read.kind : 'unclear';
|
|
3245
|
+
const reply = (read && read.ok && plainReply(read.reply)) || SAY_FALLBACK;
|
|
3246
|
+
const suggestions = read && read.ok && reply !== SAY_FALLBACK ? plainSuggestions(read.suggestions) : [];
|
|
3247
|
+
addTurns(t, { who: 'owner', text }, { who: 'orb', text: reply });
|
|
3248
|
+
return { stay: { ok: true, stay: true, kind: reply === SAY_FALLBACK ? 'unclear' : kind, reply, ...(suggestions.length ? { suggestions } : {}), thread: t.turns.slice() } };
|
|
3249
|
+
}
|
|
3250
|
+
|
|
3251
|
+
// On a question it is the answer; on an idea, a note the writer reworks it
|
|
3252
|
+
// with (the rework is the next idea); on a choice or the plain card, the
|
|
3253
|
+
// next idea fits it; on a yes or a finished job, a word to that member.
|
|
3254
|
+
async function say({ text, confirm = false, note = false } = {}) {
|
|
3255
|
+
const said = String(text || '').trim();
|
|
3256
|
+
if (!said) return { ok: false, reason: 'empty', message: 'say something first' };
|
|
3257
|
+
if (said.length > SAY_MAX) return { ok: false, reason: 'too_long', message: `keep it under ${SAY_MAX} characters` };
|
|
3258
|
+
const cur = state.status === 'ready' ? state.current : null;
|
|
3259
|
+
if (!cur) return { ok: false, reason: 'gone', message: 'there is no card on screen' };
|
|
3260
|
+
const kind = cur.kind || 'bet';
|
|
3261
|
+
log({ card: 'bet', choice: 'say', command: null, ran: 'in process', exit_code: 0, kind, chars: said.length });
|
|
3262
|
+
const t = threadFor(cur.id);
|
|
3263
|
+
let read = { kind: 'instruction', reply: '' };
|
|
3264
|
+
// a second tap or a note acts on what was already read
|
|
3265
|
+
if (!confirm && !note) {
|
|
3266
|
+
const turn = await readTurn(t, said, cardForReader(view()));
|
|
3267
|
+
// the card moved on while it was read (a tap, do it): drop the reply
|
|
3268
|
+
if (state.current !== cur || state.status !== 'ready') return { ok: false, reason: 'gone', message: 'that card changed, so nothing was done' };
|
|
3269
|
+
if (turn.stay) return turn.stay;
|
|
3270
|
+
read = turn.act;
|
|
3271
|
+
}
|
|
3272
|
+
if (kind === 'question') {
|
|
3273
|
+
const q = cur.question;
|
|
3274
|
+
if (read.kind === 'answer') {
|
|
3275
|
+
if (q.key === 'scoreboard') {
|
|
3276
|
+
const out = answer({ choice: 'other', text: said });
|
|
3277
|
+
if (out.ok) endThread();
|
|
3278
|
+
return out;
|
|
3279
|
+
}
|
|
3280
|
+
appendLedger(focusFile, { workspace: deps.workspaceRoot, key: q.key, answer: 'own', label: said, ask: q.ask, at: iso() });
|
|
3281
|
+
if (!LOCAL_QUESTIONS.has(q.key)) appendLedger(questionsFile, { id: q.key, workspace: deps.workspaceRoot, status: 'answered' });
|
|
3282
|
+
state.status = 'cleared';
|
|
3283
|
+
endThread();
|
|
3284
|
+
pumpQuestions();
|
|
3285
|
+
return { ok: true, ack: `got it: ${said.replace(/[.\s]+$/, '')}.` };
|
|
3286
|
+
}
|
|
3287
|
+
// an instruction on a question: a note for the next idea
|
|
3288
|
+
state.pendingSharper = { ownerNote: said };
|
|
3289
|
+
state.status = 'cleared';
|
|
3290
|
+
endThread();
|
|
3291
|
+
pump();
|
|
3292
|
+
return { ok: true, ack: plainReply(read.reply) || 'got it. the next idea will fit that.' };
|
|
3293
|
+
}
|
|
3294
|
+
if (kind === 'yes' || kind === 'done') {
|
|
3295
|
+
const out = await steer({ member: cur.job.owner, text: said, confirm, note });
|
|
3296
|
+
if (out.ok) endThread();
|
|
3297
|
+
return out;
|
|
3298
|
+
}
|
|
3299
|
+
if (kind === 'bet') {
|
|
3300
|
+
record(cur.id, { status: 'passed', note: 'reworked with your note', owner_note: said });
|
|
3301
|
+
state.pendingSharper = { reworkOf: cur.bet.title, ownerNote: said };
|
|
3302
|
+
state.status = 'cleared';
|
|
3303
|
+
endThread();
|
|
3304
|
+
pump();
|
|
3305
|
+
return { ok: true, ack: plainReply(read.reply) || 'got it. reworking that idea with your note.' };
|
|
3306
|
+
}
|
|
3307
|
+
if (kind === 'choice') passJob(cur);
|
|
3308
|
+
state.pendingSharper = { ownerNote: said };
|
|
3309
|
+
state.status = 'cleared';
|
|
3310
|
+
endThread();
|
|
3311
|
+
pump();
|
|
3312
|
+
return { ok: true, ack: plainReply(read.reply) || 'got it. the next idea will fit that.' };
|
|
3313
|
+
}
|
|
3314
|
+
|
|
3315
|
+
// The talk card with one member: the same conversation. A question back is
|
|
3316
|
+
// answered from what the table knows; an instruction steers them.
|
|
3317
|
+
async function talk({ member, text, confirm = false, note = false } = {}) {
|
|
3318
|
+
const name = String(member || '');
|
|
3319
|
+
if (!(team || []).some(m => m.name === name)) return { ok: false, reason: 'gone', message: 'there is no such member on the team' };
|
|
3320
|
+
const said = String(text || '').trim();
|
|
3321
|
+
if (!said) return { ok: false, reason: 'empty', message: 'say something first' };
|
|
3322
|
+
if (said.length > SAY_MAX) return { ok: false, reason: 'too_long', message: `keep it under ${SAY_MAX} characters` };
|
|
3323
|
+
const t = threadFor(`member:${name}`);
|
|
3324
|
+
if (!confirm && !note) {
|
|
3325
|
+
const row = teamTable().rows.find(r => r.member === name);
|
|
3326
|
+
const turn = await readTurn(t, said, { kind: 'member', title: name, detail: row ? `${row.status}: ${row.on}. ${row.time}.` : 'no work in the last day', buttons: [] });
|
|
3327
|
+
if (turn.stay) return turn.stay;
|
|
3328
|
+
}
|
|
3329
|
+
const out = await steer({ member: name, text: said, confirm, note });
|
|
3330
|
+
if (out.ok) endThread();
|
|
3331
|
+
return out;
|
|
3332
|
+
}
|
|
3333
|
+
|
|
3334
|
+
// ---- the team table: who is on what, from real state ----
|
|
3335
|
+
|
|
3336
|
+
// A member is working only when a process is really running its mission,
|
|
3337
|
+
// or the mission moved in the last few minutes. A handoff that no process
|
|
3338
|
+
// picked up is waiting to start. Members with nothing in the last day are
|
|
3339
|
+
// left out.
|
|
3340
|
+
const RECENT_MS = 24 * 60 * 60 * 1000;
|
|
3341
|
+
const MOVING_MS = 5 * 60 * 1000;
|
|
3342
|
+
const STATUS_ORDER = ['waiting for your yes', 'working', 'waiting to start', 'stopped', 'done', 'idle'];
|
|
3343
|
+
let teamCache = null;
|
|
3344
|
+
function teamTable() {
|
|
3345
|
+
if (teamCache && now() - teamCache.at < 10000) return teamCache.table;
|
|
3346
|
+
const names = new Set((team || []).map(m => m.name));
|
|
3347
|
+
let missions;
|
|
3348
|
+
try { missions = (deps.readMissions || defaultReadMissions)(deps.workspaceRoot); } catch { missions = null; }
|
|
3349
|
+
const list = missions instanceof Map ? [...missions.values()] : Object.values(missions || {});
|
|
3350
|
+
const ideaTitle = new Map(readLedger(deps.ledgerFile, deps.workspaceRoot).filter(r => r.mission_id && r.bet).map(r => [r.mission_id, r.bet.title]));
|
|
3351
|
+
const best = new Map();
|
|
3352
|
+
const put = (member, row, at) => {
|
|
3353
|
+
if (!names.has(member)) return;
|
|
3354
|
+
const rank = STATUS_ORDER.indexOf(row.status);
|
|
3355
|
+
const cur = best.get(member);
|
|
3356
|
+
if (!cur || rank < cur.rank || (rank === cur.rank && at > cur.at)) best.set(member, { row: { member, ...row }, rank, at });
|
|
3357
|
+
};
|
|
3358
|
+
for (const m of list) {
|
|
3359
|
+
if (!m || !m.owner) continue;
|
|
3360
|
+
const moved = Math.max(...[m.last_tick_at, m.updated_at, m.created_at].map(t => Date.parse(t || '') || 0));
|
|
3361
|
+
const age = now() - moved;
|
|
3362
|
+
if (!moved || age > RECENT_MS) continue;
|
|
3363
|
+
const created = Date.parse(m.created_at || '') || moved;
|
|
3364
|
+
const on = ideaTitle.get(m.id) || missionTitle(m.objective);
|
|
3365
|
+
const status = String(m.status || '');
|
|
3366
|
+
if (status === 'complete') { put(m.owner, { on, status: 'done', time: `finished ${spanWords(age)} ago` }, moved); continue; }
|
|
3367
|
+
if (status === 'stopped') { put(m.owner, { on, status: 'stopped', time: `stopped ${spanWords(age)} ago` }, moved); continue; }
|
|
3368
|
+
if (!OPEN_MISSION.has(status)) continue;
|
|
3369
|
+
const asks = (Array.isArray(m.human_asks) ? m.human_asks : []).some(a => a && !(typeof a === 'object' && a.answered_at))
|
|
3370
|
+
|| require('./yes-lock').askedYesLocks(lockDir, m.id).length > 0;
|
|
3371
|
+
if (asks) { put(m.owner, { on, status: 'waiting for your yes', time: `waiting ${spanWords(age)}` }, moved); continue; }
|
|
3372
|
+
if (driverAlive(m) || age <= MOVING_MS) {
|
|
3373
|
+
const wall = Number(m.max_wall_seconds) || Number(m.budget_contract && m.budget_contract.requested_seconds) || 0;
|
|
3374
|
+
const left = wall ? created + wall * 1000 - now() : 0;
|
|
3375
|
+
put(m.owner, { on, status: 'working', time: left > 0 ? leftWords(left) : `at it for ${spanWords(now() - created)}` }, moved);
|
|
3376
|
+
continue;
|
|
3377
|
+
}
|
|
3378
|
+
if (!m.last_tick_at) { put(m.owner, { on, status: 'waiting to start', time: `waiting ${spanWords(now() - created)}` }, moved); continue; }
|
|
3379
|
+
put(m.owner, { on, status: 'stopped', time: `stopped ${spanWords(age)} ago` }, moved);
|
|
3380
|
+
}
|
|
3381
|
+
for (const hnd of (deps.readHandoffs || defaultReadHandoffs)(deps.workspaceRoot)) {
|
|
3382
|
+
const at = Date.parse((hnd && (hnd.closed_at || hnd.created_at)) || '') || 0;
|
|
3383
|
+
if (!hnd || !hnd.to || !at || now() - at > RECENT_MS) continue;
|
|
3384
|
+
const on = clip(String(hnd.summary || '').replace(/^\[[^\]]*\]\s*/, ''), 100);
|
|
3385
|
+
if (hnd.status === 'open') put(hnd.to, { on, status: 'waiting to start', time: `waiting ${spanWords(now() - at)}` }, at);
|
|
3386
|
+
else put(hnd.to, { on, status: 'idle', time: `free for ${spanWords(now() - at)}` }, at);
|
|
3387
|
+
}
|
|
3388
|
+
const rows = [...best.values()]
|
|
3389
|
+
.sort((a, b) => a.rank - b.rank || a.row.member.localeCompare(b.row.member))
|
|
3390
|
+
.map(x => x.row);
|
|
3391
|
+
const table = { rows };
|
|
3392
|
+
teamCache = { at: now(), table };
|
|
3393
|
+
return table;
|
|
3394
|
+
}
|
|
3395
|
+
|
|
3396
|
+
// A word to a member from its talk card or a card on screen. The phone
|
|
3397
|
+
// never calls a steer that can start anyone: it uses --no-ignite, which
|
|
3398
|
+
// only reaches a live run and otherwise starts nothing, and then the page
|
|
3399
|
+
// offers a second tap or a note. A confirmed start runs in the background
|
|
3400
|
+
// (--detach) and reports started or not from that. The owner's words go
|
|
3401
|
+
// after --, behind "the owner says:", so they are never read as options.
|
|
3402
|
+
const SAY_MAX = 280;
|
|
3403
|
+
function steeringNote(name, said) {
|
|
3404
|
+
const file = path.join(deps.workspaceRoot, '.atris', 'state', 'steering.jsonl');
|
|
3405
|
+
appendLedger(file, { schema: 'atris.steering.v1', id: `steer-${crypto.randomBytes(4).toString('hex')}`, kind: 'preference', status: 'active', scope: { member: name }, raw: said, memory: [said], created_at: iso(), source: 'gm-phone' });
|
|
3406
|
+
}
|
|
3407
|
+
|
|
3408
|
+
async function steer({ member, text, confirm = false, note = false } = {}) {
|
|
3409
|
+
const name = String(member || '');
|
|
3410
|
+
if (!(team || []).some(m => m.name === name)) return { ok: false, reason: 'gone', message: 'there is no such member on the team' };
|
|
3411
|
+
const said = String(text || '').trim();
|
|
3412
|
+
if (!said) return { ok: false, reason: 'empty', message: 'say something first' };
|
|
3413
|
+
if (said.length > SAY_MAX) return { ok: false, reason: 'too_long', message: `keep it under ${SAY_MAX} characters` };
|
|
3414
|
+
if (note) {
|
|
3415
|
+
steeringNote(name, said);
|
|
3416
|
+
log({ card: 'bet', choice: 'steer_note', command: null, ran: 'in process', exit_code: 0, member: name, chars: said.length });
|
|
3417
|
+
return { ok: true, ack: `saved for ${name}'s next run.` };
|
|
3418
|
+
}
|
|
3419
|
+
const words = `the owner says: ${said}`;
|
|
3420
|
+
const args = ['member', 'steer', name, confirm === true ? '--detach' : '--no-ignite', '--json', '--', words];
|
|
3421
|
+
const res = await deps.runCli(args);
|
|
3422
|
+
const out = parseJsonOut(res.stdout) || {};
|
|
3423
|
+
log({ card: 'bet', choice: confirm === true ? 'steer_start' : 'steer', command: `atris member steer ${name} ${args[3]} --json -- "<${said.length} characters>"`, exit_code: res.code, member: name, chars: said.length, action: out.action || null });
|
|
3424
|
+
teamCache = null;
|
|
3425
|
+
if (res.code === 0 && out.action === 'member_steered') return { ok: true, ack: `sent to ${name}.` };
|
|
3426
|
+
if (confirm === true) {
|
|
3427
|
+
if (res.code === 0 && out.action === 'member_run_detached') return { ok: true, ack: `${name} is starting on it.` };
|
|
3428
|
+
// refused before it began: say why, in its plain words
|
|
3429
|
+
if (out.action === 'member_run_not_started' && out.reason) return { ok: false, reason: 'not_started', message: `${name} did not start: ${String(out.reason).replace(/[.\s]+$/, '')}.` };
|
|
3430
|
+
return { ok: false, reason: 'not_started', message: `${name} did not start. try again in a minute.` };
|
|
3431
|
+
}
|
|
3432
|
+
if (out.action === 'member_not_running' || res.code === 3) return { ok: false, reason: 'needs_confirm', message: `tap again to start ${name} on this` };
|
|
3433
|
+
return { ok: false, reason: 'steer_failed', message: 'it did not reach them. try again in a minute.' };
|
|
3434
|
+
}
|
|
3435
|
+
|
|
3436
|
+
// resolves when every review and send back running now has finished (tests and shutdown)
|
|
3437
|
+
async function settle() {
|
|
3438
|
+
while (state.reviews.size || state.retrying.size) await Promise.all([...state.reviews.values(), ...state.retrying.values()]);
|
|
3439
|
+
}
|
|
3440
|
+
|
|
3441
|
+
// the queue starts filling now, not when the page first opens; with the
|
|
3442
|
+
// queue already full, the facts for a question card are read on their own
|
|
3443
|
+
if (deps.warmOnCreate !== false) {
|
|
3444
|
+
pump();
|
|
3445
|
+
if (!state.inFlight && deps.readBundle) {
|
|
3446
|
+
Promise.resolve().then(() => deps.readBundle()).then((b) => { if (b && !state.bundle) state.bundle = b; }).catch(() => {});
|
|
3447
|
+
}
|
|
3448
|
+
}
|
|
2183
3449
|
|
|
2184
|
-
return { warm, next, skip, doIt, nice,
|
|
3450
|
+
return { warm, next, pick, say, talk, report, teamTable, steer, skip, doIt, nice, answerYes, answer, pollJobs, settle, yesCards, recordYes, view, state };
|
|
2185
3451
|
}
|
|
2186
3452
|
|
|
2187
3453
|
module.exports = {
|
|
3454
|
+
BACKOFF_MS,
|
|
3455
|
+
FALLBACK_QUESTION,
|
|
3456
|
+
readScoreboard,
|
|
3457
|
+
readSaid,
|
|
3458
|
+
scoreboardPath,
|
|
3459
|
+
choiceWhy,
|
|
2188
3460
|
JOB_POLL_MS,
|
|
3461
|
+
OWNER_BANNED,
|
|
3462
|
+
jobTime,
|
|
3463
|
+
generateQuestion,
|
|
3464
|
+
readProjectContext,
|
|
3465
|
+
validateQuestion,
|
|
3466
|
+
STALL_MS,
|
|
3467
|
+
focusPath,
|
|
3468
|
+
readFocus,
|
|
2189
3469
|
queuedProblem,
|
|
2190
3470
|
MODEL_TIMEOUT_MS,
|
|
2191
3471
|
STALE_MS,
|
|
@@ -2213,6 +3493,7 @@ module.exports = {
|
|
|
2213
3493
|
readLedger,
|
|
2214
3494
|
releaseFact,
|
|
2215
3495
|
runModel,
|
|
3496
|
+
stopWriters,
|
|
2216
3497
|
scoreBets,
|
|
2217
3498
|
skepticCommand,
|
|
2218
3499
|
validateBet,
|