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.
- package/README.md +1 -1
- package/bin/atris.js +10 -6
- package/commands/agent-client.js +51 -5
- package/commands/auth.js +149 -35
- package/commands/campaign.js +3 -1
- package/commands/ninety-nine.js +247 -24
- package/commands/usage.js +13 -3
- package/lib/campaign-engine-stall.js +95 -0
- package/lib/campaign-loop.js +16 -3
- package/lib/campaign-new.js +15 -4
- package/lib/design-api.js +15 -3
- package/lib/ninety-nine-sync.js +72 -2
- package/lib/ninety-nine.js +148 -4
- package/package.json +1 -1
package/commands/ninety-nine.js
CHANGED
|
@@ -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
|
|
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>
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
let active =
|
|
251
|
-
const stopTarget =
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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,
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|
package/lib/campaign-loop.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 };
|