atris 3.82.16 → 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 CHANGED
@@ -269,7 +269,7 @@ The owner makes the client once. The secret is shown one time, or written to a
269
269
  private file with `--secret-file`:
270
270
 
271
271
  ```bash
272
- atris agent-client create --name nightly-bot --scopes mcp:read,x-search --secret-file ./bot.secret
272
+ atris agent-client create --name nightly-bot --scopes mcp:workspace:read,x-search --secret-file ./bot.secret
273
273
  atris agent-client list
274
274
  atris agent-client rotate <client_id>
275
275
  atris agent-client revoke <client_id>
@@ -10,7 +10,7 @@
10
10
  const fs = require('fs');
11
11
  const path = require('path');
12
12
  const { apiRequestJson } = require('../utils/api');
13
- const { ensureValidCredentials, readActiveCredentials } = require('../utils/auth');
13
+ const { ensureValidCredentials, readActiveCredentials, listProfiles, loadProfile, getSessionProfile } = require('../utils/auth');
14
14
  const agentClient = require('../utils/agent-client');
15
15
  const { readFlag } = require('../lib/arg-parser');
16
16
  const { wantsJson, wantsHelp, oneLine } = require('../lib/developer-api');
@@ -19,7 +19,52 @@ const BASE_PATH = '/auth/agent-clients';
19
19
  const ACTIONS = new Set(['create', 'list', 'revoke', 'rotate']);
20
20
  const VALUE_FLAGS = ['--name', '--scopes', '--daily-credit-cap', '--ttl', '--secret-file'];
21
21
  const SHOWN_ONCE = 'store this secret now, it will not be shown again:';
22
- const AGENT_CLIENT_CANNOT_MANAGE = 'agent clients are managed with your own login, not an agent client. run: atris switch <you> or atris login --force';
22
+ // Scopes an agent client can carry today. The server holds the real list and
23
+ // rejects anything else; this is what help and the unknown-scope hint show.
24
+ const COMMON_SCOPES = [
25
+ 'x-search', 'youtube', 'gmail-read', 'calendar-read',
26
+ 'files:read', 'files:write', 'scoreboard:read', 'ai:complete',
27
+ 'mcp:workspace:read', 'mcp:publish',
28
+ ];
29
+
30
+ // The person's own saved logins, so the error can name a real switch command.
31
+ function humanProfileNames(deps = {}) {
32
+ const list = deps.listProfiles || listProfiles;
33
+ const load = deps.loadProfile || loadProfile;
34
+ try {
35
+ return list().filter((name) => {
36
+ const profile = load(name);
37
+ return profile && profile.token && !agentClient.isAgentClientCredential(profile);
38
+ });
39
+ } catch {
40
+ return [];
41
+ }
42
+ }
43
+
44
+ // The switch command that actually reaches the login in use. ATRIS_PROFILE
45
+ // beats everything, then this terminal's own pick, then the global login, so
46
+ // a --global switch would not help a terminal that picked its own account.
47
+ function switchCommandFor(name, deps = {}) {
48
+ const env = deps.env || process.env;
49
+ if (String(env.ATRIS_PROFILE || '').trim()) return `export ATRIS_PROFILE=${name}`;
50
+ let session = null;
51
+ try { session = (deps.getSessionProfile || getSessionProfile)(); } catch { session = null; }
52
+ return session ? `atris switch ${name}` : `atris switch ${name} --global`;
53
+ }
54
+
55
+ function cannotManageMessage(deps = {}) {
56
+ const names = humanProfileNames(deps);
57
+ const lead = 'agent clients are managed with your own login, not an agent client.';
58
+ if (names.length === 1) return `${lead} switch back: ${switchCommandFor(names[0], deps)}`;
59
+ if (names.length > 1) return `${lead} switch back with one of: ${names.map((n) => switchCommandFor(n, deps)).join(' | ')}`;
60
+ return `${lead} sign in as yourself: atris login --force`;
61
+ }
62
+
63
+ function withScopeHint(message) {
64
+ const text = String(message || '');
65
+ if (!/unknown scope/i.test(text)) return text;
66
+ return `${text}. scopes you can use: ${COMMON_SCOPES.join(', ')}`;
67
+ }
23
68
 
24
69
  function showAgentClientHelp(log = console.log) {
25
70
  log('usage: atris agent-client create --name <name> --scopes <a,b> [--daily-credit-cap N] [--ttl SECONDS] [--secret-file <path>]');
@@ -30,6 +75,7 @@ function showAgentClientHelp(log = console.log) {
30
75
  log('an agent client lets an agent sign itself in, with no person in the loop.');
31
76
  log('create and rotate show the secret once. --secret-file writes it to a private file instead.');
32
77
  log('the agent then runs: atris login --client-id <atc_...> --client-secret-file <path>');
78
+ log(`scopes you can use: ${COMMON_SCOPES.join(', ')}`);
33
79
  log('needs your own login. add --json for machine output.');
34
80
  }
35
81
 
@@ -170,7 +216,7 @@ async function agentClientCommand(args = [], deps = {}) {
170
216
 
171
217
  const readActive = deps.readActiveCredentials || readActiveCredentials;
172
218
  if (agentClient.isAgentClientCredential(readActive())) {
173
- return fail(AGENT_CLIENT_CANNOT_MANAGE);
219
+ return fail(cannotManageMessage(deps));
174
220
  }
175
221
 
176
222
  const request = deps.apiRequestJson || apiRequestJson;
@@ -180,7 +226,7 @@ async function agentClientCommand(args = [], deps = {}) {
180
226
  return fail('not logged in. run: atris login');
181
227
  }
182
228
  if (ensured.source === agentClient.AUTH_TYPE || agentClient.isAgentClientCredential(ensured.credentials)) {
183
- return fail(AGENT_CLIENT_CANNOT_MANAGE);
229
+ return fail(cannotManageMessage(deps));
184
230
  }
185
231
  const token = ensured.credentials.token;
186
232
 
@@ -211,7 +257,7 @@ async function agentClientCommand(args = [], deps = {}) {
211
257
  const secret = result && result.data && result.data.client_secret;
212
258
  const message = status === 404 && parsed.action !== 'revoke' && parsed.action !== 'rotate'
213
259
  ? 'agent clients are not live on the server yet. try again after the backend update.'
214
- : agentClient.scrub(oneLine(result && (result.data || result.error)), [token, secret]);
260
+ : withScopeHint(agentClient.scrub(oneLine(result && (result.data || result.error)), [token, secret]));
215
261
  return fail(message, { status: status || 0 });
216
262
  }
217
263
 
@@ -821,7 +821,9 @@ async function driveLoop(args, ctx, verb, view) {
821
821
  if (verb === 'propose') {
822
822
  const proposals = await loop.propose(view.id);
823
823
  const after = getCampaign(ctx.store, view.id);
824
- ctx.out(proposals.length ? `${proposals.length} proposed and claimed\n${renderShow(after)}` : 'nothing usable was proposed', { ok: proposals.length > 0, proposals, campaign: after, next: decide(after) });
824
+ const stall = proposals.length ? null : loop.lastStall();
825
+ const empty = stall ? `nothing usable was proposed: ${stall.line}` : 'nothing usable was proposed';
826
+ ctx.out(proposals.length ? `${proposals.length} proposed and claimed\n${renderShow(after)}` : empty, { ok: proposals.length > 0, proposals, campaign: after, next: decide(after), ...(stall ? { engine: stall.engine, cause: stall.kind, next_step: stall.next } : {}) });
825
827
  return proposals.length ? 0 : 1;
826
828
  }
827
829
  if (verb === 'step') {
@@ -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 };
@@ -9,6 +9,7 @@
9
9
  const fs = require('fs');
10
10
  const path = require('path');
11
11
  const { newCampaignId } = require('./campaign-store');
12
+ const { engineCannotRun, explainStall } = require('./campaign-engine-stall');
12
13
 
13
14
  const DEFAULT_BUDGET = 20;
14
15
  const DEFAULT_OWNER = 'mission-lead';
@@ -100,10 +101,11 @@ function parseDraft(text, members) {
100
101
  async function askOwner(root, view, prompt) {
101
102
  const { resolveEngineForMember } = require('./member-engine');
102
103
  const { runEngineAskJobs } = require('./engine-ask');
104
+ const { askSummary } = require('./campaign-engine-stall');
103
105
  const pick = resolveEngineForMember(view.owner, root) || {};
104
106
  const engine = (pick.engine && pick.engine.id) || 'claude';
105
107
  const [answer] = await runEngineAskJobs([{ engine, model: pick.model || '', prompt, label: 'campaign new judge' }], { root, timeoutMs: ASK_TIMEOUT_MS });
106
- return { text: (answer && answer.stdout) || '', engine, ok: Boolean(answer && answer.ok) };
108
+ return askSummary(answer, engine, { no_engine: !(pick.engine && pick.engine.id) });
107
109
  }
108
110
 
109
111
  function writeJudge(root, id, script) {
@@ -135,7 +137,9 @@ async function draftJudge({ root, goal, owner, idFlag, timeout, ask, judge }) {
135
137
  const draft = parseDraft(answer.text, members);
136
138
  if (!draft) {
137
139
  previous = 'the reply was not one JSON object with a judge_script';
138
- last = { error: `${owner}'s engine (${answer.engine || 'unknown'}) did not return a judge script` };
140
+ last = { error: `${owner}'s engine (${answer.engine || 'unknown'}) did not return a judge script`, answer };
141
+ // Asking again cannot help an engine that never ran.
142
+ if (engineCannotRun(answer)) break;
139
143
  continue;
