atris 3.82.15 → 3.82.17

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.
@@ -6,8 +6,20 @@ const { randomUUID } = require('node:crypto');
6
6
  const { argsWantHelp } = require('../lib/noninteractive');
7
7
  const { getAtrisDir, loadCredentials } = require('../utils/auth');
8
8
  const { stateHome } = require('../lib/state-home');
9
- const { validateSync, checkpointKey, syncLines } = require('../lib/ninety-nine-sync');
9
+ const tty = require('node:tty');
10
+ const { apiRequestJson } = require('../utils/api');
11
+ const { validateSync, checkpointKey, syncLines, syncBlocks, blockAccountKey } = require('../lib/ninety-nine-sync');
10
12
  const {
13
+ OUTCOMES,
14
+ INTENTION_MAX,
15
+ BLOCK_NOTE_MAX,
16
+ oneLine,
17
+ localDate,
18
+ withOutcome,
19
+ answerRecord,
20
+ unansweredBlocks,
21
+ blocksToday,
22
+ blocksLine,
11
23
  validLength,
12
24
  parseSessionLog,
13
25
  foldSessions,
@@ -24,16 +36,16 @@ const {
24
36
 
25
37
  function usage() {
26
38
  return [
27
- 'usage: atris 99 focus <25|50|90>',
39
+ 'usage: atris 99 focus <25|50|90> ["what you will get done"]',
28
40
  ' atris 99 meditate <10|20|30>',
29
41
  ' atris 99 show the timer and today',
30
- ' atris 99 <25|50|90> start focus',
42
+ ' atris 99 <25|50|90> [intention] start focus',
31
43
  ' atris 99 <10|20|30> start meditation',
32
44
  ' atris 99 f <25|50|90>',
33
45
  ' atris 99 m <10|20|30>',
34
46
  ' atris 99 start <focus|meditate> <minutes>',
35
47
  ' atris 99 again repeat the last finished session',
36
- ' atris 99 stop [note words]',
48
+ ' atris 99 stop [--outcome yes|partly|no] [--note text] [note words]',
37
49
  ' atris 99 pause',
38
50
  ' atris 99 resume',
39
51
  ' atris 99 status [--json]',
@@ -41,6 +53,10 @@ function usage() {
41
53
  ' atris 99 sync [--dry-run]',
42
54
  ' atris 99 help | --help',
43
55
  '',
56
+ 'a focus block that runs its full time asks "Did you get it done? (yes/partly/no)" in a terminal;',
57
+ '--outcome and --note answer it without asking. Scripts are never asked.',
58
+ 'with sync on, each finished focus block also goes to your Project 99 day, so every face shows one count.',
59
+ '',
44
60
  'sync is off without a sync block in ~/.atris/99/config.json (ATRIS_99_CONFIG overrides it).',
45
61
  'personal: {"sync":{"target":"personal","app_slug":"project-99"}}',
46
62
  'workspace: {"sync":{"target":"workspace","business":"your-business","workspace":"optional-id"}}',
@@ -67,7 +83,7 @@ function saveSyncState(file, state) {
67
83
  }
68
84
  }
69
85
 
70
- async function syncNinetyNine(args, { log, logPath, configPath, statePath, request, credentials = loadCredentials }) {
86
+ async function syncNinetyNine(args, { log, logPath, configPath, statePath, request, credentials = loadCredentials, now = () => new Date() }) {
71
87
  if (args.length > 1 || (args.length === 1 && args[0] !== '--dry-run')) {
72
88
  log('usage: atris 99 sync [--dry-run]');
73
89
  return 2;
@@ -107,6 +123,9 @@ async function syncNinetyNine(args, { log, logPath, configPath, statePath, reque
107
123
  }
108
124
  const key = checkpointKey(config.sync, creds);
109
125
  const checkpoint = state.targets[key] || {};
126
+ if (state.blocks !== undefined && (!state.blocks || typeof state.blocks !== 'object' || Array.isArray(state.blocks))) {
127
+ throw new Error('check the 99 sync-state json');
128
+ }
110
129
  if ((checkpoint.known !== undefined && (!Array.isArray(checkpoint.known) || !checkpoint.known.every((item) => typeof item === 'string')))
111
130
  || (checkpoint.since != null && !Number.isFinite(Date.parse(checkpoint.since)))) {
112
131
  throw new Error('check the 99 sync-state json');
@@ -128,7 +147,7 @@ async function syncNinetyNine(args, { log, logPath, configPath, statePath, reque
128
147
  saveSyncState(stateFile, state);
129
148
  log(`sent ${result.send.length} lines and pulled ${result.pull.length} lines (${config.sync.target})`);
130
149
  }
131
- return 0;
150
+ return await sendBlocks({ records: [...records, ...result.pull], state, stateFile, creds, dryRun, request, now, log });
132
151
  } catch (error) {
133
152
  const message = error.code === 'EEXIST' ? 'session sync is busy; try again'
134
153
  : error.code ? 'check the sync file paths and permissions' : error.message;
@@ -144,6 +163,38 @@ async function syncNinetyNine(args, { log, logPath, configPath, statePath, reque
144
163
  }
145
164
  }
146
165
 
166
+ async function sendBlocks({ records, state, stateFile, creds, dryRun, request, now, log }) {
167
+ const key = blockAccountKey(creds);
168
+ const sent = state.blocks?.[key];
169
+ let result;
170
+ try {
171
+ result = await syncBlocks({
172
+ records, credentials: creds, dryRun, request, now: now(),
173
+ sent: sent && typeof sent === 'object' && !Array.isArray(sent) ? sent : {},
174
+ });
175
+ } catch (error) {
176
+ log(`could not send focus blocks: ${String(error.message).replace(/\s+/g, ' ')}`);
177
+ return 1;
178
+ }
179
+ const count = result.send.length;
180
+ const blocks = `${count} focus block${count === 1 ? '' : 's'}`;
181
+ if (dryRun) {
182
+ if (count) log(`would send ${blocks}`);
183
+ for (const block of result.send) log(`block: ${JSON.stringify(block)}`);
184
+ return 0;
185
+ }
186
+ if (result.locked) {
187
+ log('focus blocks stay on this computer: deep work needs Project 99 Plus');
188
+ return 0;
189
+ }
190
+ state.blocks = { ...(state.blocks || {}), [key]: result.sent };
191
+ saveSyncState(stateFile, state);
192
+ if (result.refused) log(`the 99 server refused some focus blocks (${result.refused}); they will not be sent again`);
193
+ else if (count) log(`sent ${blocks}`);
194
+ if (result.line) log(`Deep work today: ${result.line}`);
195
+ return 0;
196
+ }
197
+
147
198
  function readLog(logPath) {
148
199
  try { return fs.readFileSync(logPath, 'utf8'); } catch (error) {
149
200
  if (error.code === 'ENOENT') return '';
@@ -163,6 +214,122 @@ function todayLine(result) {
163
214
  return `Today: ${result.counts.focus} focus, ${result.counts.meditate} meditate, ${result.finished_minutes} min`;
164
215
  }
165
216
 
217
+ function parseOutcome(text) {
218
+ const answer = String(text ?? '').trim().toLowerCase();
219
+ if (['y', 'yes'].includes(answer)) return 'yes';
220
+ if (['p', 'partly', 'partial'].includes(answer)) return 'partly';
221
+ if (['n', 'no'].includes(answer)) return 'no';
222
+ return answer ? undefined : null;
223
+ }
224
+
225
+ // One line from the terminal, read without a stream so the command stays synchronous.
226
+ function askTerminal(question) {
227
+ process.stdout.write(question);
228
+ const bytes = [];
229
+ const one = Buffer.alloc(1);
230
+ for (;;) {
231
+ let read;
232
+ try { read = fs.readSync(0, one, 0, 1, null); } catch (error) {
233
+ if (error.code === 'EAGAIN') {
234
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50); // stdin not ready yet
235
+ continue;
236
+ }
237
+ return '';
238
+ }
239
+ if (read === 0 || one[0] === 10) break;
240
+ bytes.push(one[0]);
241
+ }
242
+ return Buffer.from(bytes).toString('utf8');
243
+ }
244
+
245
+ // "Did you get it done?" until a clear answer, at most twice. Empty skips it: null.
246
+ function askOutcome(ask, lead) {
247
+ for (let tries = 0; tries < 2; tries++) {
248
+ const answer = parseOutcome(ask(`${lead}Did you get it done? (yes/partly/no) `));
249
+ if (answer !== undefined) return answer;
250
+ }
251
+ return null;
252
+ }
253
+
254
+ // The sessions as the command finds them: who is running, what stop would end, and
255
+ // the ends to write for timers that finished or pauses that ran out.
256
+ function observe(records, time, action) {
257
+ const sessions = foldSessions(records);
258
+ const active = runningSession(sessions, time);
259
+ const stopTarget = action === 'stop'
260
+ ? active || sessions.findLast((session) => automaticEndRecord(session, time)?.status === 'done')
261
+ : null;
262
+ const ends = sessions.filter((session) => session !== stopTarget)
263
+ .map((session) => automaticEndRecord(session, time)).filter(Boolean);
264
+ return { sessions, active, stopTarget, ends };
265
+ }
266
+
267
+ function lead(session) {
268
+ return `Focus ${session.planned_min} ended${session.intention ? ` (${session.intention})` : ''}. `;
269
+ }
270
+
271
+ // The questions this command would ask, asked now: block id -> yes, partly, no or null.
272
+ // A log that cannot be read asks nothing; the locked read reports it.
273
+ function askFirst(text, time, action, stopping, ask) {
274
+ const answers = new Map();
275
+ let records;
276
+ let seen;
277
+ try {
278
+ records = parseSessionLog(text);
279
+ seen = observe(records, time, action);
280
+ } catch {
281
+ return answers;
282
+ }
283
+ records.push(...seen.ends);
284
+ const unanswered = unansweredBlocks(records, time);
285
+ if (action === 'stop') {
286
+ if (stopping.outcome !== undefined) return answers;
287
+ const target = seen.stopTarget;
288
+ if (target) {
289
+ if (target.kind === 'focus' && endRecord(target, time).status === 'done') answers.set(target.id, askOutcome(ask, ''));
290
+ } else if (unanswered.length) {
291
+ const last = unanswered.at(-1);
292
+ answers.set(last.id, askOutcome(ask, lead(last)));
293
+ }
294
+ return answers;
295
+ }
296
+ for (const session of unanswered) answers.set(session.id, askOutcome(ask, lead(session)));
297
+ return answers;
298
+ }
299
+
300
+ async function fetchSuggestion(time, { request = apiRequestJson, credentials = loadCredentials } = {}) {
301
+ try {
302
+ const creds = credentials();
303
+ if (!creds?.token) return '';
304
+ const query = new URLSearchParams({ day: localDate(time), tz_offset_minutes: String(-time.getTimezoneOffset()) });
305
+ const response = await request(`/99/focus/suggestion?${query}`, {
306
+ method: 'GET', token: creds.token, timeoutMs: 6000, retries: 0,
307
+ });
308
+ const line = response?.ok && typeof response.data?.suggestion === 'string' ? response.data.suggestion : '';
309
+ return oneLine(line, 120);
310
+ } catch {
311
+ return '';
312
+ }
313
+ }
314
+
315
+ function stopArgs(words) {
316
+ const out = { note: [], outcome: undefined, flagNote: undefined };
317
+ for (let index = 0; index < words.length; index++) {
318
+ const word = words[index];
319
+ const [flag, inline] = word.startsWith('--') ? [word.split('=')[0], word.includes('=') ? word.slice(word.indexOf('=') + 1) : undefined] : [];
320
+ if (flag === '--outcome' || flag === '--note') {
321
+ const value = inline !== undefined ? inline : words[++index];
322
+ if (value === undefined) return { error: true };
323
+ if (flag === '--outcome') {
324
+ if (!OUTCOMES.includes(value)) return { error: true };
325
+ out.outcome = value;
326
+ } else out.flagNote = value;
327
+ } else out.note.push(word);
328
+ }
329
+ const note = out.flagNote !== undefined ? [out.flagNote, ...out.note].join(' ') : out.note.join(' ');
330
+ return { outcome: out.outcome, note: oneLine(note, BLOCK_NOTE_MAX) };
331
+ }
332
+
166
333
  function kindName(kind) {
167
334
  return kind === 'focus' ? 'Focus' : 'Meditate';
168
335
  }
@@ -172,6 +339,11 @@ function timeLeft(session, time) {
172
339
  return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`;
173
340
  }
174
341
 
342
+ function runningLine(active, time) {
343
+ return `${kindName(active.kind)} · ${timeLeft(active, time)} left${active.status === 'paused' ? ' · paused' : ''}`
344
+ + (active.intention ? ` · ${active.intention}` : '');
345
+ }
346
+
175
347
  function localEndTime(end) {
176
348
  return end.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
177
349
  }
@@ -184,6 +356,9 @@ function ninetyNineAtris(args = process.argv.slice(3), {
184
356
  statePath,
185
357
  request,
186
358
  credentials,
359
+ interactive = tty.isatty(0),
360
+ ask = askTerminal,
361
+ suggestion = (time) => fetchSuggestion(time, { request, credentials }),
187
362
  } = {}) {
188
363
  let [action, ...rest] = args;
189
364
  const overview = args.length === 0;
@@ -192,7 +367,7 @@ function ninetyNineAtris(args = process.argv.slice(3), {
192
367
  log(usage());
193
368
  return 0;
194
369
  }
195
- if (action === 'sync') return syncNinetyNine(rest, { log, logPath, configPath, statePath, request, credentials });
370
+ if (action === 'sync') return syncNinetyNine(rest, { log, logPath, configPath, statePath, request, credentials, now });
196
371
  if (action === 'start' && ['focus', 'meditate', 'f', 'm'].includes(rest[0])) [action, ...rest] = rest;
197
372
  if (action === 'f') action = 'focus';
198
373
  if (action === 'm') action = 'meditate';
@@ -204,16 +379,29 @@ function ninetyNineAtris(args = process.argv.slice(3), {
204
379
  }
205
380
  }
206
381
  const json = rest.includes('--json');
382
+ let intention = '';
383
+ let stopping;
207
384
  if (action === 'focus' || action === 'meditate') {
208
385
  const lengths = action === 'focus' ? '25 | 50 | 90' : '10 | 20 | 30';
209
386
  if (rest.length === 0) {
210
387
  log(`Pick a length: atris 99 ${action} ${lengths}`);
211
388
  return 1;
212
389
  }
213
- if (rest.length !== 1 || !validLength(action, Number(rest[0])) || String(Number(rest[0])) !== rest[0]) {
390
+ // Focus takes what the block is for after its length; meditation takes nothing more.
391
+ const words = rest.slice(1);
392
+ if ((action === 'focus' ? words.some((word) => word.startsWith('--')) : words.length)
393
+ || !validLength(action, Number(rest[0])) || String(Number(rest[0])) !== rest[0]) {
214
394
  log(`Allowed ${action} lengths: ${lengths.replaceAll(' | ', ', ')} minutes.`);
215
395
  return 1;
216
396
  }
397
+ intention = oneLine(words.join(' '), INTENTION_MAX);
398
+ rest = [rest[0]];
399
+ } else if (action === 'stop') {
400
+ stopping = stopArgs(rest);
401
+ if (stopping.error) {
402
+ log('usage: atris 99 stop [--outcome yes|partly|no] [--note text] [note words]');
403
+ return 2;
404
+ }
217
405
  } else if (action === 'status' || action === 'today') {
218
406
  if (rest.length > 1 || (rest.length === 1 && !json)) {
219
407
  log(`usage: atris 99 ${action} [--json]`);
@@ -232,12 +420,16 @@ function ninetyNineAtris(args = process.argv.slice(3), {
232
420
  let lock;
233
421
  let lockPath;
234
422
  try {
423
+ const time = now();
235
424
  const override = logPath || process.env.ATRIS_99_LOG;
236
425
  const file = override || path.join(getAtrisDir(), '99', 'sessions.jsonl');
237
426
  const dir = path.dirname(file);
238
427
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
239
428
  // An override may live in a shared folder. Only tighten our own folder.
240
429
  if (!override) fs.chmodSync(dir, 0o700);
430
+ // Ask a person at a terminal before taking the lock, so an open question never
431
+ // makes another command or the Mac app wait. Scripts and json readers are never asked.
432
+ const answers = interactive && !json ? askFirst(readLog(file), time, action, stopping, ask) : new Map();
241
433
  lockPath = `${file}.lock`;
242
434
  try { lock = fs.openSync(lockPath, 'wx', 0o600); } catch (error) {
243
435
  if (error.code === 'EEXIST') throw new Error('session log is busy; try again');
@@ -245,27 +437,41 @@ function ninetyNineAtris(args = process.argv.slice(3), {
245
437
  }
246
438
  let text = readLog(file);
247
439
  const records = parseSessionLog(text);
248
- let sessions = foldSessions(records);
249
- const time = now();
250
- let active = runningSession(sessions, time);
251
- const stopTarget = action === 'stop'
252
- ? active || sessions.findLast((session) => automaticEndRecord(session, time)?.status === 'done')
253
- : null;
440
+ const seen = observe(records, time, action);
441
+ let sessions = seen.sessions;
442
+ let active = seen.active;
443
+ const stopTarget = seen.stopTarget;
444
+ for (const end of seen.ends) {
445
+ text = appendRecord(file, text, end);
446
+ records.push(end);
447
+ }
254
448
 
255
- for (const session of sessions) {
256
- const end = automaticEndRecord(session, time);
257
- if (end && session !== stopTarget) {
258
- text = appendRecord(file, text, end);
259
- records.push(end);
449
+ const answer = (session, outcome) => {
450
+ const record = answerRecord(session, time, outcome);
451
+ text = appendRecord(file, text, record);
452
+ records.push(record);
453
+ };
454
+ if (action !== 'stop') {
455
+ for (const session of unansweredBlocks(records, time)) {
456
+ if (answers.has(session.id)) answer(session, answers.get(session.id));
260
457
  }
261
458
  }
262
459
 
263
460
  if (action === 'stop') {
264
461
  if (!stopTarget) {
462
+ // Nothing running: --outcome, or the question, answers the last block that ended on its own.
463
+ const last = unansweredBlocks(records, time).at(-1);
464
+ const outcome = last && (stopping.outcome ?? answers.get(last.id));
465
+ if (last && outcome !== undefined) {
466
+ answer(last, outcome);
467
+ log(`Focus ${last.planned_min}: ${outcome || 'skipped'}.`);
468
+ return 0;
469
+ }
265
470
  log('Nothing running. Start one: atris 99 50');
266
471
  return 1;
267
472
  }
268
- const end = endRecord(stopTarget, time, rest.join(' '));
473
+ const end = endRecord(stopTarget, time, stopping.note);
474
+ withOutcome(end, stopTarget, stopping.outcome ?? answers.get(stopTarget.id));
269
475
  appendRecord(file, text, end);
270
476
  records.push(end);
271
477
  log(end.status === 'done'
@@ -289,7 +495,7 @@ function ninetyNineAtris(args = process.argv.slice(3), {
289
495
  }
290
496
  if (overview) {
291
497
  if (!active) log('Nothing running.');
292
- else log(`${kindName(active.kind)} · ${timeLeft(active, time)} left${active.status === 'paused' ? ' · paused' : ''}`);
498
+ else log(runningLine(active, time));
293
499
  log(todayLine(sessionsToday(sessions, time)));
294
500
  log('Start: atris 99 50 Again: atris 99 again Help: atris 99 help');
295
501
  } else if (action === 'focus' || action === 'meditate') {
@@ -297,9 +503,20 @@ function ninetyNineAtris(args = process.argv.slice(3), {
297
503
  log('a session is already running; use atris 99 stop first');
298
504
  return 1;
299
505
  }
300
- appendRecord(file, text, startRecord(action, Number(rest[0]), time, randomUUID()));
506
+ appendRecord(file, text, startRecord(action, Number(rest[0]), time, randomUUID(), intention));
301
507
  const end = new Date(time.getTime() + Number(rest[0]) * 60000);
302
508
  log(`${kindName(action)} ${rest[0]} started. Ends at ${localEndTime(end)}.`);
509
+ if (action === 'focus' && !intention && suggestion) {
510
+ // The timer has started; the hint never delays it and a failure prints nothing.
511
+ const hint = suggestion(time);
512
+ const show = (line) => {
513
+ const text = oneLine(line, 120);
514
+ if (text) log(`Suggestion: ${text}`);
515
+ return 0;
516
+ };
517
+ if (hint && typeof hint.then === 'function') return hint.then(show, () => 0);
518
+ show(hint);
519
+ }
303
520
  } else if (action === 'pause' || action === 'resume') {
304
521
  if (!active) {
305
522
  log('Nothing running. Start one: atris 99 50');
@@ -324,13 +541,19 @@ function ninetyNineAtris(args = process.argv.slice(3), {
324
541
  const result = sessionStatus(active, time);
325
542
  if (json) log(JSON.stringify(result));
326
543
  else if (!active) log('nothing running');
327
- else log(`${kindName(active.kind)} · ${timeLeft(active, time)} left${active.status === 'paused' ? ' · paused' : ''}`);
544
+ else log(runningLine(active, time));
328
545
  } else {
329
546
  const result = sessionsToday(sessions, time);
330
547
  const health = healthToday(records, time);
331
548
  if (health) result.health_day = health;
549
+ const blocks = blocksToday(sessions, time);
550
+ result.deep_work = { block_count: blocks.count, block_minutes: blocks.minutes };
332
551
  if (json) log(JSON.stringify(result));
333
- else log(todayLine(result));
552
+ else {
553
+ log(todayLine(result));
554
+ const line = blocksLine(blocks);
555
+ if (line) log(`Deep work: ${line}`);
556
+ }
334
557
  }
335
558
  return 0;
336
559
  } catch (error) {
package/commands/usage.js CHANGED
@@ -41,6 +41,8 @@ function renderUsage(data = {}) {
41
41
  return lines.join('\n');
42
42
  }
43
43
 
44
+ const USAGE_NEEDS_KEY = 'usage is counted per developer key, not per sign-in. make one with atris api-key create, then run ATRIS_API_KEY=<key> atris usage';
45
+
44
46
  async function usageCommand(args = [], deps = {}) {
45
47
  if (wantsHelp(args)) {
46
48
  showUsageHelp();
@@ -51,14 +53,21 @@ async function usageCommand(args = [], deps = {}) {
51
53
  const io = { log: deps.log || console.log, err: deps.err || console.error };
52
54
  const load = deps.loadCredentials || loadCredentials;
53
55
  const request = deps.apiRequestJson || apiRequestJson;
56
+ const devKeyEarly = String(((deps.env || process.env).ATRIS_API_KEY) || '').trim();
54
57
  const token = tokenFrom(load());
55
- if (!token) {
58
+ if (!token && !devKeyEarly) {
56
59
  printResult({ json, ok: false, error: NOT_LOGGED_IN, payload: { ok: false, error: NOT_LOGGED_IN } }, io);
57
60
  return 1;
58
61
  }
59
62
 
60
63
  try {
61
- const result = await request('/developer/usage', { method: 'GET', token });
64
+ // The usage endpoint reads only X-API-Key: it reports one developer key's
65
+ // usage. Send a developer key when one is set; a sign-in token is refused.
66
+ const env = deps.env || process.env;
67
+ const devKey = String(env.ATRIS_API_KEY || '').trim();
68
+ const result = devKey
69
+ ? await request('/developer/usage', { method: 'GET', headers: { 'X-API-Key': devKey } })
70
+ : await request('/developer/usage', { method: 'GET', token });
62
71
  if (isNotFound(result)) {
63
72
  printResult({
64
73
  json,
@@ -69,7 +78,8 @@ async function usageCommand(args = [], deps = {}) {
69
78
  return 1;
70
79
  }
71
80
  if (!result || !result.ok) {
72
- const error = oneLine(result && result.error);
81
+ let error = oneLine(result && result.error);
82
+ if (!devKey && [401, 403, 422].includes(result && result.status)) error = `${error}. ${USAGE_NEEDS_KEY}`;
73
83
  printResult({ json, ok: false, error, payload: { ok: false, error, status: result && result.status } }, io);
74
84
  return 1;
75
85
  }
@@ -0,0 +1,95 @@
1
+ // When the owner's engine gives a campaign nothing (no judge draft, no
2
+ // missions), say which engine was asked and why it likely failed, then one
3
+ // next step. Used by campaign new (lib/campaign-new.js) and propose and step
4
+ // (lib/campaign-loop.js). It never invents work; it only ends the stall in
5
+ // something a person can do.
6
+
7
+ 'use strict';
8
+
9
+ const NOT_INSTALLED = /not installed|command not found|\benoent\b/i;
10
+ const SIGNED_OUT = /not (?:logged|signed) in|please log ?in|log ?in required|login required|not authenticated|unauthori[sz]ed|auth(?:entication)? (?:expired|required)|\b401\b/i;
11
+ const USAGE_LIMIT = /usage[ _-]?limit|rate[ _-]?limit|insufficient credits|purchase more credits|quota|payment required/i;
12
+
13
+ // The ask runner's answer, cut to what the stall line needs. A clean answer
14
+ // stays { text, engine, ok }; the runner's reason, exit and stderr ride along
15
+ // only when it failed and said something.
16
+ function askSummary(answer, engine, extra = {}) {
17
+ const a = answer || {};
18
+ const summary = { text: a.stdout || '', engine, ok: Boolean(a.ok) };
19
+ if (!summary.ok) {
20
+ if (a.reason) summary.reason = a.reason;
21
+ if (Number.isInteger(a.exit_code)) summary.exit_code = a.exit_code;
22
+ if (a.stderr) summary.stderr = String(a.stderr).slice(-2000);
23
+ if (a.timed_out) summary.timed_out = true;
24
+ }
25
+ for (const [key, value] of Object.entries(extra)) if (value) summary[key] = value;
26
+ return summary;
27
+ }
28
+
29
+ // What went wrong, from what the runner saw. Fakes and older callers pass
30
+ // only { text, engine }; those read as "ran but sent back nothing".
31
+ function stallCause(answer = {}) {
32
+ const engine = answer.engine || 'unknown';
33
+ const said = `${answer.reason || ''}\n${answer.stderr || ''}`;
34
+ const text = String(answer.text || '').trim();
35
+ if (answer.reason === 'spawn_error' || (!answer.ok && NOT_INSTALLED.test(said))) {
36
+ // With no ready engine the ask falls back to one; say so when that one is missing too.
37
+ return { kind: answer.no_engine ? 'no_engine' : 'not_installed', engine };
38
+ }
39
+ if (!answer.ok && SIGNED_OUT.test(said)) return { kind: 'signed_out', engine };
40
+ if (!answer.ok && USAGE_LIMIT.test(said)) return { kind: 'usage_limit', engine };
41
+ if (answer.reason === 'timeout' || answer.timed_out) return { kind: 'timed_out', engine };
42
+ if (/^exit_/.test(answer.reason || '') && answer.exit_code !== 0) return { kind: 'exited', engine, exit: answer.exit_code };
43
+ if (!text) return { kind: 'empty', engine };
44
+ return { kind: 'unusable', engine };
45
+ }
46
+
47
+ // The engine's own sign-in command, from the login table engine login uses.
48
+ function signInStep(engine) {
49
+ try {
50
+ const { ENGINE_LOGIN_MANIFESTS } = require('../commands/engine');
51
+ const hint = ENGINE_LOGIN_MANIFESTS[engine] && ENGINE_LOGIN_MANIFESTS[engine].missingHint;
52
+ if (hint) return hint.replace(/\s+first$/, '');
53
+ } catch {}
54
+ return `sign in to ${engine}`;
55
+ }
56
+
57
+ // The engine is the problem when it never ran or could not answer; then the
58
+ // next step is to fix it. Otherwise it ran and the next step is by hand.
59
+ const ENGINE_KINDS = new Set(['no_engine', 'not_installed', 'signed_out', 'exited']);
60
+
61
+ // owner: the member asked. wanted: what it was asked for ("a judge script",
62
+ // "missions"). byHand: the exact command to do it without the engine.
63
+ // again: what to run once the engine works.
64
+ function explainStall({ owner, answer, wanted, byHand, again }) {
65
+ const cause = stallCause(answer);
66
+ const e = cause.engine;
67
+ const why = {
68
+ no_engine: `no engine on this computer is ready to do ${owner}'s work and the fallback ${e} is not installed`,
69
+ not_installed: `${owner}'s engine is ${e} and ${e} is not installed on this computer`,
70
+ signed_out: `${owner}'s engine is ${e} and ${e} is not signed in`,
71
+ usage_limit: `${owner}'s engine is ${e} and ${e} hit its usage limit`,
72
+ timed_out: `${owner}'s engine is ${e} and ${e} ran out of time before it answered`,
73
+ exited: `${owner}'s engine is ${e} and ${e} stopped with exit ${cause.exit} before it answered`,
74
+ empty: `${owner}'s engine is ${e} and ${e} ran but sent back nothing`,
75
+ unusable: `${owner}'s engine is ${e} and ${e} answered without ${wanted} atris could use`,
76
+ }[cause.kind];
77
+ const fix = {
78
+ no_engine: 'install codex or claude code',
79
+ not_installed: `install ${e}`,
80
+ signed_out: signInStep(e),
81
+ exited: `run atris engine test ${e} to see why it stopped`,
82
+ }[cause.kind];
83
+ const next = ENGINE_KINDS.has(cause.kind) && again
84
+ ? `${fix}, then run ${again} again`
85
+ : byHand;
86
+ return { kind: cause.kind, engine: e, why, next, line: `${why}.\nnext: ${next}`, inline: `${why}. next: ${next}` };
87
+ }
88
+
89
+ // True when asking again cannot help: the engine never ran.
90
+ function engineCannotRun(answer) {
91
+ const kind = stallCause(answer).kind;
92
+ return kind === 'no_engine' || kind === 'not_installed' || kind === 'signed_out';
93
+ }
94
+
95
+ module.exports = { askSummary, engineCannotRun, explainStall };
@@ -15,6 +15,7 @@ const { getCampaign, openCampaignStore } = require('./campaign-store');
15
15
  const { defaultReserve, missionCost, reconcileCampaign } = require('./campaign-missions');
16
16
  const { confirmedReading, judgeRanData, latestJudgeOutput } = require('./campaign-reading');
17
17
  const { LANE_ASK_KIND, pausedLane, laneAskOpen, laneQuestion, openLaneAsks } = require('./campaign-lanes');
18
+ const { explainStall } = require('./campaign-engine-stall');
18
19
 
19
20
  const TERMINAL = new Set(['stopped', 'complete']);
20
21
 
@@ -182,10 +183,11 @@ function parseProposals(text, view, count, rsiSummary = null, events = null) {
182
183
  async function askOwner(root, view, prompt) {
183
184
  const { resolveEngineForMember } = require('./member-engine');
184
185
  const { runEngineAskJobs } = require('./engine-ask');
186
+ const { askSummary } = require('./campaign-engine-stall');
185
187
  const pick = resolveEngineForMember(view.owner, root) || {};
186
188
  const engine = (pick.engine && pick.engine.id) || 'claude';
187
189
  const [answer] = await runEngineAskJobs([{ engine, model: pick.model || '', prompt, label: `campaign ${view.id} plan` }], { root, timeoutMs: PROPOSE_TIMEOUT_MS });
188
- return { text: (answer && answer.stdout) || '', engine, ok: Boolean(answer && answer.ok) };
190
+ return askSummary(answer, engine, { no_engine: !(pick.engine && pick.engine.id) });
189
191
  }
190
192
 
191
193
  // Run one atris command and resolve with its exit code and output.
@@ -241,6 +243,9 @@ function makeLoop(root, options = {}, deps = {}) {
241
243
  const view = (id) => getCampaign(store, id);
242
244
  const append = (id, type, data, as = actor) => store.append({ campaign_id: id, type, actor: as, data });
243
245
 
246
+ // Why the last propose came back empty plus the one next step.
247
+ let lastStall = null;
248
+
244
249
  async function propose(id, count = parallel) {
245
250
  const events = store.read(id);
246
251
  const current = events.length ? fold(events) : null;
@@ -259,6 +264,13 @@ function makeLoop(root, options = {}, deps = {}) {
259
264
  const prompt = proposalPrompt(current, count, latestJudgeOutput(events), deps.lessons ? deps.lessons(id) : '', rsiSummary, events, brief);
260
265
  const answer = await ask(root, current, prompt);
261
266
  const proposals = parseProposals(answer.text, current, count, rsiSummary, events);
267
+ lastStall = proposals.length ? null : explainStall({
268
+ owner: current.owner,
269
+ answer,
270
+ wanted: 'missions',
271
+ byHand: `add one yourself: atris mission start "<what to do next>" --campaign ${id} --owner ${current.owner}`,
272
+ again: `atris campaign step ${id}`,
273
+ });
262
274
  // Planning costs money too. It rides on the first mission it produced as a
263
275
  // step, so it is counted when that mission ends without making the judge
264
276
  // due. A plan that produced nothing is not failed work: the step saves a
@@ -421,7 +433,8 @@ function makeLoop(root, options = {}, deps = {}) {
421
433
  const data = await readJudge(id, current, () => WAIT_FIRST_MS);
422
434
  if (data.passed) return { stop: false, did: 'judge' };
423
435
  const next = data.wait ? `; next check at ${clockTime(data.wait.wake_at)}` : '';
424
- return { ok: false, stop: true, reason: `${current.owner} proposed nothing usable${next}` };
436
+ const why = lastStall ? `. ${lastStall.inline}` : '';
437
+ return { ok: false, stop: true, reason: `${current.owner} proposed nothing usable${next}${why}`, ...(lastStall ? { engine: lastStall.engine, cause: lastStall.kind, next_step: lastStall.next } : {}) };
425
438
  }
426
439
  for (const p of proposals) log(` proposed for ${p.member}: ${p.objective}`);
427
440
  return { stop: false, did: 'propose' };
@@ -445,7 +458,7 @@ function makeLoop(root, options = {}, deps = {}) {
445
458
  return { steps: maxSteps, reason: `stopped after ${maxSteps} steps`, campaign: view(id) };
446
459
  }
447
460
 
448
- return { step, tick, run, propose };
461
+ return { step, tick, run, propose, lastStall: () => lastStall };
449
462
  }
450
463
 
451
464
  module.exports = { ACTIVITY_LINE, askOwner, briefPath, makeLoop, parseProposals, proposalPrompt, rankCampaignMembers, readBrief, readingKey, runCli };