atris 3.74.12 → 3.74.14

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.
@@ -785,6 +785,7 @@ const MEMBER_RUN_START_BOOLEAN_FLAGS = [
785
785
  '--json',
786
786
  '--no-verify',
787
787
  '--referee',
788
+ '--referee-if-check-edited',
788
789
  '--no-referee',
789
790
  '--worktree',
790
791
  '--shared-checkout',
@@ -1064,9 +1065,21 @@ function startMemberRunMission(name, missionText, args = []) {
1064
1065
  }
1065
1066
 
1066
1067
  // An ignited run asks a second engine to review the work by default, so every
1067
- // run ends with a verdict. --no-referee skips it; an explicit --referee is kept.
1068
- function memberRunWantsReferee(runArgs, ignite) {
1069
- return Boolean(ignite) && !hasFlag(runArgs, '--referee') && !hasFlag(runArgs, '--no-referee');
1068
+ // run ends with a verdict. A real test command is already that verdict: the
1069
+ // second engine only reran the same commands and cost about a minute a run
1070
+ // (member lab 2026-10-24), so it stays for runs whose check is not a test
1071
+ // runner (whitespace, syntax, or file-exists checks). --no-referee skips it; an explicit --referee is kept.
1072
+ function memberRunHasRealCheck(verifier) {
1073
+ const command = String(verifier || '').trim();
1074
+ // A plain test runner only: chaining (|| true, ; , &&, pipes, subshells) or a
1075
+ // pass-with-no-tests switch can make any run "pass", so those keep the review.
1076
+ if (/\|\||&&|[;|`\r\n]|\$\(|pass-?with-?no-?tests|--no-tests|--exit-zero/i.test(command)) return false;
1077
+ return /^((npm|pnpm|yarn)\s+(run\s+)?test\b|node\s+--test\b|npx\s+(jest|vitest|mocha)\b|pytest\b|python3?\s+-m\s+pytest\b|go\s+test\b|cargo\s+test\b)/i.test(command);
1078
+ }
1079
+
1080
+ function memberRunWantsReferee(runArgs, ignite, verifier = '') {
1081
+ return Boolean(ignite) && !hasFlag(runArgs, '--referee') && !hasFlag(runArgs, '--no-referee')
1082
+ && !memberRunHasRealCheck(verifier);
1070
1083
  }
1071
1084
 
1072
1085
  function memberRunCli(args, cwd, { live = false, missionId = '' } = {}) {
@@ -1393,7 +1406,10 @@ function memberRunWith(name, args, { quiet = false, closeTaskIds = [] } = {}) {
1393
1406
  ['--mission', '--mission-id', '--minutes', '--hours', '--industry', '--domain', '--value', '--outcome', '--truth', '--proof', '--base', '--yes-lock', '--yes-lock-parent'],
1394
1407
  ['--worktree', '--shared-checkout', '--no-worktree', '--choose-work', '--auto', '--ignite', '--wait', '--sync']);
1395
1408
  if (ignite && !hasFlag(runArgs, '--complete-on-pass')) runArgs.push('--complete-on-pass');
1396
- if (memberRunWantsReferee(runArgs, ignite)) runArgs.push('--referee');
1409
+ if (memberRunWantsReferee(runArgs, ignite, mission.verifier)) runArgs.push('--referee');
1410
+ // Skipped because a real test is the check: review anyway if the worker
1411
+ // edits what that test command runs.
1412
+ else if (ignite && memberRunHasRealCheck(mission.verifier) && !hasFlag(runArgs, '--no-referee')) runArgs.push('--referee-if-check-edited');
1397
1413
  const budgetSeconds = memberRunBudgetSeconds(args);
1398
1414
  if (!readFlag(runArgs, '--max-ticks', '')) {
1399
1415
  runArgs.push('--max-ticks', budgetSeconds ? String(Math.max(4, Math.ceil(budgetSeconds / 300))) : '1');
@@ -788,6 +788,7 @@ const MISSION_RUN_BOOLEAN_FLAGS = [
788
788
  '--no-claude',
789
789
  '--no-verify',
790
790
  '--referee',
791
+ '--referee-if-check-edited',
791
792
  '--no-referee',
792
793
  '--complete-on-pass',
793
794
  '--no-drain',
@@ -6950,6 +6951,39 @@ function missionProtectedTags(mission, root = process.cwd()) {
6950
6951
  return tags;
6951
6952
  }
6952
6953
 
6954
+ // True when the tick changed what its check command runs: package.json (which
6955
+ // defines npm test) or a file the command names. A passing test the worker
6956
+ // rewrote is not evidence on its own.
6957
+ function tickTouchedCheckDefinition(verifier, before, root = process.cwd()) {
6958
+ const run = (args) => spawnSync('git', args, { cwd: root, encoding: 'utf8' });
6959
+ const head = String(before?.head || '').trim();
6960
+ const changed = [
6961
+ // No starting commit to compare against: every tracked file counts.
6962
+ ...String(run(head ? ['diff', '--name-only', head] : ['ls-files']).stdout || '').split('\n'),
6963
+ ...String(run(['diff', '--name-only', 'HEAD']).stdout || '').split('\n'),
6964
+ ...String(run(['ls-files', '--others', '--exclude-standard']).stdout || '').split('\n'),
6965
+ ].map((file) => file.trim()).filter(Boolean);
6966
+ // npm test runs whatever package.json says, so its paths and folders count too.
6967
+ let checkText = String(verifier || '');
6968
+ if (/^(npm|pnpm|yarn)\s/i.test(checkText.trim())) {
6969
+ try { checkText += ` ${JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'))?.scripts?.test || ''}`; } catch {}
6970
+ }
6971
+ // Runners that find tests by name (node --test, jest, pytest, go test) never
6972
+ // list them, so any changed file shaped like a test counts as well.
6973
+ const testShaped = /(^|\/)(tests?|__tests__|specs?|checks?)\/|\.(test|spec)\.[cm]?[jt]sx?$|_test\.(go|py)$|(^|\/)test_[^/]*\.py$|(^|\/)conftest\.py$/i;
6974
+ return changed.some((file) => path.basename(file) === 'package.json'
6975
+ || testShaped.test(file)
6976
+ || checkText.includes(file)
6977
+ || (path.dirname(file) !== '.' && checkText.includes(`${path.dirname(file)}/`)));
6978
+ }
6979
+
6980
+ // The worker's `learned:` line from its receipt, so the lesson reaches the
6981
+ // member log (and the next worker's preloaded memory) without a hand edit.
6982
+ function receiptLearnedLine(text) {
6983
+ const match = String(text || '').match(/^[\s*-]*learned:\s*(.+)$/im);
6984
+ return match ? match[1].trim().slice(0, 240) : undefined;
6985
+ }
6986
+
6953
6987
  function inspectMissionTickProtectedDiff(mission, before, root = process.cwd()) {
6954
6988
  const after = gitWorktreeSnapshot(root);
6955
6989
  if (before?.reason === 'not-git-worktree' && after?.reason === 'not-git-worktree') {
@@ -9153,6 +9187,31 @@ function readBoundedContextFile(filePath, maxChars = 1600) {
9153
9187
  }
9154
9188
  }
9155
9189
 
9190
+ // Code members never call the account-wide tool servers (mail, calendar,
9191
+ // docs), yet loading them cost about 5s per worker start in the member lab.
9192
+ // They keep the project's own .mcp.json; ATRIS_MEMBER_KEEP_MCP=1 keeps all.
9193
+ const CODE_MEMBERS = new Set(['executor', 'builder', 'codex-executor', 'auto-improver', 'navigator', 'validator', 'architect']);
9194
+
9195
+ function claudeRunnerMcpArgs(mission, cwd = process.cwd(), env = process.env) {
9196
+ if (env.ATRIS_MEMBER_KEEP_MCP === '1') return [];
9197
+ if (!CODE_MEMBERS.has(String(mission?.owner || '').trim().toLowerCase())) return [];
9198
+ const args = ['--strict-mcp-config'];
9199
+ const projectConfig = path.join(cwd, '.mcp.json');
9200
+ if (fs.existsSync(projectConfig)) args.push('--mcp-config', projectConfig);
9201
+ // Servers added for this folder with Claude's local scope live in
9202
+ // ~/.claude.json under the folder's path; keep those too.
9203
+ try {
9204
+ const home = env.HOME || os.homedir();
9205
+ const projects = JSON.parse(fs.readFileSync(path.join(home, '.claude.json'), 'utf8'))?.projects || {};
9206
+ // A side checkout's servers are usually registered under the main checkout.
9207
+ const common = spawnSync('git', ['rev-parse', '--git-common-dir'], { cwd, encoding: 'utf8' });
9208
+ const mainRoot = common.status === 0 ? path.dirname(path.resolve(cwd, String(common.stdout || '').trim())) : '';
9209
+ const local = { ...(mainRoot ? projects[mainRoot]?.mcpServers : null), ...projects[path.resolve(cwd)]?.mcpServers };
9210
+ if (Object.keys(local).length) args.push('--mcp-config', JSON.stringify({ mcpServers: local }));
9211
+ } catch {}
9212
+ return args;
9213
+ }
9214
+
9156
9215
  function buildPreloadedMemberContextLines(mission, root = process.cwd()) {
9157
9216
  const owner = String(mission?.owner || '').trim();
9158
9217
  const baseRoot = mission?.worktree_root || mission?.worktree?.path || root;
@@ -9282,7 +9341,7 @@ function buildTickPrompt(mission, tickIndex, maxTicks, frozen, pings = [], cwd =
9282
9341
  ``,
9283
9342
  `## Your task`,
9284
9343
  `Do ONE increment of work toward the stop condition. ONE. No more.`,
9285
- `- ${memberIdentityLine(mission.owner)} Read atris/team/${mission.owner}/MEMBER.md (and SOUL.md if present) before acting, work in that identity, inside its scope and stop rules. After your work, append what you did and what you learned to atris/team/${mission.owner}/logs/<today's date>.md.`,
9344
+ `- ${memberIdentityLine(mission.owner)} Your member files are preloaded above; open atris/team/${mission.owner}/MEMBER.md only if that copy is marked truncated. Work in that identity, inside its scope and stop rules. Atris writes your member log from your receipt, so end the receipt with one \`learned: <one line>\` line instead of editing the log.`,
9286
9345
  `- FIRST: inspect current mission/task state before acting. Read the relevant files, run \`atris mission status ${mission.id}\`, \`git status\`, or \`atris task list\` as needed so you know what's already done.`,
9287
9346
  `- Pick the smallest concrete action that moves the mission forward.`,
9288
9347
  `- Before acting, state your single next move in one sentence.`,
@@ -9290,7 +9349,7 @@ function buildTickPrompt(mission, tickIndex, maxTicks, frozen, pings = [], cwd =
9290
9349
  `- The tick is not done until the verify step passes. If a frozen verifier exists, the harness runs it, but that does not replace the real-surface proof below. If no frozen verifier exists, finish by actually running the surface you changed on this computer and paste the real command output into your receipt.`,
9291
9350
  `- After that one verify step, STOP. Never start a second slice in one tick.`,
9292
9351
  `- Write the tick summary in operator language: what changed and what it buys or costs in plain words, with no flags, task ids, or code identifiers.`,
9293
- `- Propose the next needle-mover: include a \`next_push: <one concrete feature or optimization to build next>\` line in your receipt and member log so the team compounds exponentially.`,
9352
+ `- Propose the next needle-mover: include a \`next_push: <one concrete feature or optimization to build next>\` line in your receipt so the team compounds exponentially.`,
9294
9353
  ``,
9295
9354
  `## No claimable work? Work the backstop ladder`,
9296
9355
  `If the mission and task state offer no claimable work, inspect these rungs in order. Pick the FIRST rung with real fuel and do ONE bounded slice:`,
@@ -9686,6 +9745,7 @@ function spawnGenericRunnerTick(mission, opts) {
9686
9745
  result: finalText,
9687
9746
  summary: usefulClaudeReceiptSummary(finalText || errStr, ok ? 'no-text' : 'error'),
9688
9747
  receipt_text: cappedClaudeReceiptText(finalText || errStr),
9748
+ learned: receiptLearnedLine(finalText),
9689
9749
  duration_total_ms: Date.now() - startedAt,
9690
9750
  num_turns: null,
9691
9751
  stop_reason: null,
@@ -9775,6 +9835,7 @@ function spawnClaudeTick(mission, opts) {
9775
9835
  ];
9776
9836
  if (model) args.push('--model', model);
9777
9837
  if (mission.roster_effort) args.push('--effort', mission.roster_effort);
9838
+ args.push(...claudeRunnerMcpArgs(mission, cwd));
9778
9839
  if (sessionMode === 'set') args.push('--session-id', sessionId);
9779
9840
  else if (sessionMode === 'resume') args.push('--resume', sessionId);
9780
9841
 
@@ -9881,6 +9942,7 @@ function spawnClaudeTick(mission, opts) {
9881
9942
  result: finalText,
9882
9943
  summary: usefulClaudeReceiptSummary(finalText, ok ? 'no-text' : 'error'),
9883
9944
  receipt_text: cappedClaudeReceiptText(finalText),
9945
+ learned: receiptLearnedLine(finalText),
9884
9946
  api_equivalent_estimate: costEstimate,
9885
9947
  duration_api_ms: durationApiMs,
9886
9948
  duration_total_ms: Date.now() - startedAt,
@@ -10124,6 +10186,7 @@ async function parseAndValidateMissionRunPhase(args) {
10124
10186
  const skipClaude = hasFlag(args, '--no-claude');
10125
10187
  const verifyEach = !hasFlag(args, '--no-verify');
10126
10188
  const refereeEach = hasFlag(args, '--referee') && !hasFlag(args, '--no-referee');
10189
+ const refereeIfCheckEdited = hasFlag(args, '--referee-if-check-edited') && !hasFlag(args, '--no-referee');
10127
10190
  const refereeEngine = readFlag(args, '--referee-engine', '');
10128
10191
  const completeOnPass = hasFlag(args, '--complete-on-pass');
10129
10192
  const skipDrain = hasFlag(args, '--no-drain');
@@ -10161,6 +10224,7 @@ async function parseAndValidateMissionRunPhase(args) {
10161
10224
  skipClaude,
10162
10225
  verifyEach,
10163
10226
  refereeEach,
10227
+ refereeIfCheckEdited,
10164
10228
  refereeEngine,
10165
10229
  completeOnPass,
10166
10230
  skipDrain,
@@ -10995,6 +11059,7 @@ async function executeMissionRunTicksPhase(context) {
10995
11059
  verifier: verifierResult ? (verifierResult.passed ? 'passed' : 'failed') : 'not_run',
10996
11060
  summary: tickSummaryText ? tickSummaryText.slice(0, 240) : undefined,
10997
11061
  next_push: result.next_push ? String(result.next_push).slice(0, 180) : undefined,
11062
+ learned: result?.claude?.learned || receiptLearnedLine(result?.claude?.result || result?.claude?.receipt_text),
10998
11063
  feedback: verifierResult && !verifierResult.passed ? String(mission.last_check_feedback || '').slice(0, 240) : undefined,
10999
11064
  receipt: receiptPath,
11000
11065
  }, cwd);
@@ -11192,6 +11257,7 @@ async function verifyMissionRunTickPhase(context) {
11192
11257
  && (
11193
11258
  !currentTick.frozen.verifier
11194
11259
  || context.refereeEach
11260
+ || (context.refereeIfCheckEdited && tickTouchedCheckDefinition(currentTick.frozen.verifier, currentTick.tickWorktreeBefore, cwd))
11195
11261
  || (!currentTick.storedVerifier && !process.env.NODE_TEST_CONTEXT)
11196
11262
  );
11197
11263
  if (shouldRunReferee) {
@@ -12860,6 +12926,9 @@ function missionCommand(args) {
12860
12926
  }
12861
12927
 
12862
12928
  module.exports = {
12929
+ receiptLearnedLine,
12930
+ tickTouchedCheckDefinition,
12931
+ claudeRunnerMcpArgs,
12863
12932
  crystallizeMemberSkillLesson,
12864
12933
  listSiblingWorkspaceMissionHints,
12865
12934
  missionProtectedLaneHold,
package/lib/gm-bet.js CHANGED
@@ -372,7 +372,11 @@ const SAY_READ_MS = 15000;
372
372
  const SAY_KINDS = new Set(['question', 'answer', 'instruction', 'unclear']);
373
373
  // on the weekly number card, what an instruction wants done with the number
374
374
  const NUMBER_INTENTS = new Set(['stop', 'change', 'later', 'other']);
375
+ // only for a model that answered and could not make sense of the words
375
376
  const SAY_FALLBACK = 'say that another way, or tap a button.';
377
+ // no model answered the words: never put on them
378
+ const SAY_NO_MODEL = 'the orb needs a model signed in on this computer to read that, so nothing was saved.';
379
+ const SAY_NOT_HEARD = 'the model did not answer this time, so nothing was saved. try again in a moment.';
376
380
  // the last turns of a card's conversation the reader sees
377
381
  const THREAD_TURNS = 6;
378
382
 
@@ -391,7 +395,10 @@ function readerModel(workspaceRoot) {
391
395
  // One quick call: what kind of message the owner typed on the card on
392
396
  // screen, and what to say back. The card, the conversation on it so far, and
393
397
  // a short view of the project go in.
394
- async function readSaid({ text, card, thread = [], hint = null, workspaceRoot, bundle = null, deps = {} } = {}) {
398
+ // A read no model answered says so (no_model, retry from think); a model
399
+ // that answered with a read of no use says answered, so only that one asks
400
+ // the owner to say it another way.
401
+ async function readSaid({ text, card, thread = [], hint = null, workspaceRoot, bundle = null, deps = {}, timeoutMs = SAY_READ_MS } = {}) {
395
402
  const context = clip((deps.readContext || readProjectContext)(workspaceRoot), 1500);
396
403
  const goal = bundle && bundle.sources && bundle.sources.goal;
397
404
  const prompt = [
@@ -424,11 +431,12 @@ async function readSaid({ text, card, thread = [], hint = null, workspaceRoot, b
424
431
  context || '(no readme or goals found)',
425
432
  ].filter(l => l !== '').join('\n');
426
433
  const model = (deps.readerModel || readerModel)(workspaceRoot);
427
- const said = await (deps.runModel || runModel)(prompt, { cwd: workspaceRoot, timeoutMs: SAY_READ_MS, command: { bin: 'claude', args: p => claudeArgs(p, model) }, onProgress: () => {} });
428
- if (!said.ok) return { ok: false, reason: said.reason };
434
+ const run = deps.runModel || ((p, o) => think(p, { ...o, root: workspaceRoot }));
435
+ const said = await run(prompt, { cwd: workspaceRoot, timeoutMs, command: { bin: 'claude', args: p => claudeArgs(p, model) }, onProgress: () => {} });
436
+ if (!said.ok) return { ok: false, reason: said.reason, ...(said.no_model ? { no_model: said.no_model, retry: Boolean(said.retry) } : {}) };
429
437
  const parsed = parseBet(said.text);
430
438
  const v = parsed.bet;
431
- if (!v || !SAY_KINDS.has(v.kind) || typeof v.reply !== 'string') return { ok: false, reason: 'the read was not the shape asked for' };
439
+ if (!v || !SAY_KINDS.has(v.kind) || typeof v.reply !== 'string') return { ok: false, reason: 'the read was not the shape asked for', answered: true };
432
440
  const suggestions = Array.isArray(v.suggestions) ? v.suggestions.filter(x => typeof x === 'string' && x.trim()).map(x => x.trim()) : [];
433
441
  const intent = card.kind === 'number' && NUMBER_INTENTS.has(v.intent) ? v.intent : null;
434
442
  return { ok: true, kind: v.kind, reply: clip(v.reply, 280), ...(intent ? { intent } : {}), ...(suggestions.length ? { suggestions } : {}) };
@@ -452,7 +460,8 @@ async function generateScoreboardChoices({ workspaceRoot, bundle = null, deps =
452
460
  '',
453
461
  bundleText(b),
454
462
  ].join('\n');
455
- const said = await (deps.runModel || runModel)(prompt, { cwd: workspaceRoot, onProgress: () => {} });
463
+ const run = deps.runModel || ((p, o) => think(p, { ...o, root: workspaceRoot }));
464
+ const said = await run(prompt, { cwd: workspaceRoot, onProgress: () => {} });
456
465
  if (!said.ok) return { ok: false, reason: said.reason };
457
466
  const parsed = parseBet(said.text);
458
467
  const list = parsed.bet && Array.isArray(parsed.bet.choices) ? parsed.bet.choices : [];
@@ -470,7 +479,7 @@ async function generateQuestion({ workspaceRoot, answers = [], avoid = [], bundl
470
479
  const b = bundle || await readBundle(workspaceRoot, { deps, ledgerFile, today });
471
480
  const context = (deps.readContext || readProjectContext)(workspaceRoot);
472
481
  const source = `${context}\n${bundleText(b)}`;
473
- const run = deps.runModel || runModel;
482
+ const run = deps.runModel || ((p, o) => think(p, { ...o, root: workspaceRoot }));
474
483
  const ask = async (prompt) => {
475
484
  const said = await run(prompt, { cwd: workspaceRoot, onProgress: () => {} });
476
485
  if (!said.ok) return { failed: said.reason };
@@ -1523,10 +1532,14 @@ function quietDir() {
1523
1532
  // Writers running now, by process group, so the server can end them when it
1524
1533
  // stops; otherwise a stopped server leaves them running on their own.
1525
1534
  const LIVE_WRITERS = new Map();
1535
+ // how many times the server stopped its writers: a call under way when it
1536
+ // did starts no other model after
1537
+ let WRITER_STOPS = 0;
1526
1538
 
1527
1539
  // SIGTERM now, then SIGKILL for the same groups after the grace, so a child
1528
1540
  // that ignores SIGTERM does not outlive the server either.
1529
1541
  function stopWriters() {
1542
+ WRITER_STOPS += 1;
1530
1543
  let n = 0;
1531
1544
  const groups = [...LIVE_WRITERS];
1532
1545
  for (const [pid, kill] of groups) {
@@ -1544,6 +1557,9 @@ function stopWriters() {
1544
1557
  return n;
1545
1558
  }
1546
1559
 
1560
+ // A start that failed because the program is not on this computer.
1561
+ const notHere = err => (err && err.code === 'ENOENT' ? { missing: true } : {});
1562
+
1547
1563
  // A signal (an AbortSignal) stops the call early: the whole process group
1548
1564
  // gets SIGTERM, then SIGKILL if it is still there after STOP_GRACE_MS, and
1549
1565
  // the call resolves once the process is gone, so nothing it started is left.
@@ -1561,7 +1577,7 @@ function runModel(prompt, { command = { bin: 'claude', args: claudeArgs }, cwd,
1561
1577
  try {
1562
1578
  child = spawnFn(command.bin, args, { cwd, env: modelEnv(env), detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'] });
1563
1579
  } catch (err) {
1564
- resolve({ ok: false, reason: `could not start the model: ${err.message}` });
1580
+ resolve({ ok: false, reason: `could not start the model: ${err.message}`, ...notHere(err) });
1565
1581
  return;
1566
1582
  }
1567
1583
  if (child && child.pid) LIVE_WRITERS.set(child.pid, kill);
@@ -1571,6 +1587,8 @@ function runModel(prompt, { command = { bin: 'claude', args: claudeArgs }, cwd,
1571
1587
  let text = '';
1572
1588
  let result = null;
1573
1589
  let thinking = 0;
1590
+ // the model began to answer: the api sent its first event back
1591
+ let heard = false;
1574
1592
  let timedOut = false;
1575
1593
  let stopped = false;
1576
1594
  let hardKill = null;
@@ -1610,6 +1628,7 @@ function runModel(prompt, { command = { bin: 'claude', args: claudeArgs }, cwd,
1610
1628
  } catch {
1611
1629
  return;
1612
1630
  }
1631
+ if (ev.type === 'stream_event' || ev.type === 'assistant' || ev.type === 'result') heard = true;
1613
1632
  if (ev.type === 'system' && ev.subtype === 'thinking_tokens' && Number.isFinite(ev.estimated_tokens)) {
1614
1633
  thinking = ev.estimated_tokens;
1615
1634
  onProgress({ thinking, chars: text.length, text });
@@ -1641,11 +1660,12 @@ function runModel(prompt, { command = { bin: 'claude', args: claudeArgs }, cwd,
1641
1660
  if (!stopped) LIVE_WRITERS.delete(child.pid);
1642
1661
  resolve(out);
1643
1662
  };
1644
- child.on('error', err => finish({ ok: false, reason: `could not start the model: ${err.message}` }));
1663
+ child.on('error', err => finish({ ok: false, reason: `could not start the model: ${err.message}`, ...notHere(err) }));
1645
1664
  child.on('close', (code) => {
1646
1665
  if (buf) line(buf);
1647
- if (stopped) return finish({ ok: false, reason: STOPPED_REASON, stopped: true });
1648
- if (timedOut) return finish({ ok: false, reason: `the model took longer than ${Math.round(timeoutMs / 1000)} seconds and was stopped`, timed_out: true });
1666
+ const began = heard ? { heard: true } : {};
1667
+ if (stopped) return finish({ ok: false, reason: STOPPED_REASON, stopped: true, ...began });
1668
+ if (timedOut) return finish({ ok: false, reason: `the model took longer than ${Math.round(timeoutMs / 1000)} seconds and was stopped`, timed_out: true, ...began });
1649
1669
  if (result && result.is_error) return finish({ ok: false, reason: `the model said: ${clip(result.result || result.subtype, 160)}` });
1650
1670
  const answer = result && typeof result.result === 'string' ? result.result : text;
1651
1671
  if (code !== 0 && !answer) return finish({ ok: false, reason: `the model exited with code ${code}${stderr.trim() ? `: ${clip(stderr.trim().split('\n').pop(), 160)}` : ''}` });
@@ -1655,6 +1675,196 @@ function runModel(prompt, { command = { bin: 'claude', args: claudeArgs }, cwd,
1655
1675
  });
1656
1676
  }
1657
1677
 
1678
+ // ---- who thinks: claude first, then the next model signed in here ---------------------
1679
+
1680
+ // The orb thinks on claude, the way it always has. When claude is not on
1681
+ // this computer, fails, or never starts to answer, the next model the
1682
+ // engine registry says is here answers instead, in the registry's own
1683
+ // fallback order, each on its own default model and launched the way
1684
+ // `atris engine ask` launches it (read-only). Hosted engines are left out:
1685
+ // they spend the owner's atris tokens, and the orb drafts in the background.
1686
+ //
1687
+ // What is known lives in memory, one entry per workspace: the registry's
1688
+ // list, read the way every router reads it (the first read saves it, later
1689
+ // reads take about a millisecond; an unsaved one probes every tool, about
1690
+ // half a second on a busy Mac) and kept for ten minutes (THINKER_LIST_MS),
1691
+ // and the models that just failed, which sit out for five minutes
1692
+ // (THINKER_BENCH_MS). A tap on the card that says no model answered forgets
1693
+ // both (forgetThinkers), so a model signed in a moment ago works on the very
1694
+ // next tap.
1695
+ const THINKER_LIST_MS = 10 * 60 * 1000;
1696
+ const THINKER_BENCH_MS = 5 * 60 * 1000;
1697
+ // claude that sent nothing back from the api for this long is not answering
1698
+ const UNHEARD_MS = 15000;
1699
+ const THINKERS = new Map();
1700
+
1701
+ function thinkerState(root) {
1702
+ const key = path.resolve(String(root || '.'));
1703
+ if (!THINKERS.has(key)) THINKERS.set(key, { list: null, listAt: 0, bench: new Map(), answered: new Map(), benches: 0, fresh: false });
1704
+ return THINKERS.get(key);
1705
+ }
1706
+
1707
+ // The next list read looks for every tool itself. The count of benches
1708
+ // only grows, so a drafting under way still knows whether one sat out.
1709
+ function forgetThinkers(root) {
1710
+ const st = thinkerState(root);
1711
+ st.list = null;
1712
+ st.fresh = true;
1713
+ st.bench.clear();
1714
+ st.answered.clear();
1715
+ }
1716
+
1717
+ // The models that can think here, in order: the one on the claude program
1718
+ // first (it runs the orb's own command), then every other installed,
1719
+ // non-hosted engine by the registry's fallback order, one per program.
1720
+ function thinkerLineup(root) {
1721
+ const st = thinkerState(root);
1722
+ if (st.list && Date.now() - st.listAt < THINKER_LIST_MS) return st.list;
1723
+ let engines = [];
1724
+ try {
1725
+ const reg = require('./engine-registry');
1726
+ const known = reg.readEngineRegistry(root).engines.filter(e => !reg.engineHosted(e));
1727
+ engines = known.filter(e => e.installed === true);
1728
+ // A saved "not here" can be older than the tool on this computer now (a
1729
+ // saved credit wall is never looked at again, a missing tool for a
1730
+ // minute), so the orb looks for itself: for its own model on every read,
1731
+ // and for every tool after a tap on the card that says no model answered.
1732
+ const bins = new Set(engines.map(e => e.bin));
1733
+ for (const e of known.filter(x => st.fresh || x.id === 'claude')) {
1734
+ if (bins.has(e.bin)) continue;
1735
+ bins.add(e.bin);
1736
+ if (reg.binInstalled(e.bin)) engines.push(e);
1737
+ }
1738
+ } catch {
1739
+ engines = [];
1740
+ }
1741
+ const lead = e => (e.bin === 'claude' ? 0 : 1);
1742
+ engines.sort((a, b) => lead(a) - lead(b) || Number(a.fallback_order) - Number(b.fallback_order) || String(a.id).localeCompare(String(b.id)));
1743
+ const once = new Set();
1744
+ st.list = engines.filter(e => !once.has(e.bin) && once.add(e.bin)).map(e => ({ id: e.id, lead: e.bin === 'claude' }));
1745
+ st.listAt = Date.now();
1746
+ st.fresh = false;
1747
+ return st.list;
1748
+ }
1749
+
1750
+ function benched(st, id) {
1751
+ const b = st.bench.get(id);
1752
+ if (!b) return false;
1753
+ if (Date.now() < b.until) return true;
1754
+ st.bench.delete(id);
1755
+ return false;
1756
+ }
1757
+
1758
+ function benchThinker(st, id, reason, missing = false) {
1759
+ st.bench.set(id, { until: Date.now() + THINKER_BENCH_MS, reason: clip(String(reason || ''), 160), missing });
1760
+ st.benches += 1;
1761
+ }
1762
+
1763
+ // 'ready': a model here can take the next call. 'none': no model is on this
1764
+ // computer. 'failed': every one here failed or never answered just now.
1765
+ function thinkerVerdict(root) {
1766
+ const st = thinkerState(root);
1767
+ const list = thinkerLineup(root);
1768
+ if (list.some(t => !benched(st, t.id))) return 'ready';
1769
+ return list.every(t => st.bench.get(t.id) && st.bench.get(t.id).missing) ? 'none' : 'failed';
1770
+ }
1771
+
1772
+ // A fallback model's child joins the writers the server ends when it stops.
1773
+ function trackedSpawn(bin, args, opts) {
1774
+ const child = require('child_process').spawn(bin, args, opts);
1775
+ if (child && child.pid) {
1776
+ const kill = process.kill.bind(process);
1777
+ LIVE_WRITERS.set(child.pid, kill);
1778
+ child.once('close', () => { if (LIVE_WRITERS.get(child.pid) === kill) LIVE_WRITERS.delete(child.pid); });
1779
+ }
1780
+ return child;
1781
+ }
1782
+
1783
+ // One answer from a model other than claude, through the read-only command
1784
+ // `atris engine ask` builds for it, in the same shape runModel answers.
1785
+ function askThinker(id, prompt, { timeoutMs = MODEL_TIMEOUT_MS, onProgress = () => {}, signal = null } = {}) {
1786
+ const ask = require('./engine-ask');
1787
+ let invocation;
1788
+ try {
1789
+ invocation = ask.buildReadOnlyEngineInvocation(id, prompt, '', { timeoutMs, effort: IDEA_EFFORT });
1790
+ } catch (err) {
1791
+ return Promise.resolve({ ok: false, reason: `could not start the model: ${err.message}` });
1792
+ }
1793
+ let text = '';
1794
+ // any word on stdout: the model began to answer
1795
+ let heard = false;
1796
+ return ask.runAskProcess(invocation, {
1797
+ cwd: invocation.cwd || quietDir(),
1798
+ timeoutMs,
1799
+ signal,
1800
+ env: modelEnv(process.env),
1801
+ spawnProcess: trackedSpawn,
1802
+ onOutputChunk: (chunk, stream) => {
1803
+ if (stream !== 'stdout' || text.length > OUTPUT_MAX) return;
1804
+ heard = true;
1805
+ text += chunk.toString('utf8');
1806
+ onProgress({ thinking: 0, chars: text.length, text });
1807
+ },
1808
+ }).then((r) => {
1809
+ const began = heard ? { heard: true } : {};
1810
+ if (r.cancelled) return { ok: false, reason: STOPPED_REASON, stopped: true, ...began };
1811
+ if (r.timed_out) return { ok: false, reason: `the model took longer than ${Math.round(timeoutMs / 1000)} seconds and was stopped`, timed_out: true, ...began };
1812
+ const tail = String(r.stderr || r.stdout || '').trim().split('\n').pop();
1813
+ if (r.reason === 'spawn_error') return { ok: false, reason: `could not start the model: ${clip(tail, 160)}`, ...(/ENOENT/.test(r.stderr || '') ? { missing: true } : {}) };
1814
+ // a failed exit is never an answer, whatever it printed (a login error on stdout)
1815
+ if (r.exit_code !== 0) return { ok: false, reason: `the model exited with code ${r.exit_code}${tail ? `: ${clip(tail, 160)}` : ''}` };
1816
+ const answer = String(r.stdout || '');
1817
+ if (!answer.trim()) return { ok: false, reason: 'the model gave an empty answer' };
1818
+ return { ok: true, text: answer };
1819
+ });
1820
+ }
1821
+
1822
+ // One call through the lineup: each model in turn until one answers, each
1823
+ // with what is left of timeoutMs. command is the claude command the call
1824
+ // wants (its model and effort); the others ride their own defaults. A stop,
1825
+ // or the server stopping its writers, ends it at once. A model that fails
1826
+ // sits out; so does one that said nothing until its time ran out, or for
1827
+ // unheardMs before a stop, unless it answered another call meanwhile. A
1828
+ // model that began to answer and is only slow keeps its place, and one
1829
+ // that answers is back in at once. With no answer, no_model says why:
1830
+ // 'none' when no model is on this computer, else 'failed'; retry says
1831
+ // another one here can still take the next call.
1832
+ async function think(prompt, { root, command = { bin: 'claude', args: claudeArgs }, cwd, timeoutMs = MODEL_TIMEOUT_MS, onProgress = () => {}, signal = null, unheardMs = UNHEARD_MS } = {}) {
1833
+ const st = thinkerState(root);
1834
+ const deadline = Date.now() + timeoutMs;
1835
+ const stops = WRITER_STOPS;
1836
+ const shutDown = () => ({ ok: false, reason: STOPPED_REASON, stopped: true });
1837
+ let last = null;
1838
+ for (const t of thinkerLineup(root)) {
1839
+ if (WRITER_STOPS !== stops) return shutDown();
1840
+ if (benched(st, t.id)) continue;
1841
+ const left = deadline - Date.now();
1842
+ if (left <= 0) break;
1843
+ const started = Date.now();
1844
+ const out = t.lead
1845
+ ? await runModel(prompt, { command, cwd, timeoutMs: left, onProgress, signal })
1846
+ : await askThinker(t.id, prompt, { timeoutMs: left, onProgress, signal });
1847
+ if (out.ok) {
1848
+ st.answered.set(t.id, Date.now());
1849
+ st.bench.delete(t.id);
1850
+ return { ...out, engine: t.id };
1851
+ }
1852
+ // the server stopped while this one ran: not the model's failure
1853
+ if (WRITER_STOPS !== stops) return shutDown();
1854
+ // heard: claude's api sent an event back, or another model printed a word
1855
+ const silent = !out.heard && !(st.answered.get(t.id) >= started);
1856
+ if (out.stopped) {
1857
+ if (silent && Date.now() - started >= unheardMs) benchThinker(st, t.id, 'it never started to answer');
1858
+ return { ...out, engine: t.id, ...(silent ? { unheard: true } : {}) };
1859
+ }
1860
+ last = out;
1861
+ if (out.timed_out && !silent) break;
1862
+ benchThinker(st, t.id, out.reason, Boolean(out.missing));
1863
+ }
1864
+ const verdict = thinkerVerdict(root);
1865
+ return { ok: false, reason: last ? last.reason : 'no model here can think right now', no_model: verdict === 'none' ? 'none' : 'failed', retry: verdict === 'ready' };
1866
+ }
1867
+
1658
1868
  // ---- checking a bet -------------------------------------------------------------------
1659
1869
 
1660
1870
  function parseBet(text) {
@@ -2152,6 +2362,14 @@ function ownerConcern(c) {
2152
2362
  // Every model attempt, writer and skeptic (the kind field), with its lane, is
2153
2363
  // kept in the attempts log, the stopped ones too.
2154
2364
  async function generateBet({ workspaceRoot, today, previousTitle = null, avoidTitles: avoidIn = [], reworkOf = null, ownerNote = null, concern = null, emit = () => {}, onBundle = () => {}, deps = {}, ledgerFile, attemptsFile, allowTeamMetrics = false, writers = deps.writers || SWARM_WRITERS, budgetMs = deps.budgetMs || SWARM_BUDGET_MS, signal = null } = {}) {
2365
+ // the orb's lineup of models thinks, unless a test stubs the model
2366
+ const lineup = !deps.runModel;
2367
+ // no model here can think: said at once, before a fact is read
2368
+ if (lineup) {
2369
+ const verdict = thinkerVerdict(workspaceRoot);
2370
+ if (verdict !== 'ready') return { ok: false, reason: verdict === 'none' ? 'no model is on this computer' : 'every model here failed just now', no_model: verdict, retry: false };
2371
+ }
2372
+ const benchesBefore = lineup ? thinkerState(workspaceRoot).benches : 0;
2155
2373
  // the budget runs on the real clock, the one the stop timer uses
2156
2374
  const deadline = Date.now() + budgetMs;
2157
2375
  const left = () => deadline - Date.now();
@@ -2159,10 +2377,12 @@ async function generateBet({ workspaceRoot, today, previousTitle = null, avoidTi
2159
2377
  const judgeMin = Math.min(JUDGE_MIN_LEFT_MS, budgetMs * (JUDGE_MIN_LEFT_MS / SWARM_BUDGET_MS));
2160
2378
  // a rework may stay close to the idea it reworks
2161
2379
  const avoidTitles = reworkOf ? avoidIn.filter(t => t !== reworkOf) : avoidIn;
2162
- const run = deps.runModel || ((prompt, opts) => runModel(prompt, { ...opts, command: writerCommand() }));
2380
+ // claude silent for half the budget is not answering this drafting
2381
+ const unheardMs = Math.min(UNHEARD_MS, budgetMs / 2);
2382
+ const run = deps.runModel || ((prompt, opts) => think(prompt, { ...opts, root: workspaceRoot, command: writerCommand(), unheardMs }));
2163
2383
  // tests pass runSkeptic; a stubbed runModel without one is also used for the
2164
2384
  // skeptic, so a test can never reach the real model by accident
2165
- const judge = deps.runSkeptic || deps.runModel || ((prompt, opts) => runModel(prompt, { ...opts, command: skepticCommand() }));
2385
+ const judge = deps.runSkeptic || deps.runModel || ((prompt, opts) => think(prompt, { ...opts, root: workspaceRoot, command: skepticCommand(), unheardMs }));
2166
2386
  const clock = deps.now || Date.now;
2167
2387
  const cwd = deps.runModel ? workspaceRoot : quietDir();
2168
2388
  const n = Math.max(1, Math.floor(Number(writers)) || 1);
@@ -2176,6 +2396,9 @@ async function generateBet({ workspaceRoot, today, previousTitle = null, avoidTi
2176
2396
  const live = new Set();
2177
2397
  let turnedDown = 0;
2178
2398
  let checking = 0;
2399
+ // writer answers that came back, and writers stopped before the api said a word
2400
+ let answers = 0;
2401
+ let unheard = 0;
2179
2402
  let winner = null;
2180
2403
  let over = null;
2181
2404
  let release;
@@ -2261,7 +2484,9 @@ async function generateBet({ workspaceRoot, today, previousTitle = null, avoidTi
2261
2484
  emit({ kind: 'thinking', line: w ? `thinking... ${commas(w)} words written` : 'thinking...', n: w, lane: laneNo });
2262
2485
  },
2263
2486
  });
2264
- const base = { at: new Date(clock()).toISOString(), workspace: workspaceRoot, today, attempt, strategy: how, ...tag, ...(how === 'blocker' ? { blocker: blocker.missing_first } : {}), previous_title: previousTitle, avoid_titles: avoid.length, ms: clock() - started };
2487
+ const base = { at: new Date(clock()).toISOString(), workspace: workspaceRoot, today, attempt, strategy: how, ...tag, ...(how === 'blocker' ? { blocker: blocker.missing_first } : {}), previous_title: previousTitle, avoid_titles: avoid.length, ms: clock() - started, ...(answer.engine ? { engine: answer.engine } : {}) };
2488
+ if (answer.ok) answers += 1;
2489
+ else if (answer.unheard) unheard += 1;
2265
2490
  if (!answer.ok) {
2266
2491
  saveAttempt(attemptsFile, { ...base, kind: 'writer', model_ok: false, ...(answer.stopped ? { stopped: true } : {}), raw: null, parse: null, passed: false, reasons: [answer.reason] });
2267
2492
  return { failed: answer.reason, stopped: Boolean(answer.stopped), base };
@@ -2373,6 +2598,13 @@ async function generateBet({ workspaceRoot, today, previousTitle = null, avoidTi
2373
2598
  Promise.all(lanes).then(() => stop('done'));
2374
2599
  await ended;
2375
2600
  finish();
2601
+ // out of time with no answer back: the stopped calls end within the stop's
2602
+ // grace, and only then is it known whether claude ever started to answer
2603
+ if (lineup && over === 'budget' && !answers) {
2604
+ let grace;
2605
+ await Promise.race([Promise.all(lanes), new Promise((resolve) => { grace = setTimeout(resolve, STOP_GRACE_MS + 500); })]);
2606
+ clearTimeout(grace);
2607
+ }
2376
2608
 
2377
2609
  if (over === 'stopped') return { ok: false, stopped: true, reason: 'stopped before an idea was picked', bundle };
2378
2610
  if (winner) return { ok: true, bet: { ...winner.bet, skeptic: winner.verdict }, bundle, attempts: winner.attempt, lane: winner.lane, checked: true };
@@ -2386,7 +2618,19 @@ async function generateBet({ workspaceRoot, today, previousTitle = null, avoidTi
2386
2618
  emit({ kind: 'none', line: 'no draft met the plain rules', say: SWARM_SAY.nothing });
2387
2619
  const failedAll = ends.length > 0 && ends.every(e => e.model_failed);
2388
2620
  const last = [...ends].reverse().find(e => e.reason);
2389
- if (failedAll) return { ok: false, reason: ends[0].reason, bundle };
2621
+ // No model answered: every writer's call failed, or the time ran out with
2622
+ // claude never starting to answer. That is the model's trouble, never the
2623
+ // owner's: no_model says so, and retry says another model here can take
2624
+ // the next drafting (one sat out during this one, so it is not the same try).
2625
+ const silent = over === 'budget' && !answers && unheard > 0;
2626
+ if (failedAll || silent) {
2627
+ const reason = failedAll ? ends[0].reason : `no model answered in ${Math.round(budgetMs / 1000)} seconds`;
2628
+ if (!lineup && failedAll) return { ok: false, reason, bundle, no_model: 'failed', retry: false };
2629
+ const verdict = lineup ? thinkerVerdict(workspaceRoot) : 'ready';
2630
+ if (verdict !== 'ready') return { ok: false, reason, bundle, no_model: verdict, retry: false };
2631
+ if (lineup && thinkerState(workspaceRoot).benches > benchesBefore) return { ok: false, reason, bundle, no_model: 'failed', retry: true };
2632
+ if (failedAll) return { ok: false, reason, bundle };
2633
+ }
2390
2634
  if (ends.length > 0 && ends.every(e => e.out_of_time) && !turnedDown) return { ok: false, reason: `no time was left to draft inside ${Math.round(budgetMs / 1000)} seconds once the facts were read`, bundle };
2391
2635
  if (over === 'budget') return { ok: false, reason: `nothing usable came back in ${Math.round(budgetMs / 1000)} seconds${last ? ` (${last.reason})` : ''}`, bundle };
2392
2636
  return { ok: false, reason: `the model's answer was turned down ${plural(turnedDown, 'time')} (${last ? last.reason : 'no answer met the rules'})`, bundle };
@@ -2854,6 +3098,7 @@ const BACKOFF_MS = 10 * 60 * 1000;
2854
3098
  // sayTimeoutMs how long a read may take before the plain fallback (15 s)
2855
3099
  // loadSeason(root) -> season, the board's 30 days of plays for each ovr (tests stub)
2856
3100
  // resolveEngine(name) -> { engine, model }, the member's roster pick (tests stub)
3101
+ // wakeThinkers() forget which models were missing or failing, on a tap on the card that says so
2857
3102
  function createBetDesk(deps) {
2858
3103
  const now = deps.now || Date.now;
2859
3104
  const setTimer = deps.setTimer || ((fn, ms) => { const t = setTimeout(fn, ms); if (t.unref) t.unref(); return t; });
@@ -2867,7 +3112,7 @@ function createBetDesk(deps) {
2867
3112
  // a desk with a stubbed idea writer never reaches the real reader either
2868
3113
  const readFn = deps.readSaid || (deps.generate
2869
3114
  ? (async () => ({ ok: false, reason: 'no reader here' }))
2870
- : (opts => readSaid({ ...opts, workspaceRoot: deps.workspaceRoot, bundle: state.bundle, deps: deps.generatorDeps || {} })));
3115
+ : (opts => readSaid({ ...opts, workspaceRoot: deps.workspaceRoot, bundle: state.bundle, deps: deps.generatorDeps || {}, ...(deps.sayTimeoutMs ? { timeoutMs: deps.sayTimeoutMs } : {}) })));
2871
3116
  const suggestNumbers = deps.suggestScoreboard || (deps.generate
2872
3117
  ? (() => new Promise(() => {}))
2873
3118
  : (opts => generateScoreboardChoices({ ...opts, deps: deps.generatorDeps || {}, ledgerFile: deps.ledgerFile, today: dayKey(now()) })));
@@ -2878,6 +3123,7 @@ function createBetDesk(deps) {
2878
3123
  // the real writer starts on the next turn, so its first reads never hold up a tap
2879
3124
  const generate = deps.generate || (opts => new Promise(resolve => setImmediate(resolve)).then(() => generateBet({ ...opts, deps: deps.generatorDeps || {}, ledgerFile: deps.ledgerFile, attemptsFile: deps.attemptsFile })));
2880
3125
  const log = deps.log || (() => {});
3126
+ const wakeThinkers = deps.wakeThinkers || (() => forgetThinkers(deps.workspaceRoot));
2881
3127
  const state = {
2882
3128
  status: 'idle',
2883
3129
  current: null,
@@ -3408,6 +3654,10 @@ function createBetDesk(deps) {
3408
3654
  log({ card: 'bet', choice: 'show', command: null, ran: 'in process', exit_code: 0, kind: 'wait' });
3409
3655
  }
3410
3656
  const onWait = () => state.status === 'ready' && Boolean(state.current) && state.current.kind === 'wait';
3657
+ // the card on screen says no model answered
3658
+ const onNoModel = () => state.status === 'ready' && Boolean(state.current) && state.current.kind === 'missed' && Boolean(state.current.no_model);
3659
+ // the line the calm line shows when one model did not answer and another takes over
3660
+ const NEXT_MODEL_LINE = 'the model here did not answer, so another one is trying.';
3411
3661
 
3412
3662
  // Words typed on the calm line are a note for the idea being written: no
3413
3663
  // read, no new card. The next drafting starts with them (one under way
@@ -3480,19 +3730,22 @@ function createBetDesk(deps) {
3480
3730
  return { progress: lines, started_at: startedAt, waited_ms: Math.max(0, now() - startedAt) };
3481
3731
  }
3482
3732
 
3483
- // The wait ended with no draft that met the plain rules (the model is down,
3484
- // or every draft broke a rule): a card the owner can act on, never an error.
3733
+ // The wait ended with no draft that met the plain rules: a card the owner
3734
+ // can act on, never an error. When no model answered at all (noModel:
3735
+ // 'none', no model on this computer, or 'failed', the one here did not
3736
+ // answer) the card says that instead, and what to do.
3485
3737
  const MISSED_CARD = { what: 'no idea came together this time.' };
3738
+ const NO_MODEL_CARD = { what: 'the orb needs a model signed in on this computer.' };
3486
3739
  // It remembers the wait it ended (after, and the id that wait was served
3487
3740
  // under), so words the owner was typing there still count.
3488
- function showMissed() {
3741
+ function showMissed(noModel = null) {
3489
3742
  const was = state.current && state.current.kind === 'wait' ? state.current : null;
3490
3743
  const servedWait = was && state.served && state.served.kind === 'wait' ? state.served.id : null;
3491
- state.current = { kind: 'missed', id: `missed:${now()}`, ...(was ? { after: was.id, afterServed: servedWait, ...(was.note ? { note: was.note, notes: was.notes } : {}) } : {}) };
3744
+ state.current = { kind: 'missed', id: `missed:${now()}`, ...(noModel ? { no_model: noModel } : {}), ...(was ? { after: was.id, afterServed: servedWait, ...(was.note ? { note: was.note, notes: was.notes } : {}) } : {}) };
3492
3745
  state.status = 'ready';
3493
3746
  state.mission = null;
3494
3747
  state.error = null;
3495
- log({ card: 'bet', choice: 'show', command: null, ran: 'in process', exit_code: 0, kind: 'missed' });
3748
+ log({ card: 'bet', choice: 'show', command: null, ran: 'in process', exit_code: 0, kind: 'missed', ...(noModel ? { no_model: noModel } : {}) });
3496
3749
  }
3497
3750
 
3498
3751
  // The owner asked for something (a sharper idea, words on a card): a
@@ -3601,12 +3854,24 @@ function createBetDesk(deps) {
3601
3854
  }
3602
3855
  if (!out.ok) {
3603
3856
  // a failure stays in the log; the owner never sees the reason
3604
- log({ card: 'bet', choice: 'generate', command: null, ran: 'in process', exit_code: 1, error: clip(out.reason, 200) });
3857
+ log({ card: 'bet', choice: 'generate', command: null, ran: 'in process', exit_code: 1, error: clip(out.reason, 200), ...(out.no_model ? { no_model: out.no_model } : {}) });
3605
3858
  emit({ kind: 'failed', line: out.reason });
3859
+ // one model did not answer and another one here can: the next
3860
+ // drafting starts on it now, with what this one was asked, and the
3861
+ // calm line says so instead of ending
3862
+ if (out.no_model && out.retry) {
3863
+ if (gen.asked && !state.pendingSharper) state.pendingSharper = asks;
3864
+ if (onWait()) {
3865
+ state.current.lines = [...(state.current.lines || []), { at: now(), line: NEXT_MODEL_LINE }];
3866
+ state.current.waitsOn = null;
3867
+ }
3868
+ return pump();
3869
+ }
3606
3870
  state.failures += 1;
3607
3871
  if (state.failures >= MAX_BACKGROUND_FAILURES) backOff();
3608
- // the owner is waiting on the calm line: the wait ends in a card he can act on
3609
- if (onWait()) showMissed();
3872
+ // the owner is waiting on the calm line: the wait ends in a card he
3873
+ // can act on, the one that says no model answered when none did
3874
+ if (onWait()) showMissed(out.no_model || null);
3610
3875
  return pump();
3611
3876
  }
3612
3877
  const item = { id: newId(), bet: out.bet, created_at: now(), state_key: key, ...(gen.asked ? { asked: true } : {}), ...(ownerNote && reworkOf ? { rework_of: reworkOf } : {}), ...(ownerNote ? { owner_note: ownerNote } : {}) };
@@ -3677,6 +3942,8 @@ function createBetDesk(deps) {
3677
3942
  // later: not now on a yes card.
3678
3943
  function next({ sharper = false, later = false } = {}) {
3679
3944
  if (state.status === 'starting') return { ok: true, busy: true, ...view() };
3945
+ // a tap on the card that says no model answered looks again, so one signed in since then is found
3946
+ if (onNoModel()) wakeThinkers();
3680
3947
  const onScreen = state.status === 'ready' && state.current ? state.current : null;
3681
3948
  // try again after a wait that ended with a note on it: the note goes into the next
3682
3949
  // drafting, or the calm line joins the one already writing it, never a second
@@ -4020,7 +4287,7 @@ function createBetDesk(deps) {
4020
4287
  id: cur ? cur.id : null,
4021
4288
  kind,
4022
4289
  bet: kind === 'bet' ? cur.bet : null,
4023
- job: kind === 'yes' || kind === 'done' ? publicJob(cur) : (kind === 'wait' ? { ...WAIT_CARD } : (kind === 'missed' ? { ...MISSED_CARD } : null)),
4290
+ job: kind === 'yes' || kind === 'done' ? publicJob(cur) : (kind === 'wait' ? { ...WAIT_CARD } : (kind === 'missed' ? { ...(cur.no_model ? NO_MODEL_CARD : MISSED_CARD) } : null)),
4024
4291
  question: kind === 'question' ? { ask: cur.question.ask, line: cur.question.line, choices: cur.question.choices } : null,
4025
4292
  report: kind === 'report' ? {
4026
4293
  what: `${cur.board.name}: what is it this week?`,
@@ -4083,7 +4350,9 @@ function createBetDesk(deps) {
4083
4350
 
4084
4351
  // Read first. A question back or an unclear message is answered on the
4085
4352
  // same card and saves nothing; an answer or an instruction is acted on.
4086
- // A failed or slow read falls back to the plain line and saves nothing.
4353
+ // A model that answered with a read of no use asks him to say it another
4354
+ // way. A read no model answered (it failed, was slow, or none is here) is
4355
+ // never put on his words: the reply says what happened, and nothing is saved.
4087
4356
  const QUESTION_HINT = 'his message ends with a question mark: it is a question back, not an answer';
4088
4357
  async function readTurn(t, text, card) {
4089
4358
  const thread = t.turns.slice(-THREAD_TURNS);
@@ -4092,16 +4361,23 @@ function createBetDesk(deps) {
4092
4361
  // more with that said, and if it still reads as an answer, fall back
4093
4362
  if (read && read.ok && read.kind === 'answer' && /\?\s*$/.test(text)) {
4094
4363
  read = await readWithin({ text, card, thread, hint: QUESTION_HINT });
4095
- if (read && read.ok && read.kind === 'answer') read = { ok: false, reason: 'a question read as an answer' };
4364
+ if (read && read.ok && read.kind === 'answer') read = { ok: false, reason: 'a question read as an answer', answered: true };
4096
4365
  }
4097
4366
  if (read && read.ok && (read.kind === 'answer' || read.kind === 'instruction')) return { act: read };
4098
- const kind = read && read.ok ? read.kind : 'unclear';
4099
- const reply = (read && read.ok && plainReply(read.reply)) || SAY_FALLBACK;
4367
+ let reply;
4368
+ let kind;
4369
+ if (read && (read.ok || read.answered)) {
4370
+ reply = (read.ok && plainReply(read.reply)) || SAY_FALLBACK;
4371
+ kind = reply === SAY_FALLBACK ? 'unclear' : read.kind;
4372
+ } else {
4373
+ reply = read && read.no_model && !read.retry ? SAY_NO_MODEL : SAY_NOT_HEARD;
4374
+ kind = 'unheard';
4375
+ }
4100
4376
  const suggestions = read && read.ok && reply !== SAY_FALLBACK
4101
4377
  ? plainSuggestions((Array.isArray(read.suggestions) ? read.suggestions : []).map(you).filter(sug => !(card.buttons || []).some(b => sameLabel(b, sug))))
4102
4378
  : [];
4103
4379
  addTurns(t, { who: 'owner', text }, { who: 'orb', text: reply });
4104
- return { stay: { ok: true, stay: true, kind: reply === SAY_FALLBACK ? 'unclear' : kind, reply, ...(suggestions.length ? { suggestions } : {}), thread: t.turns.slice() } };
4380
+ return { stay: { ok: true, stay: true, kind, reply, ...(suggestions.length ? { suggestions } : {}), thread: t.turns.slice() } };
4105
4381
  }
4106
4382
 
4107
4383
  // On a question it is the answer; on an idea, a note the writer reworks it
@@ -4117,6 +4393,8 @@ function createBetDesk(deps) {
4117
4393
  if (card !== undefined && card !== cur.id) return { ok: false, reason: 'gone', message: 'that card changed, so nothing was done. here is the one on screen now.' };
4118
4394
  // on the calm line there is nothing to answer: the words are a note for the idea being written, read by no model
4119
4395
  if (cur.kind === 'wait') return noteOnWait(cur, said);
4396
+ // words on the card that says no model answered look again first, so one signed in since then reads them
4397
+ if (onNoModel()) wakeThinkers();
4120
4398
  let kind = cur.kind || 'bet';
4121
4399
  log({ card: 'bet', choice: 'say', command: null, ran: 'in process', exit_code: 0, kind, chars: said.length });
4122
4400
  const t = threadFor(cur.id);
@@ -4541,7 +4819,7 @@ function createBetDesk(deps) {
4541
4819
  if (kind === 'report') return gmCard.report({ id: cur.id, title: v.report.what, body: v.report.why });
4542
4820
  if (kind === 'choice') return gmCard.choice({ id: cur.id, items: v.choice.options });
4543
4821
  if (kind === 'wait') return gmCard.wait({ id: cur.id, ...waitProgress(cur), thread: waitThread(cur) });
4544
- if (kind === 'missed') return gmCard.missed({ id: cur.id });
4822
+ if (kind === 'missed') return gmCard.missed({ id: cur.id, noModel: cur.no_model || null });
4545
4823
  if (kind === 'yes') {
4546
4824
  const j = cur.job;
4547
4825
  return gmCard.yes({ id: cur.id, title: v.job.what, body: v.job.why, job: jobFacts(j.owner), request: { job: j.mission_id, key: j.key, hash: j.lock_hash || null } });
package/lib/gm-card.js CHANGED
@@ -166,10 +166,20 @@ function wait({ id, progress = [], started_at = null, waited_ms = null, thread =
166
166
  return { id, kind: 'wait', asks: id, title: 'writing your next idea.', body: null, options: [], input: sayInput({ placeholder: 'add a note for this idea' }), watch: true, progress, started_at, waited_ms, thread };
167
167
  }
168
168
 
169
- // the wait ended with nothing usable: a way to try again, and the box
170
- function missed({ id }) {
169
+ // the wait ended with nothing usable: a way to try again, and the box.
170
+ // noModel: no model answered at all, so the card says what it needs
171
+ // instead: 'none' when there is no model on this computer, 'failed' when
172
+ // the one here did not answer
173
+ const NO_MODEL_TITLE = 'the orb needs a model signed in on this computer.';
174
+ const NO_MODEL_BODY = {
175
+ none: 'there is none here yet. once one is set up, tap try again.',
176
+ failed: 'the model here did not answer. check that it is signed in, then tap try again.',
177
+ };
178
+ function missed({ id, noModel = null }) {
171
179
  return {
172
- id, kind: 'missed', asks: id, title: 'no idea came together this time.', body: 'try again, or say what you want one about.',
180
+ id, kind: 'missed', asks: id,
181
+ title: noModel ? NO_MODEL_TITLE : 'no idea came together this time.',
182
+ body: noModel ? NO_MODEL_BODY[noModel] || NO_MODEL_BODY.failed : 'try again, or say what you want one about.',
173
183
  options: [answer(id, { id: 'again', label: 'try again', action: 'next', style: 'primary' })],
174
184
  input: sayInput(),
175
185
  };
package/lib/zen-apply.js CHANGED
@@ -31,6 +31,7 @@ const {
31
31
  sideCopyLines,
32
32
  scheduledCopies,
33
33
  runningCopies,
34
+ shortPlace,
34
35
  untrackedFiles,
35
36
  ABANDONED_DAYS,
36
37
  abandonedDaysFromEnv,
@@ -81,6 +82,13 @@ function identityEnv(root) {
81
82
  return env;
82
83
  }
83
84
 
85
+ // Where zen put your copy of a clashing note, said from the repo root so the
86
+ // line fits the pane, or from home when it sits outside the repo.
87
+ function savedPlace(root, file) {
88
+ const rel = path.relative(root, file);
89
+ return rel && !rel.startsWith('..') && !path.isAbsolute(rel) ? rel : shortPlace(file);
90
+ }
91
+
84
92
  function archiveStashes(root, tag) {
85
93
  const shas = (gitOut(['stash', 'list', '--format=%H'], root) || '').split('\n').filter(Boolean);
86
94
  if (!shas.length) return null;
@@ -444,7 +452,7 @@ function moveKeepingNotes(root, args, target, tag, from = 'HEAD') {
444
452
  for (const p of clashed) {
445
453
  const mine = path.join(dir, 'mine', p);
446
454
  if (isJournal(p) && joinJournal(path.join(root, p), mine)) notes.push(`joined your ${p} onto the new one`);
447
- else if (!(fromCommit && lineRefsOnly(root, fromCommit, p, mine))) notes.push(`kept the new ${p}; your copy is in ${mine}`);
455
+ else if (!(fromCommit && lineRefsOnly(root, fromCommit, p, mine))) notes.push(`kept the new ${p}; your copy is in ${savedPlace(root, mine)}`);
448
456
  }
449
457
  for (const p of missed) {
450
458
  const mine = path.join(dir, 'mine', p);
@@ -452,9 +460,9 @@ function moveKeepingNotes(root, args, target, tag, from = 'HEAD') {
452
460
  if (to && fromCommit && followMove(root, fromCommit, p, to, mine)) {
453
461
  notes.push(`${p} moved to ${to}, and your edits went with it`);
454
462
  } else if (to) {
455
- notes.push(`${p} moved to ${to} and your edits clash there; your copy is in ${fs.existsSync(mine) ? mine : patchFile}`);
463
+ notes.push(`${p} moved to ${to} and your edits clash there; your copy is in ${savedPlace(root, fs.existsSync(mine) ? mine : patchFile)}`);
456
464
  } else {
457
- notes.push(`${p} no longer fits the new changes; your copy is in ${fs.existsSync(mine) ? mine : patchFile}`);
465
+ notes.push(`${p} no longer fits the new changes; your copy is in ${savedPlace(root, fs.existsSync(mine) ? mine : patchFile)}`);
458
466
  }
459
467
  }
460
468
  }
@@ -464,7 +472,7 @@ function moveKeepingNotes(root, args, target, tag, from = 'HEAD') {
464
472
  // A copy that matches the incoming file loses nothing, so say nothing.
465
473
  if (sameBytes(saved, path.join(root, p))) fs.rmSync(saved, { force: true });
466
474
  else if (isJournal(p) && joinJournal(path.join(root, p), saved)) notes.push(`joined your ${p} onto the new one`);
467
- else notes.push(`kept the new ${p}; your copy is in ${saved}`);
475
+ else notes.push(`kept the new ${p}; your copy is in ${savedPlace(root, saved)}`);
468
476
  } else {
469
477
  fs.mkdirSync(path.dirname(path.join(root, p)), { recursive: true });
470
478
  fs.copyFileSync(saved, path.join(root, p));
package/lib/zen.js CHANGED
@@ -646,4 +646,5 @@ module.exports = {
646
646
  readTeamState,
647
647
  scheduledCopies,
648
648
  runningCopies,
649
+ shortPlace,
649
650
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.74.12",
3
+ "version": "3.74.14",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof.",
5
5
  "mcpName": "io.github.atrislabs/atris",
6
6
  "main": "bin/atris.js",