140
144
  }
141
145
  if (!draft.title) draft.title = goal.slice(0, 80);
@@ -218,11 +222,18 @@ async function draftAndStart(args, ctx, deps = {}) {
218
222
  judge: ctx.judge,
219
223
  });
220
224
  if (!result.ok) {
225
+ const byHand = `write the judge yourself: atris campaign start ${shellQuote(goal.slice(0, 80))} --judge "<command that fails today>" --budget ${budget}`;
226
+ // No draft came back: name the engine and why, then the one next step.
227
+ const stall = result.answer
228
+ ? explainStall({ owner, answer: result.answer, wanted: 'a judge script', byHand, again: 'atris campaign new' })
229
+ : null;
230
+ const error = stall ? stall.why : result.error;
231
+ const next = stall ? stall.next : byHand;
221
232
  if (ctx.asJson) {
222
- console.log(JSON.stringify({ ok: false, error: result.error, draft: result.draft || null, reading: result.reading || null }));
233
+ console.log(JSON.stringify({ ok: false, error, next, engine: stall ? stall.engine : result.engine || null, cause: stall ? stall.kind : null, draft: result.draft || null, reading: result.reading || null }));
223
234
  return 1;
224
235
  }
225
- return ctx.fail(`${result.error}. Write the judge yourself: atris campaign start "<title>" --judge "<command that fails today>" --budget ${budget}`, 1);
236
+ return ctx.fail(`${error}.\nnext: ${next}`, 1);
226
237
  }
