atris 3.74.8 → 3.74.10

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/commands/auth.js CHANGED
@@ -1,4 +1,4 @@
1
- const { AGENT_TOKEN_EXPIRED_DETAIL, decodeJwtClaims, loadCredentials, saveCredentials, saveAgentClientCredentials, preserveAgentClientLogin, deleteCredentials, getCredentialsPath, openBrowser, promptUser, displayAccountSummary, ensureValidCredentials, loadProfile, listProfiles, profileNameFromEmail, deleteProfile, saveProfile, getTokenExpiryEpochSeconds, getTerminalSessionId, setSessionProfile, getSessionProfile, cleanStaleSessions, getSessionsDir } = require('../utils/auth');
1
+ const { AGENT_TOKEN_EXPIRED_DETAIL, assertSafeLoginWrite, decodeJwtClaims, loadCredentials, saveCredentials, saveAgentClientCredentials, preserveAgentClientLogin, deleteCredentials, getCredentialsPath, openBrowser, promptUser, displayAccountSummary, ensureValidCredentials, loadProfile, listProfiles, profileNameFromEmail, deleteProfile, saveProfile, getTokenExpiryEpochSeconds, getTerminalSessionId, setSessionProfile, getSessionProfile, cleanStaleSessions, getSessionsDir } = require('../utils/auth');
2
2
  const { getAppBaseUrl, getApiBaseUrl, apiRequestJson } = require('../utils/api');
3
3
  const { isNonInteractive, wantsJson } = require('../lib/noninteractive');
4
4
  const { hasFlag, readFlag } = require('../lib/arg-parser');