227
238
 
228
239
  const draft = { id: result.id, title: result.draft.title, judge: result.command, judge_file: result.judgeFile, why: result.draft.why, seats: result.draft.seats, owner, budget, engine: result.engine };
package/lib/design-api.js CHANGED
@@ -34,14 +34,25 @@ function readKeyFile(file = DESIGN_KEY_FILE) {
34
34
  }
35
35
 
36
36
  // Returns the bearer token for design calls, or null when nothing is set up.
37
+ // A developer key (env, then the saved key file) comes before the login token:
38
+ // the design server only accepts developer keys, so a saved key must never be
39
+ // shadowed by being signed in.
37
40
  function resolveDesignKey(env = process.env, deps = {}) {
38
41
  const fromEnv = env.ATRIS_API_KEY && env.ATRIS_API_KEY.trim();
39
42
  if (fromEnv) return fromEnv;
43
+ const fromFile = (deps.readKeyFile || readKeyFile)();
44
+ if (fromFile) return fromFile;
40
45
  const load = deps.loadCredentials || loadCredentials;
41
46
  const creds = load();
42
47
  const token = creds && typeof creds.token === 'string' ? creds.token.trim() : '';
43
- if (token) return token;
44
- return (deps.readKeyFile || readKeyFile)();
48
+ return token || null;
49
+ }
50
+
51
+ // A design call that was refused for a sign-in token gets a next step.
52
+ const DESIGN_NEEDS_KEY = 'design tools take a developer key, not your sign-in. make one with atris api-key create, then set ATRIS_API_KEY or save it to ~/.atris/design-api-key';
53
+
54
+ function isDeveloperKey(key) {
55
+ return /^atris_/.test(String(key || '').trim());
45
56
  }
46
57
 
47
58
  // One JSON call against the design API. Returns { ok, status, data, error }.
@@ -68,9 +79,10 @@ async function designRequest(pathname, options = {}) {
68
79
  let data = null;
69
80
  try { data = text ? JSON.parse(text) : null; } catch { data = null; }
70
81
  const ok = res.status >= 200 && res.status < 300;
71
- const error = !ok
82
+ let error = !ok
72
83
  ? (data && typeof data === 'object' && (data.detail || data.error || data.message)) || text.slice(0, 200) || `http ${res.status}`
73
84
  : undefined;
85
+ if (res.status === 401 && !isDeveloperKey(key)) error = `${typeof error === 'string' ? error : 'unauthorized'}. ${DESIGN_NEEDS_KEY}`;
74
86
  return { ok, status: res.status, data, error };
75
87
  }
76
88
 
@@ -3,7 +3,7 @@
3
3
  const { createHash } = require('node:crypto');
4
4
  const { apiRequestJson, getApiBaseUrl } = require('../utils/api');
5
5
  const { decodeJwtClaims } = require('../utils/auth');
6
- const { parseSessionLog, lineIdentity, uniqueLines } = require('./ninety-nine');
6
+ const { parseSessionLog, lineIdentity, uniqueLines, localBlocks, localDate, blocksLine, openDays } = require('./ninety-nine');
7
7
 
8
8
  const WORKSPACE_DIRECTORY = 'atris/99';
9
9
 
@@ -153,4 +153,74 @@ async function syncLines({ records, sync, checkpoint = {}, deviceId, credentials
153
153
  };
154
154
  }
155
155
 
156
- module.exports = { validateSync, checkpointKey, syncLines };
156
+ // The account the day endpoint saves to, whatever the session sync target is.
157
+ function blockAccountKey(credentials) {
158
+ const account = credentials.user_id || decodeJwtClaims(credentials.token)?.sub || credentials.email
159
+ || createHash('sha256').update(credentials.token).digest('hex');
160
+ return createHash('sha256').update(JSON.stringify([getApiBaseUrl(), account, 'blocks'])).digest('hex');
161
+ }
162
+
163
+ function blockPrint(block) {
164
+ return createHash('sha256').update(JSON.stringify(block)).digest('hex').slice(0, 16);
165
+ }
166
+
167
+ const BLOCKS_PER_SAVE = 24;
168
+ const SENT_KEEP_DAYS = 7;
169
+
170
+ // Each finished focus block made here goes once to PUT /api/99/day/{day} under
171
+ // habits.deep_work.blocks, with done, as the phone and web send it. `sent` maps
172
+ // block id to "day|print" of the copy the server took. A save the server refuses
173
+ // for good (400, or 429 for a full day) settles its blocks so one bad block never holds the rest.
174
+ async function syncBlocks({ records, sent = {}, credentials, now = new Date(), dryRun = false, request = apiRequestJson }) {
175
+ const open = new Set(openDays(now));
176
+ const pending = localBlocks(records).filter(({ day, block }) => open.has(day)
177
+ && sent[block.id] !== `${day}|${blockPrint(block)}`);
178
+ const next = { ...sent };
179
+ const result = { send: pending.map((item) => item.block), sent: next, line: null, refused: null, locked: false };
180
+ if (dryRun || !pending.length) return result;
181
+ const byDay = new Map();
182
+ for (const item of pending) {
183
+ if (!byDay.has(item.day)) byDay.set(item.day, []);
184
+ byDay.get(item.day).push(item);
185
+ }
186
+ for (const [day, items] of byDay) {
187
+ for (let offset = 0; offset < items.length; offset += BLOCKS_PER_SAVE) {
188
+ const batch = items.slice(offset, offset + BLOCKS_PER_SAVE);
189
+ const query = new URLSearchParams({ tz_offset_minutes: String(batch[0].tz_offset_minutes) });
190
+ let response;
191
+ try {
192
+ response = await request(`/99/day/${day}?${query}`, {
193
+ method: 'PUT', token: credentials.token,
194
+ body: { habits: { deep_work: { done: true, blocks: batch.map((item) => item.block) } } },
195
+ });
196
+ } catch {
197
+ throw new Error('could not reach the 99 server; try again');
198
+ }
199
+ if (response?.status === 402) {
200
+ result.locked = true;
201
+ return result;
202
+ }
203
+ if ([401, 403].includes(response?.status)) throw new Error('sync needs your signed-in user login; run atris login');
204
+ if ([400, 429].includes(response?.status)) {
205
+ result.refused = String(response?.data?.detail?.error || 'refused').slice(0, 40);
206
+ } else if (!response?.ok) {
207
+ throw new Error(`the 99 server did not take the focus blocks${response?.status ? ` (http ${response.status})` : ''}; try again`);
208
+ } else {
209
+ const deep = response.data?.habits?.deep_work;
210
+ if (Number.isInteger(deep?.block_count) && Number.isInteger(deep?.block_minutes)) {
211
+ result.line = blocksLine({ count: deep.block_count, minutes: deep.block_minutes });
212
+ }
213
+ }
214
+ for (const item of batch) next[item.block.id] = `${item.day}|${blockPrint(item.block)}`;
215
+ }
216
+ }
217
+ const oldest = new Date(now);
218
+ oldest.setDate(oldest.getDate() - SENT_KEEP_DAYS);
219
+ const cutoff = localDate(oldest);
220
+ for (const [id, value] of Object.entries(next)) {
221
+ if (typeof value !== 'string' || value.slice(0, 10) < cutoff) delete next[id];
222
+ }
223
+ return result;
224
+ }
225
+
226
+ module.exports = { validateSync, checkpointKey, syncLines, syncBlocks, blockAccountKey };
@@ -4,6 +4,10 @@ const LENGTHS = { focus: [25, 50, 90], meditate: [10, 20, 30] };
4
4
  const SOURCES = ['cli', 'mac', 'ios'];