@@ -939,6 +939,7 @@ function activeProfileName(credentials) {
939
939
  function writeGlobalCredentials(profile) {
940
940
  const kept = preserveAgentClientLogin();
941
941
  const credentialsPath = getCredentialsPath();
942
+ assertSafeLoginWrite(credentialsPath);
942
943
  fs.writeFileSync(credentialsPath, JSON.stringify(profile, null, 2));
943
944
  try { fs.chmodSync(credentialsPath, 0o600); } catch {}
944
945
  return kept;
@@ -1167,7 +1168,7 @@ function resolveProfile() {
1167
1168
  const profile = loadProfile(p);
1168
1169
  return profile?.email?.toLowerCase().includes(q);
1169
1170
  });
1170
- if (match) {
1171
+ if (match && loadProfile(match)) {
1171
1172
  process.stdout.write(match);
1172
1173
  process.exit(0);
1173
1174
  }
@@ -1251,7 +1252,7 @@ function shellInit() {
1251
1252
  ' if [[ $? -eq 0 && "$_tty" == /dev/* ]]; then echo "$_tty"; return; fi',
1252
1253
  '}',
1253
1254
  'atris() {',
1254
- ' if [[ "$1" == "switch" && -n "$2" && "$2" != "--"* ]]; then',
1255
+ ' if [[ "$1" == "switch" && -n "$2" && "$2" != "-"* ]]; then',
1255
1256
  ' local _profile',
1256
1257
  ' _profile=$(command atris _resolve "$2" 2>/dev/null)',
1257
1258
  ' if [[ $? -eq 0 && -n "$_profile" ]]; then',
@@ -3547,25 +3547,27 @@ async function computerChat(token, ctx, initialOptions = {}) {
3547
3547
  }
3548
3548
 
3549
3549
  if (scriptedInput !== null) {
3550
- for (const rawLine of scriptedInput.split(/\r?\n/)) {
3550
+ const messages = oneShotMessage ? [scriptedInput] : scriptedInput.split(/\r?\n/);
3551
+ for (const rawLine of messages) {
3551
3552
  const line = String(rawLine || '').trim();
3552
3553
  if (!line) continue;
3553
- if (line === '/exit' || line === '/quit') break;
3554
- if (line.startsWith('/run ')) {
3554
+ const isCommand = line.startsWith('/') && !/[\r\n]/.test(line);
3555
+ if (isCommand && (line === '/exit' || line === '/quit')) break;
3556
+ if (isCommand && line.startsWith('/run ')) {
3555
3557
  await computerRun(token, line.slice(5), ctx);
3556
3558
  continue;
3557
3559
  }
3558
- if (line === '/pwd') {
3560
+ if (isCommand && line === '/pwd') {
3559
3561
  await computerRun(token, 'pwd', ctx);
3560
3562
  continue;
3561
3563
  }
3562
- if (line === '/audit' || line.startsWith('/audit ')) {
3564
+ if (isCommand && (line === '/audit' || line.startsWith('/audit '))) {
3563
3565
  const rawLimit = line.split(/\s+/, 2)[1];
3564
3566
  const limit = rawLimit ? Number.parseInt(rawLimit, 10) : 10;
3565
3567
  await computerAudit(token, ctx, Number.isFinite(limit) ? limit : 10);
3566
3568
  continue;
3567
3569
  }
3568
- if (line.startsWith('/')) {
3570
+ if (isCommand) {
3569
3571
  const command = line.split(/\s+/, 1)[0];
3570
3572
  if (!KNOWN_CHAT_COMMANDS.has(command)) {
3571
3573
  console.log(`Unknown command: ${command}`);
@@ -3573,7 +3575,7 @@ async function computerChat(token, ctx, initialOptions = {}) {
3573
3575
  }
3574
3576
  continue;
3575
3577
  }
3576
- sessionId = await sendBusinessChat(token, ctx, line, sessionId, false, null, {
3578
+ sessionId = await sendBusinessChat(token, ctx, oneShotMessage ? rawLine : line, sessionId, false, null, {
3577
3579
  worker,
3578
3580
  model,
3579
3581
  systemPrompt: chatSystemPrompt,
@@ -204,7 +204,8 @@ async function businessDiff(slug) {
204
204
  const localContent = fs.readFileSync(localPath, 'utf8');
205
205
 
206
206
  if (f.isNew) {
207
- console.log(` + ${f.path.replace(/^\//, '')} (new file, ${localContent.split('\n').length} lines)`);
207
+ const lineCount = localContent.split('\n').length - (localContent.endsWith('\n') ? 1 : 0);
208
+ console.log(` + ${f.path.replace(/^\//, '')} (new file, ${lineCount} lines)`);
208
209
  } else {
209
210
  // Show a simple line-count diff
210
211
  console.log(` ~ ${f.path.replace(/^\//, '')} (modified)`);
package/commands/gm.js CHANGED
@@ -797,19 +797,20 @@ function tail(text, used, width) {
797
797
  return clip(text, Math.max(10, width - used));
798
798
  }
799
799
 
800
- // Last guard on width: a long line breaks after a comma onto an indented
801
- // line; anything still too long ends in "...".
800
+ // Last guard on width, in terminal columns: a long line breaks after a
801
+ // comma onto an indented line; anything still too long ends in "...".
802
802
  function fitLines(lines, width) {
803
+ const { displayWidth, sliceColumns } = require('../lib/text-table');
803
804
  const out = [];
804
805
  for (const line of lines) {
805
806
  let rest = line;
806
- while (rest.length > width) {
807
- const cut = rest.lastIndexOf(', ', width - 1);
807
+ while (displayWidth(rest) > width) {
808
+ const cut = rest.lastIndexOf(', ', sliceColumns(rest, width).length - 1);
808
809
  if (cut <= 0) break;
809
810
  out.push(rest.slice(0, cut + 1));
810
811
  rest = ` ${rest.slice(cut + 2)}`;
811
812
  }
812
- out.push(rest.length > width ? `${rest.slice(0, width - 3)}...` : rest);
813
+ out.push(displayWidth(rest) > width ? `${sliceColumns(rest, width - 3)}...` : rest);
813
814
  }
814
815
  return out;
815
816
  }
@@ -830,13 +831,30 @@ function boardWorkspaceRoot(args = []) {
830
831
  return taskDb.workspaceRoot(path.resolve(workspaceArg));
831
832
  }
832
833
 
834
+ // The team page's cells (role, ovr, engine) for each roster member, read
835
+ // only when the board draws its box in a terminal; null otherwise.
836
+ function boardCells(board, workspaceRoot, ratings) {
837
+ if (!process.stdout.isTTY || !workspaceRoot) return null;
838
+ return require('../lib/gm-bet').teamCells(workspaceRoot, board.roster.map(r => r.name), { ratings });
839
+ }
840
+
833
841
  function loadBoard(workspaceRoot, options = {}) {
842
+ return loadSeasonBoard(workspaceRoot, options).board;
843
+ }
844
+
845
+ // The board and every member's rating (gm-season memberRatings, the same
846
+ // numbers the orb's team page reads) from one read of the season.
847
+ function loadSeasonBoard(workspaceRoot, options = {}) {
834
848
  const season = require('../lib/gm-season');
835
- return season.seasonBoard(season.loadSeason(workspaceRoot, { lastSeen: options.lastSeen }), options);
849
+ const played = season.loadSeason(workspaceRoot, { lastSeen: options.lastSeen });
850
+ return { board: season.seasonBoard(played, options), ratings: season.memberRatings(played.outcomes || []) };
836
851
  }
837
852
 
838
853
  // One screen, about 40 rows at most. Pure: board in, lines out.
839
- function renderBoardLines(board, { interactive = false, width = termWidth() } = {}) {
854
+ // In a terminal, given the team page's cells, the roster is the orb's team
855
+ // table as a divided box (member, role, ovr, engine, status); piped, or when
856
+ // the box cannot fit the width, it keeps the plain columns.
857
+ function renderBoardLines(board, { interactive = false, width = termWidth(), bordered = Boolean(process.stdout.isTTY), cells = null } = {}) {
840
858
  const { clockLabel } = require('../lib/gm-season');
841
859
  const lines = [];
842
860
  const nameW = nameWidth([
@@ -856,7 +874,12 @@ function renderBoardLines(board, { interactive = false, width = termWidth() } =
856
874
  lines.push('');
857
875
 
858
876
  lines.push('roster, last 30 days');
859
- if (board.roster.length) {
877
+ const boxed = bordered && cells && board.roster.length
878
+ ? require('../lib/gm-bet').teamBox(board.roster.map(r => ({ member: r.name, status: r.status })), cells, { width: width - 2 })
879
+ : null;
880
+ if (boxed) {
881
+ lines.push(...boxed.split('\n').map(line => ` ${line}`));
882
+ } else if (board.roster.length) {
860
883
  lines.push(` ${pad('name', nameW)}${pad('ovr', 8)}${pad('form', 7)}${pad('record', 18)}status`);
861
884
  for (const row of board.roster) {
862
885
  const record = `landed ${row.landed} of ${row.jobs}`;
@@ -913,9 +936,10 @@ function renderBoardLines(board, { interactive = false, width = termWidth() } =
913
936
  }
914
937
  lines.push('');
915
938
 
916
- lines.push('key: ovr 60 to 99 = share of finished jobs landed, rookie under 3 finished,');
939
+ lines.push(`key: ovr 60 to 99 = share of finished jobs landed, ${boxed ? 'new' : 'rookie'} under 3 finished,`);
917
940
  lines.push(' stricter than the website rating on purpose (drops and abandons count)');
918
- lines.push(' form: last 5, newest right: k landed, d dropped, x sent back, a abandoned');
941
+ if (boxed) lines.push(' ·· = nothing to show yet: no finished job, no role, or no pinned model');
942
+ else lines.push(' form: last 5, newest right: k landed, d dropped, x sent back, a abandoned');
919
943
  lines.push(' hours: claim to result. on a job: moved in the last 6h. review: pending');
920
944
  if (interactive) for (const line of PLAY_KEYS) lines.push(line);
921
945
  else lines.push(' run atris gm in a terminal to play');
@@ -990,9 +1014,10 @@ function printLeague() {
990
1014
  }
991
1015
 
992
1016
  function printBoard(args = []) {
993
- const board = loadBoard(boardWorkspaceRoot(args));
1017
+ const root = boardWorkspaceRoot(args);
1018
+ const { board, ratings } = loadSeasonBoard(root);
994
1019
  if (board.error) return failRead(board.error);
995
- console.log(renderBoardLines(board).join('\n'));
1020
+ console.log(renderBoardLines(board, { cells: boardCells(board, root, ratings) }).join('\n'));
996
1021
  return undefined;
997
1022
  }
998
1023
 
@@ -1107,11 +1132,11 @@ async function gmPlay(args = []) {
1107
1132
  leagueRowsSeen = [...board.rows, ...(board.hidden_clubs || [])];
1108
1133
  write(renderLeagueLines(board, { interactive: true }).join('\n'));
1109
1134
  } else {
1110
- const board = loadBoard(workspaceRoot, { seen, lastSeen });
1135
+ const { board, ratings } = loadSeasonBoard(workspaceRoot, { seen, lastSeen });
1111
1136
  lastBoard = board;
1112
1137
  seen = new Set(board.last_plays.map(play => play.id));
1113
1138
  stuck = board.stuck || [];
1114
- write(renderBoardLines(board, { interactive: true }).join('\n'));
1139
+ write(renderBoardLines(board, { interactive: true, cells: boardCells(board, workspaceRoot, ratings) }).join('\n'));
1115
1140
  season.writeLastSeen(workspaceRoot, board.now);
1116
1141
  }
1117
1142
  } catch (err) {
@@ -1986,9 +2011,11 @@ async function gmCommand(...args) {
1986
2011
  }
1987
2012
 
1988
2013
  module.exports = {
2014
+ boardCells,
1989
2015
  displayCommand,
1990
2016
  ensureStarterMission,
1991
2017
  gmCommand,
2018
+ loadSeasonBoard,
1992
2019
  pickClub,
1993
2020
  playAction,
1994
2021
  renderBoardLines,
@@ -1226,7 +1226,8 @@ async function calendarSearch(args = []) {
1226
1226
  }
1227
1227
 
1228
1228
  async function calendarToday() {
1229
- await calendarRange("Today's events", { days: 1 });
1229
+ const { start, end } = localDayBounds();
1230
+ await calendarRange("Today's events", { timeMin: start.toISOString(), timeMax: end.toISOString() });
1230
1231
  }
1231
1232
 
1232
1233
  async function calendarYesterday() {
@@ -3,7 +3,7 @@ const path = require('path');
3
3
  const crypto = require('crypto');
4
4
  const os = require('os');
5
5
  const { execFileSync, spawnSync } = require('child_process');
6
- const { loadCredentials } = require('../utils/auth');
6
+ const { loadCredentials, ensureFreshCredentials } = require('../utils/auth');
7
7
  const { resolveBackendRoot } = require('../utils/backend-root');
8
8
  const { apiRequestJson } = require('../utils/api');
9
9
  const { runAliveTick } = require('../lib/member-alive');
@@ -4942,7 +4942,7 @@ async function memberPush(name) {
4942
4942
  process.exit(1);
4943
4943
  }
4944
4944
 
4945
- const creds = loadCredentials();
4945
+ const creds = await ensureFreshCredentials(apiRequestJson);
4946
4946
  if (!creds || !creds.token) {
4947
4947
  console.error('Not logged in. Run: atris login');
4948
4948
  process.exit(1);
@@ -5007,7 +5007,7 @@ async function memberPush(name) {
5007
5007
  // --- PULL subcommand ---
5008
5008
 
5009
5009
  async function memberPull(nameOrAgentId) {
5010
- const creds = loadCredentials();
5010
+ const creds = await ensureFreshCredentials(apiRequestJson);
5011
5011
  if (!creds || !creds.token) {
5012
5012
  console.error('Not logged in. Run: atris login');
5013
5013
  process.exit(1);
@@ -11664,6 +11664,44 @@ async function memberStandupWalk(room, args = []) {
11664
11664
  return payload;
11665
11665
  }
11666
11666
 
11667
+ // The team page's cells (role, ovr, engine) per standup member, read only
11668
+ // when the standup can draw its table (a terminal); --json never carries
11669
+ // them. ovr comes from gm-season memberRatings, as on the orb's team page.
11670
+ function standupTableData(standup, cwd = process.cwd()) {
11671
+ if (!process.stdout.isTTY) return { bordered: false };
11672
+ const season = require('../lib/gm-season');
11673
+ const root = require('../lib/task-db').workspaceRoot(path.resolve(cwd));
11674
+ let ratings = new Map();
11675
+ try {
11676
+ const played = season.loadSeason(root, { lastSeen: null });
11677
+ if (played && !played.error) ratings = season.memberRatings(played.outcomes || []);
11678
+ } catch { ratings = new Map(); }
11679
+ const cells = require('../lib/gm-bet').teamCells(root, standup.map((s) => s.member), { ratings });
11680
+ return { bordered: true, cells };
11681
+ }
11682
+
11683
+ // The standup: in a terminal the orb's team table as a divided box
11684
+ // (member, role, ovr, engine, status) with each member's found and push
11685
+ // lines under it; piped, or when the box cannot fit, the boxes it always had.
11686
+ function memberStandupLines(standup, { bordered = false, width = process.stdout.columns, cells = null } = {}) {
11687
+ if (bordered && cells && standup.length) {
11688
+ const table = require('../lib/gm-bet').teamBox(standup.map((s) => ({ member: s.member, status: s.status })), cells, { width: Number(width) - 2 });
11689
+ if (table) {
11690
+ return [
11691
+ ...table.split('\n').map((line) => ` ${line}`),
11692
+ ...standup.flatMap((s) => [` ${s.member}`, ` found: ${s.latest_update}`, ` push: ${s.next_push}`]),
11693
+ ];
11694
+ }
11695
+ }
11696
+ return standup.flatMap((s, idx) => [
11697
+ `+-----------------------------------------------------------------------+`,
11698
+ `| ${idx + 1}. ${s.member.toUpperCase()} (${s.role}) · ${s.status} · engine:${s.engine}`,
11699
+ `| Found: ${s.latest_update}`,
11700
+ `| Push: ${s.next_push}`,
11701
+ `+-----------------------------------------------------------------------+`,
11702
+ ]);
11703
+ }
11704
+
11667
11705
  async function memberRoom(...args) {
11668
11706
  if (hasFlag(args, '--help') || hasFlag(args, '-h')) {
11669
11707
  console.log('Usage: atris member [day|standup|room] [--walk] [--limit N] [--decisions "y|idea|n"] [--json]');
@@ -11680,13 +11718,7 @@ async function memberRoom(...args) {
11680
11718
  ? `Your team runs on ${teamEngines.length > 1 ? `${teamEngines.slice(0, -1).join(', ')} and ${teamEngines[teamEngines.length - 1]}` : teamEngines[0]}.`
11681
11719
  : 'Your team picks an engine for each job.';
11682
11720
 
11683
- const standupRows = room.standup.flatMap((s, idx) => [
11684
- `+-----------------------------------------------------------------------+`,
11685
- `| ${idx + 1}. ${s.member.toUpperCase()} (${s.role}) · ${s.status} · engine:${s.engine}`,
11686
- `| Found: ${s.latest_update}`,
11687
- `| Push: ${s.next_push}`,
11688
- `+-----------------------------------------------------------------------+`,
11689
- ]);
11721
+ const standupRows = memberStandupLines(room.standup, standupTableData(room.standup));
11690
11722
 
11691
11723
  const handoffRows = room.handoffs.length
11692
11724
  ? room.handoffs.map((h) => ` - ${h.from} -> ${h.to}: ${h.summary} (run: ${h.next_command})`)
@@ -12019,6 +12051,8 @@ async function memberCommand(subcommand, ...args) {
12019
12051
 
12020
12052
  module.exports = {
12021
12053
  logDateForScan,
12054
+ memberStandupLines,
12055
+ standupTableData,
12022
12056
  memberRunDefaultWallSeconds,
12023
12057
  memberRunWantsReferee,
12024
12058
  proposalPromptForGoal,
@@ -20,19 +20,26 @@ const {
20
20
  sessionStatus,
21
21
  sessionsToday,
22
22
  healthToday,
23
- healthSummary,
24
23
  } = require('../lib/ninety-nine');
25
24
 
26
25
  function usage() {
27
26
  return [
28
27
  'usage: atris 99 focus <25|50|90>',
29
28
  ' atris 99 meditate <10|20|30>',
29
+ ' atris 99 show the timer and today',
30
+ ' atris 99 <25|50|90> start focus',
31
+ ' atris 99 <10|20|30> start meditation',
32
+ ' atris 99 f <25|50|90>',
33
+ ' atris 99 m <10|20|30>',
34
+ ' atris 99 start <focus|meditate> <minutes>',
35
+ ' atris 99 again repeat the last finished session',
30
36
  ' atris 99 stop [note words]',
31
37
  ' atris 99 pause',
32
38
  ' atris 99 resume',
33
39
  ' atris 99 status [--json]',
34
40
  ' atris 99 today [--json]',
35
41
  ' atris 99 sync [--dry-run]',
42
+ ' atris 99 help | --help',
36
43
  '',
37
44
  'sync is off without a sync block in ~/.atris/99/config.json (ATRIS_99_CONFIG overrides it).',
38
45
  'personal: {"sync":{"target":"personal","app_slug":"project-99"}}',
@@ -152,6 +159,23 @@ function appendRecord(logPath, previousText, record) {
152
159
  return previousText + line;
153
160
  }
154
161
 
162
+ function todayLine(result) {
163
+ return `Today: ${result.counts.focus} focus, ${result.counts.meditate} meditate, ${result.finished_minutes} min`;
164
+ }
165
+
166
+ function kindName(kind) {
167
+ return kind === 'focus' ? 'Focus' : 'Meditate';
168
+ }
169
+
170
+ function timeLeft(session, time) {
171
+ const seconds = sessionStatus(session, time).running.remaining_seconds;
172
+ return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`;
173
+ }
174
+
175
+ function localEndTime(end) {
176
+ return end.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
177
+ }
178
+
155
179
  function ninetyNineAtris(args = process.argv.slice(3), {
156
180
  log = console.log,
157
181
  now = () => new Date(),
@@ -161,32 +185,48 @@ function ninetyNineAtris(args = process.argv.slice(3), {
161
185
  request,
162
186
  credentials,
163
187
  } = {}) {
164
- const [action, ...rest] = args;
188
+ let [action, ...rest] = args;
189
+ const overview = args.length === 0;
165
190
  // Words after stop belong to its note, including the word "help".
166
191
  if ((action !== 'stop' && argsWantHelp(args)) || (action === 'stop' && ['--help', '-h'].includes(rest[0]))) {
167
192
  log(usage());
168
193
  return 0;
169
194
  }
170
195
  if (action === 'sync') return syncNinetyNine(rest, { log, logPath, configPath, statePath, request, credentials });
196
+ if (action === 'start' && ['focus', 'meditate', 'f', 'm'].includes(rest[0])) [action, ...rest] = rest;
197
+ if (action === 'f') action = 'focus';
198
+ if (action === 'm') action = 'meditate';
199
+ for (const kind of ['focus', 'meditate']) {
200
+ if (validLength(kind, Number(action)) && String(Number(action)) === action) {
201
+ rest.unshift(action);
202
+ action = kind;
203
+ break;
204
+ }
205
+ }
171
206
  const json = rest.includes('--json');
172
207
  if (action === 'focus' || action === 'meditate') {
208
+ const lengths = action === 'focus' ? '25 | 50 | 90' : '10 | 20 | 30';
209
+ if (rest.length === 0) {
210
+ log(`Pick a length: atris 99 ${action} ${lengths}`);
211
+ return 1;
212
+ }
173
213
  if (rest.length !== 1 || !validLength(action, Number(rest[0])) || String(Number(rest[0])) !== rest[0]) {
174
- log(action === 'focus' ? 'choose focus 25, 50, or 90 minutes' : 'choose meditate 10, 20, or 30 minutes');
175
- return 2;
214
+ log(`Allowed ${action} lengths: ${lengths.replaceAll(' | ', ', ')} minutes.`);
215
+ return 1;
176
216
  }
177
217
  } else if (action === 'status' || action === 'today') {
178
218
  if (rest.length > 1 || (rest.length === 1 && !json)) {
179
219
  log(`usage: atris 99 ${action} [--json]`);
180
220
  return 2;
181
221
  }
182
- } else if (action === 'pause' || action === 'resume') {
222
+ } else if (action === 'pause' || action === 'resume' || action === 'again') {
183
223
  if (rest.length) {
184
224
  log(`usage: atris 99 ${action}`);
185
225
  return 2;
186
226
  }
187
- } else if (action !== 'stop') {
188
- log(usage());
189
- return 2;
227
+ } else if (action !== 'stop' && !overview) {
228
+ log(`Unknown: ${action}. Try: atris 99 help`);
229
+ return 1;
190
230
  }
191
231
 
192
232
  let lock;
@@ -222,55 +262,75 @@ function ninetyNineAtris(args = process.argv.slice(3), {
222
262
 
223
263
  if (action === 'stop') {
224
264
  if (!stopTarget) {
225
- log('nothing running');
265
+ log('Nothing running. Start one: atris 99 50');
226
266
  return 1;
227
267
  }
228
268
  const end = endRecord(stopTarget, time, rest.join(' '));
229
269
  appendRecord(file, text, end);
230
- log(`${stopTarget.kind} ${end.status}: ${end.status === 'done' ? stopTarget.planned_min : 0} finished minutes`);
270
+ records.push(end);
271
+ log(end.status === 'done'
272
+ ? `${kindName(stopTarget.kind)} ${stopTarget.planned_min} done.`
273
+ : "Stopped early, so it doesn't count.");
274
+ log(todayLine(sessionsToday(foldSessions(records), time)));
231
275
  return 0;
232
276
  }
233
277
 
234
278
  sessions = foldSessions(records);
235
279
  active = runningSession(sessions, time);
236
- if (action === 'focus' || action === 'meditate') {
280
+ if (action === 'again') {
281
+ const previous = sessions.filter((session) => session.status === 'done')
282
+ .sort((a, b) => Date.parse(b.ended_at) - Date.parse(a.ended_at))[0];
283
+ if (!previous) {
284
+ log('No finished session yet. Try: atris 99 50');
285
+ return 1;
286
+ }
287
+ action = previous.kind;
288
+ rest = [String(previous.planned_min)];
289
+ }
290
+ if (overview) {
291
+ if (!active) log('Nothing running.');
292
+ else log(`${kindName(active.kind)} · ${timeLeft(active, time)} left${active.status === 'paused' ? ' · paused' : ''}`);
293
+ log(todayLine(sessionsToday(sessions, time)));
294
+ log('Start: atris 99 50 Again: atris 99 again Help: atris 99 help');
295
+ } else if (action === 'focus' || action === 'meditate') {
237
296
  if (active) {
238
297
  log('a session is already running; use atris 99 stop first');
239
298
  return 1;
240
299
  }
241
300
  appendRecord(file, text, startRecord(action, Number(rest[0]), time, randomUUID()));
242
- log(`${action} started for ${rest[0]} minutes`);
301
+ const end = new Date(time.getTime() + Number(rest[0]) * 60000);
302
+ log(`${kindName(action)} ${rest[0]} started. Ends at ${localEndTime(end)}.`);
243
303
  } else if (action === 'pause' || action === 'resume') {
244
- let record;
245
- try {
246
- record = action === 'pause' ? pauseRecord(active, time) : resumeRecord(active, time);
247
- } catch (error) {
248
- log(error.message);
304
+ if (!active) {
305
+ log('Nothing running. Start one: atris 99 50');
306
+ return 1;
307
+ }
308
+ if (action === 'pause' && active.status === 'paused') {
309
+ log('Already paused. Resume: atris 99 resume');
310
+ return 1;
311
+ }
312
+ if (action === 'resume' && active.status !== 'paused') {
313
+ log('Not paused.');
249
314
  return 1;
250
315
  }
316
+ const record = action === 'pause' ? pauseRecord(active, time) : resumeRecord(active, time);
251
317
  appendRecord(file, text, record);
252
- log(`${active.kind} ${action === 'pause' ? 'paused' : 'resumed'}`);
318
+ if (action === 'pause') log(`Paused. ${timeLeft(active, time)} left.`);
319
+ else {
320
+ const end = new Date(Date.parse(active.planned_end_at) + time.getTime() - Date.parse(active.paused_at));
321
+ log(`Resumed. Ends at ${localEndTime(end)}.`);
322
+ }
253
323
  } else if (action === 'status') {
254
324
  const result = sessionStatus(active, time);
255
325
  if (json) log(JSON.stringify(result));
256
326
  else if (!active) log('nothing running');
257
- else {
258
- const seconds = result.running.remaining_seconds;
259
- log(`${active.kind}: ${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')} left${active.status === 'paused' ? ' (paused)' : ''}`);
260
- }
327
+ else log(`${kindName(active.kind)} · ${timeLeft(active, time)} left${active.status === 'paused' ? ' · paused' : ''}`);
261
328
  } else {
262
329
  const result = sessionsToday(sessions, time);
263
330
  const health = healthToday(records, time);
264
331
  if (health) result.health_day = health;
265
332
  if (json) log(JSON.stringify(result));
266
- else {
267
- for (const session of result.sessions) {
268
- log(`${session.kind} ${session.planned_min} minutes: ${session.status}`);
269
- }
270
- log(`${result.finished_minutes} finished minutes today`);
271
- log(`Today: ${result.counts.focus} focus, ${result.counts.meditate} meditate, ${result.finished_minutes} min`);
272
- if (health) log(healthSummary(health));
273
- }
333
+ else log(todayLine(result));
274
334
  }
275
335
  return 0;
276
336
  } catch (error) {
package/commands/orb.js CHANGED
@@ -153,7 +153,7 @@ function unknownOrbFlags(args = []) {
153
153
  function tailLines(text, count = 60) {
154
154
  const lines = String(text || '').split(/\r?\n/);
155
155
  if (lines[lines.length - 1] === '') lines.pop();
156
- return lines.slice(-count).join('\n');
156
+ return lines.slice(Math.max(0, lines.length - count)).join('\n');
157
157
  }
158
158
 
159
159
  async function runOrb(args = []) {
package/commands/pull.js CHANGED
@@ -1,6 +1,6 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
- const { loadCredentials, abortOnAuthFailure } = require('../utils/auth');
3
+ const { ensureFreshCredentials, abortOnAuthFailure } = require('../utils/auth');
4
4
  const { apiRequestJson } = require('../utils/api');
5
5
  const { findAllMembers } = require('./member');
6
6
  const { loadConfig } = require('../utils/config');
@@ -158,7 +158,7 @@ async function pullAtris() {
158
158
  process.exit(1);
159
159
  }
160
160
 
161
- const creds = loadCredentials();
161
+ const creds = await ensureFreshCredentials(apiRequestJson);
162
162
  if (!creds || !creds.token) {
163
163
  console.error('Not logged in. Run: atris login');
164
164
  process.exit(1);
@@ -206,7 +206,7 @@ async function pullAtris() {
206
206
 
207
207
  async function pullBusiness(slug) {
208
208
  const elapsedMs = startTimer();
209
- const creds = loadCredentials();
209
+ const creds = await ensureFreshCredentials(apiRequestJson);
210
210
  if (!creds || !creds.token) {
211
211
  console.error('Not logged in. Run: atris login');
212
212
  process.exit(1);
package/commands/push.js CHANGED
@@ -1,7 +1,7 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
  const readline = require('readline');
4
- const { loadCredentials, abortOnAuthFailure } = require('../utils/auth');
4
+ const { ensureFreshCredentials, abortOnAuthFailure } = require('../utils/auth');
5
5
  const { apiRequestJson } = require('../utils/api');
6
6
  const { loadBusinesses, saveBusinesses, businessMatchesSlug } = require('./business');
7
7
  const { loadManifest, saveManifest, buildManifest, computeLocalHashes, isIgnoredSyncPath, filterSyncFiles } = require('../lib/manifest');
@@ -457,7 +457,7 @@ async function pushAtris() {
457
457
  const timeoutSec = parsePushTimeoutSec(process.argv);
458
458
  let onlyPrefixes = null;
459
459
 
460
- const creds = loadCredentials();
460
+ const creds = await ensureFreshCredentials(apiRequestJson);
461
461
  if (!creds || !creds.token) { console.error('Not logged in. Run: atris login'); process.exit(1); }
462
462
 
463
463
  // Determine source directory
package/commands/skill.js CHANGED
@@ -549,6 +549,11 @@ function skillFix(name) {
549
549
  const skillsDir = path.join(process.cwd(), 'atris', 'skills');
550
550
  const allSkills = findAllSkills(skillsDir);
551
551
 
552
+ if (allSkills.length === 0) {
553
+ console.error('No skills found in atris/skills/.');
554
+ process.exit(1);
555
+ }
556
+
552
557
  const targets = name === '--all'
553
558
  ? allSkills
554
559
  : allSkills.filter(s => s.folder === name || s.leafFolder === name);
package/commands/soul.js CHANGED
@@ -28,7 +28,8 @@ function readFile(filePath) {
28
28
  }
29
29
 
30
30
  function countFiles(dir) {
31
- try { return fs.readdirSync(dir, { recursive: true }).filter(f => !f.startsWith('.')).length; } catch { return 0; }
31
+ const isFile = (f) => { try { return fs.statSync(path.join(dir, f)).isFile(); } catch { return false; } }; // a dead link skips itself, not the count
32
+ try { return fs.readdirSync(dir, { recursive: true }).filter(f => !f.startsWith('.') && isFile(f)).length; } catch { return 0; }
32
33
  }
33
34
 
34
35
  // ── Soul snapshot ──────────────────────────────────────
@@ -268,7 +268,8 @@ function makeEvent({ ts, agent, event, summary, raw, source, root }) {
268
268
  const ms = timestampMs(ts);
269
269
  if (!ms) return null;
270
270
  const cleanAgent = sanitizeAgent(agent);
271
- const cleanSummary = sanitizeSummary(summary, cleanAgent);
271
+ // Snapshot headings are complete lines, not agent-prefixed activity.
272
+ const cleanSummary = sanitizeSummary(summary, event === 'snapshot' ? '' : cleanAgent);
272
273
  return {
273
274
  ts: new Date(ms).toISOString(),
274
275
  ms,
@@ -755,7 +756,7 @@ function renderJsonLine(record) {
755
756
  ts: record.ts,
756
757
  agent: sanitizeAgent(record.agent),
757
758
  event: record.event,
758
- summary: sanitizeSummary(record.summary, record.agent),
759
+ summary: sanitizeSummary(record.summary, record.event === 'snapshot' ? '' : record.agent),
759
760
  raw: record.raw || null,
760
761
  });
761
762
  }
@@ -292,7 +292,7 @@ function stringsCommand(argv) {
292
292
  Pairs with 'atris slop' (how copy reads), strings governs what words you ship.
293
293
  The registry lives in .atris/strings.json. Wire 'check --staged' into the pre-commit gate.
294
294
  `);
295
- return 0;
295
+ return !sub || ['help', '--help', '-h'].includes(sub) ? 0 : 2;
296
296
  }
297
297
 
298
298
  module.exports = {
@@ -377,7 +377,7 @@ function normalizeQuestions(payload, problem) {
377
377
  }
378
378
 
379
379
  function fallbackQuestions(problem) {
380
- const subject = (clipLine(problem, 90) || 'this problem').replace(/[?!.]+$/, '');
380
+ const subject = clipLine((String(problem || '').trim() || 'this problem').replace(/[?!.]+$/, ''), 90);
381
381
  return [
382
382
  {
383
383
  question: `What existing code path already owns "${subject}"?`,
@@ -1902,7 +1902,7 @@ async function executeAgentSDKFast(userInput) {
1902
1902
  } catch (error) {
1903
1903
  console.error(`✗ Error: ${error.message}`);
1904
1904
  console.log('');
1905
- console.log('💡 Hosted turns use https://api.atris.ai. For local development, set ATRIS_API_BASE to your own backend URL.');
1905
+ console.log('💡 start your local backend at http://127.0.0.1:8000.');
1906
1906
  process.exit(1);
1907
1907
  }
1908
1908
  }