5
5
  const PAUSE_LIMIT_MS = 30 * 60000;
6
6
  const HEALTH_FIELDS = ['sleep_min', 'steps', 'workout_min', 'mindful_min'];
7
+ const OUTCOMES = ['yes', 'partly', 'no'];
8
+ const INTENTION_MAX = 200;
9
+ const BLOCK_NOTE_MAX = 500;
10
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
7
11
 
8
12
  function lineIdentity(record) {
9
13
  return `${record.type}|${record.type === 'health_day' ? record.date : record.id}|${record.at}|${record.source}`;
@@ -19,6 +23,14 @@ function uniqueLines(records) {
19
23
  });
20
24
  }
21
25
 
26
+ function oneLineText(value) {
27
+ return typeof value === 'string' && !/[\r\n\u2028\u2029]/.test(value);
28
+ }
29
+
30
+ function oneLine(value, limit) {
31
+ return String(value ?? '').replace(/\s+/g, ' ').trim().slice(0, limit).trim();
32
+ }
33
+
22
34
  function validLength(kind, minutes) {
23
35
  return Object.hasOwn(LENGTHS, kind) && LENGTHS[kind].includes(minutes);
24
36
  }
@@ -31,10 +43,12 @@ function parseSessionLog(text) {
31
43
  const common = record && record.v === 1 && SOURCES.includes(record.source)
32
44
  && typeof record.at === 'string' && Number.isFinite(Date.parse(record.at));
33
45
  const session = typeof record?.id === 'string' && record.id;
34
- const start = record?.type === 'start' && validLength(record.kind, record.planned_min);
46
+ const start = record?.type === 'start' && validLength(record.kind, record.planned_min)
47
+ && (record.intention === undefined || oneLineText(record.intention));
35
48
  const end = record?.type === 'end' && ['done', 'partial'].includes(record.status)
36
49
  && (record.inferred === undefined || typeof record.inferred === 'boolean')
37
- && (record.note === undefined || (typeof record.note === 'string' && !/[\r\n\u2028\u2029]/.test(record.note)));
50
+ && (record.note === undefined || oneLineText(record.note))
51
+ && (record.outcome === undefined || record.outcome === null || OUTCOMES.includes(record.outcome));
38
52
  const pause = ['pause', 'resume'].includes(record?.type);
39
53
  const health = record?.type === 'health_day' && typeof record.date === 'string'
40
54
  && /^\d{4}-\d{2}-\d{2}$/.test(record.date)
@@ -54,6 +68,7 @@ function foldSessions(records) {
54
68
  const sessions = new Map();
55
69
  const events = new Map();
56
70
  const ends = new Map();
71
+ const outcomes = new Map();
57
72
  // Stable sort preserves append order when timestamps tie, without rewriting the log.
58
73
  for (const record of uniqueLines(records).sort((a, b) => Date.parse(a.at) - Date.parse(b.at))) {
59
74
  if (record.type === 'health_day') continue;
@@ -68,6 +83,7 @@ function foldSessions(records) {
68
83
  ended_at: null,
69
84
  status: 'running',
70
85
  });
86
+ if (record.intention) sessions.get(record.id).intention = record.intention;
71
87
  events.set(record.id, []);
72
88
  } else {
73
89
  const session = sessions.get(record.id);
@@ -76,6 +92,11 @@ function foldSessions(records) {
76
92
  events.get(record.id).push(record);
77
93
  continue;
78
94
  }
95
+ // How the block went counts from any end record: the newest answer wins, and
96
+ // null (asked, skipped) only stands when there is no answer at all.
97
+ if (record.outcome !== undefined && (record.outcome !== null || !outcomes.has(record.id))) {
98
+ outcomes.set(record.id, record.outcome);
99
+ }
79
100
  const previous = ends.get(record.id);
80
101
  // A person's end wins over an automatic observation, in either log order.
81
102
  if (!previous || (previous.inferred === true && record.inferred !== true)
@@ -115,6 +136,7 @@ function foldSessions(records) {
115
136
  session.status = end.status;
116
137
  if (end.note !== undefined) session.note = end.note;
117
138
  if (end.inferred === true) session.inferred = true;
139
+ if (outcomes.has(session.id)) session.outcome = outcomes.get(session.id);
118
140
  }
119
141
  }
120
142
  return [...sessions.values()];
@@ -127,9 +149,50 @@ function runningSession(sessions, now) {
127
149
  return running[0] || null;
128
150
  }
129
151
 
130
- function startRecord(kind, plannedMin, now, id) {
152
+ function startRecord(kind, plannedMin, now, id, intention = '') {
131
153
  if (!validLength(kind, plannedMin)) throw new Error('choose a supported session length');
132
- return { v: 1, type: 'start', id, kind, planned_min: plannedMin, at: now.toISOString(), source: 'cli' };
154
+ const record = { v: 1, type: 'start', id, kind, planned_min: plannedMin, at: now.toISOString(), source: 'cli' };
155
+ const line = kind === 'focus' ? oneLine(intention, INTENTION_MAX) : '';
156
+ if (line) record.intention = line;
157
+ return record;
158
+ }
159
+
160
+ // How a finished focus block went: yes, partly, no, or null for asked and skipped.
161
+ // Only a focus block that ran its full time carries it.
162
+ function withOutcome(record, session, outcome) {
163
+ if ((outcome === null || OUTCOMES.includes(outcome)) && session.kind === 'focus' && record.status === 'done') {
164
+ record.outcome = outcome;
165
+ }
166
+ return record;
167
+ }
168
+
169
+ // One more end line that adds the answer to a block that already ended. It sits after
170
+ // the real end, so every face keeps the earliest end and its time; only the answer is new.
171
+ function answerRecord(session, now, outcome) {
172
+ const at = Math.max(now.getTime(), Date.parse(session.ended_at) + 1);
173
+ return {
174
+ v: 1, type: 'end', id: session.id, status: 'done', at: new Date(at).toISOString(), source: 'cli', inferred: true, outcome,
175
+ };
176
+ }
177
+
178
+ // The server takes saves for the local today, and for yesterday until 3 in the morning.
179
+ function openDays(now) {
180
+ const days = [localDate(now)];
181
+ if (now.getHours() < 3) {
182
+ const yesterday = new Date(now);
183
+ yesterday.setDate(yesterday.getDate() - 1);
184
+ days.push(localDate(yesterday));
185
+ }
186
+ return days;
187
+ }
188
+
189
+ // Finished focus blocks made on this computer, still open for a save, never asked about.
190
+ function unansweredBlocks(records, now) {
191
+ const sources = new Map(records.filter((record) => record.type === 'start').map((record) => [record.id, record.source]));
192
+ const open = new Set(openDays(now));
193
+ return foldSessions(records).filter((session) => session.kind === 'focus' && session.status === 'done'
194
+ && session.outcome === undefined && ['cli', 'mac'].includes(sources.get(session.id))
195
+ && open.has(localDate(new Date(session.started_at))));
133
196
  }
134
197
 
135
198
  function endRecord(session, now, note = '') {
@@ -208,6 +271,72 @@ function sessionsToday(sessions, now) {
208
271
  };
209
272
  }
210
273
 
274
+ // A focus block counts on the local day it started, as on the phone.
275
+ function blocksToday(sessions, now) {
276
+ const date = localDate(now);
277
+ const blocks = sessions.filter((session) => session.kind === 'focus' && session.status === 'done'
278
+ && localDate(new Date(session.started_at)) === date);
279
+ return { count: blocks.length, minutes: blocks.reduce((sum, session) => sum + session.planned_min, 0) };
280
+ }
281
+
282
+ // 125 is "2 hours 5 minutes", 60 is "1 hour", 25 is "25 minutes".
283
+ function blockDuration(minutes) {
284
+ const hours = Math.floor(minutes / 60);
285
+ const rest = minutes % 60;
286
+ const hourText = hours === 1 ? '1 hour' : `${hours} hours`;
287
+ const minuteText = rest === 1 ? '1 minute' : `${rest} minutes`;
288
+ if (hours === 0) return minuteText;
289
+ return rest === 0 ? hourText : `${hourText} ${minuteText}`;
290
+ }
291
+
292
+ // "3 blocks, 2 hours 5 minutes"; null with no finished block.
293
+ function blocksLine({ count, minutes }) {
294
+ if (!count) return null;
295
+ return `${count === 1 ? '1 block' : `${count} blocks`}, ${blockDuration(minutes)}`;
296
+ }
297
+
298
+ function offsetMinutesAt(date) {
299
+ return -date.getTimezoneOffset();
300
+ }
301
+
302
+ // ISO 8601 with the local offset at that moment, whole seconds, like the phone writes.
303
+ function isoWithOffset(date) {
304
+ const offset = offsetMinutesAt(date);
305
+ const local = new Date(date.getTime() + offset * 60000).toISOString().slice(0, 19);
306
+ if (offset === 0) return `${local}Z`;
307
+ const sign = offset < 0 ? '-' : '+';
308
+ const abs = Math.abs(offset);
309
+ return `${local}${sign}${String(Math.floor(abs / 60)).padStart(2, '0')}:${String(abs % 60).padStart(2, '0')}`;
310
+ }
311
+
312
+ // A finished focus block in the shape of "Contract: blocks" in project99's deep-work idea.
313
+ // Only blocks made on this computer go; the phone sends its own.
314
+ function blockFromSession(session) {
315
+ if (session.kind !== 'focus' || session.status !== 'done' || !UUID.test(session.id)) return null;
316
+ const started = new Date(session.started_at);
317
+ return {
318
+ day: localDate(started),
319
+ tz_offset_minutes: offsetMinutesAt(started),
320
+ block: {
321
+ id: session.id,
322
+ minutes: session.planned_min,
323
+ intention: oneLine(session.intention, INTENTION_MAX),
324
+ started_at: isoWithOffset(started),
325
+ ended_at: isoWithOffset(new Date(session.ended_at)),
326
+ outcome: session.outcome ?? null,
327
+ note: oneLine(session.note, BLOCK_NOTE_MAX),
328
+ },
329
+ };
330
+ }
331
+
332
+ function localBlocks(records) {
333
+ const sources = new Map(records.filter((record) => record.type === 'start').map((record) => [record.id, record.source]));
334
+ return foldSessions(records)
335
+ .filter((session) => ['cli', 'mac'].includes(sources.get(session.id)))
336
+ .map(blockFromSession)
337
+ .filter(Boolean);
338
+ }
339
+
211
340
  function healthToday(records, now) {
212
341
  const date = localDate(now);
213
342
  let latest;
@@ -228,6 +357,21 @@ function healthSummary(record) {
228
357
  }
229
358
 
230
359
  module.exports = {
360
+ OUTCOMES,
361
+ INTENTION_MAX,
362
+ BLOCK_NOTE_MAX,
363
+ oneLine,
364
+ localDate,
365
+ withOutcome,
366
+ answerRecord,
367
+ openDays,
368
+ unansweredBlocks,
369
+ blocksToday,
370
+ blockDuration,
371
+ blocksLine,
372
+ isoWithOffset,
373
+ blockFromSession,
374
+ localBlocks,
231
375
  validLength,
232
376
  parseSessionLog,
233
377
  foldSessions,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.82.16",
3
+ "version": "3.82.17",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof. project memory your coding agents share across sessions.",
5
5
  "mcpName": "io.github.atrislabs/atris",
6
6
  "main": "bin/atris.js",