mindvest-atlas 0.100.0 → 0.100.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mindvest-atlas",
3
- "version": "0.100.0",
3
+ "version": "0.100.2",
4
4
  "description": "Atlas CLI \u2014 OAuth login, tool calls, and live alert/flow streaming for the Atlas trading API",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1033,6 +1033,21 @@ function startLocalRequests(root, timeoutMs) {
1033
1033
  * alone, so with Studio closed a "weekdays 8:30" check never ran.
1034
1034
  */
1035
1035
  const checksSeen = new Map();
1036
+ /** Check times that are not a valid schedule, said once each (they never run). */
1037
+ const checksWarned = new Set();
1038
+
1039
+ /** Say once why a checks schedule, or one check's own time, never runs. */
1040
+ function warnCheckTimes(slug, when) {
1041
+ const times = [['schedule', when.schedule], ...Object.entries(when.each || {}).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))];
1042
+ for (const [label, sched] of times) {
1043
+ if (!sched?.error) continue;
1044
+ const key = `${slug}/${label}/${sched.expr}`;
1045
+ if (checksWarned.has(key)) continue;
1046
+ checksWarned.add(key);
1047
+ const who = label === 'schedule' ? 'checks schedule' : `check "${label}"`;
1048
+ eprintln(color.yellow(`· ${slug}, ${who}: ${sched.error}`));
1049
+ }
1050
+ }
1036
1051
  /** When a project's checks last ended here: what a run writes is not a change. */
1037
1052
  export const checksEndedAt = new Map();
1038
1053
  const SETTLE_MS = 10_000;
@@ -1046,6 +1061,7 @@ export function checksTick(root, nowMs = Date.now(), spawn = spawnJob) {
1046
1061
  const dir = path.join(base, slug);
1047
1062
  if (!fs.existsSync(path.join(dir, 'checks.py'))) continue;
1048
1063
  const when = checksWhen(dir);
1064
+ warnCheckTimes(slug, when);
1049
1065
  const prints = projectPrints(dir);
1050
1066
  const off = checksOff(dir);
1051
1067
  let s = checksSeen.get(slug);
package/src/config.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
4
 
5
- export const VERSION = '0.100.0';
5
+ export const VERSION = '0.100.2';
6
6
  export const DEFAULT_BASE_URL = 'https://atlasmcp.finmanagerai.com';
7
7
  export const DEFAULT_SCOPE = 'atlas broker';
8
8
  export const CLIENT_NAME = 'Atlas CLI';
@@ -416,26 +416,51 @@ export async function mcpTools({ server = "", search = "" } = {}) {
416
416
  * the payload (see phonePress()). `done: true` finishes a card that is up. At
417
417
  * most 30 new ones an hour, so say what matters, not every step.
418
418
  *
419
+ * A tap on it opens this project in Local Mode, on this computer. `view` picks
420
+ * its tab (files, checks, dashboard, chats, computer). `open` sends the tap
421
+ * anywhere else on the dashboard instead: a name such as notifications,
422
+ * alerts, workflow:<id>:performance or project:<folder>:checks, or a path
423
+ * starting /atlas/dashboard?. The answer's `opens` says where it will go.
424
+ *
419
425
  * await notify("Backtest done", { body: "SPY: 61% win\n214 trades", key: "backtest" });
420
426
  * await notify("Backtest running", { key: "backtest", progress: 0.4 });
421
427
  * await notify("Backtest done", { key: "backtest", button: "Run on QQQ" });
428
+ * await notify("Charts ready", { key: "charts", view: "dashboard" });
429
+ * await notify("Alert fired", { open: "alerts" });
422
430
  *
423
431
  * Returns the tool's answer. `sent` is 0 when the owner switched these off
424
432
  * (Profile, Manage notifications, Studio); that is their choice, not an error.
425
433
  */
426
- export async function notify(title, { body = "", key = "", progress = null, button = "", done = false } = {}) {
434
+ export async function notify(title, { body = "", key = "", progress = null, button = "", done = false, open = "", view = "" } = {}) {
427
435
  const params = { title, body: body || "" };
428
436
  if (key) params.key = key;
429
437
  if (progress !== null && progress !== undefined) params.progress = progress;
430
- if (button) {
431
- params.button = button;
432
- // The project the button starts: this one.
433
- params.project = process.env.ATLAS_JOB || "";
434
- }
438
+ if (button) params.button = button;
435
439
  if (done) params.done = true;
440
+ // This project: the one the button starts, and where a tap opens.
441
+ const job = process.env.ATLAS_JOB || "";
442
+ if (job || button) params.project = job;
443
+ if (job) {
444
+ const machine = machineHere();
445
+ if (machine) params.machine = machine;
446
+ if (view) params.view = view;
447
+ }
448
+ if (open) params.open = open;
436
449
  return atlas("phone-notify", params);
437
450
  }
438
451
 
452
+ /** This computer's id, as the atlas command line keeps it ("" if none). */
453
+ function machineHere() {
454
+ let found = (process.env.ATLAS_MACHINE || "").trim();
455
+ if (!found) {
456
+ const dir = process.env.ATLAS_CONFIG_DIR
457
+ || (process.env.XDG_CONFIG_HOME ? path.join(process.env.XDG_CONFIG_HOME, "atlas") : path.join(os_.homedir(), ".config", "atlas"));
458
+ try { found = fs.readFileSync(path.join(dir, "machine-id"), "utf8").trim(); } catch { found = ""; }
459
+ }
460
+ found = found.slice(0, 64);
461
+ return /^[\w-]+$/.test(found) ? found : "";
462
+ }
463
+
439
464
  /**
440
465
  * The button press this is about, or null.
441
466
  *
@@ -568,7 +568,24 @@ def mcp_tools(server="", search=""):
568
568
  return atlas("mcp-tools", server=server or "", search=search or "")
569
569
 
570
570
 
571
- def notify(title, body="", key="", progress=None, button="", done=False):
571
+ def _machine_here():
572
+ """This computer's id, as the atlas command line keeps it ('' if none)."""
573
+ if os.environ.get("ATLAS_MACHINE"):
574
+ found = os.environ["ATLAS_MACHINE"].strip()
575
+ else:
576
+ base = os.environ.get("ATLAS_CONFIG_DIR") or (
577
+ os.path.join(os.environ["XDG_CONFIG_HOME"], "atlas") if os.environ.get("XDG_CONFIG_HOME")
578
+ else os.path.join(os.path.expanduser("~"), ".config", "atlas"))
579
+ try:
580
+ with open(os.path.join(base, "machine-id"), encoding="utf-8") as f:
581
+ found = f.read().strip()
582
+ except Exception:
583
+ found = ""
584
+ found = found[:64]
585
+ return found if found and all(c.isascii() and (c.isalnum() or c in "_-") for c in found) else ""
586
+
587
+
588
+ def notify(title, body="", key="", progress=None, button="", done=False, open="", view=""):
572
589
  """Tell the owner's PHONE (the MindVest Atlas app). A fixed layout you fill in.
573
590
 
574
591
  A title (cut at 40 characters) and up to two short lines; plain words. The
@@ -578,9 +595,17 @@ def notify(title, body="", key="", progress=None, button="", done=False):
578
595
  in the payload (see phone_press()). `done=True` finishes a card that is up.
579
596
  At most 30 new ones an hour, so say what matters, not every step.
580
597
 
598
+ A tap on it opens this project in Local Mode, on this computer. `view`
599
+ picks its tab (files, checks, dashboard, chats, computer). `open` sends the
600
+ tap anywhere else on the dashboard instead: a name such as notifications,
601
+ alerts, workflow:<id>:performance or project:<folder>:checks, or a path
602
+ starting /atlas/dashboard?. The answer's `opens` says where it will go.
603
+
581
604
  notify("Backtest done", "SPY: 61% win\n214 trades", key="backtest")
582
605
  notify("Backtest running", key="backtest", progress=0.4)
583
606
  notify("Backtest done", "Run it on QQQ too?", key="backtest", button="Run on QQQ")
607
+ notify("Charts ready", key="charts", view="dashboard")
608
+ notify("Alert fired", open="alerts")
584
609
 
585
610
  Returns the tool's answer. `sent` is 0 when the owner switched these off
586
611
  (Profile, Manage notifications, Studio); that is their choice, not an error.
@@ -592,10 +617,20 @@ def notify(title, body="", key="", progress=None, button="", done=False):
592
617
  params["progress"] = progress
593
618
  if button:
594
619
  params["button"] = button
595
- # The project the button starts: this one.
596
- params["project"] = os.environ.get("ATLAS_JOB", "")
597
620
  if done:
598
621
  params["done"] = True
622
+ # This project: the one the button starts, and where a tap opens.
623
+ job = os.environ.get("ATLAS_JOB", "")
624
+ if job or button:
625
+ params["project"] = job
626
+ if job:
627
+ machine = _machine_here()
628
+ if machine:
629
+ params["machine"] = machine
630
+ if view:
631
+ params["view"] = view
632
+ if open:
633
+ params["open"] = open
599
634
  return atlas("phone-notify", **params)
600
635
 
601
636
 
@@ -1144,12 +1179,13 @@ class _CheckRun:
1144
1179
  data["running"] = None
1145
1180
  data["last_run"] = {"id": self.id, "at": _time.strftime("%Y-%m-%dT%H:%M:%S", _time.localtime()),
1146
1181
  "took_s": round(_time.time() - self.started, 1), "replay_of": self.replay_of,
1147
- "model": self.model, "error": msg}
1182
+ "model": self.model, "error": msg,
1183
+ "only": self.only or None, "skip": self._skipped()}
1148
1184
  _write_json(path, data)
1149
1185
  _history_add({
1150
1186
  "kind": "run", "id": self.id, "at": data["last_run"]["at"], "why": self.why,
1151
1187
  "replay_of": self.replay_of, "model": self.model, "took_s": data["last_run"]["took_s"],
1152
- "error": msg, "only": self.only or None, "checks": self._results,
1188
+ "error": msg, "only": self.only or None, "skip": self._skipped(), "checks": self._results,
1153
1189
  })
1154
1190
  if self.why in _UNATTENDED:
1155
1191
  conf = _notify_conf()
@@ -1179,6 +1215,10 @@ class _CheckRun:
1179
1215
  "id": self.id,
1180
1216
  "replay_of": self.replay_of,
1181
1217
  "only": self.only or None,
1218
+ # WHAT THIS RUN COVERS (2026-10-17): a run on the project's
1219
+ # schedule leaves out the checks whose own time it is not, so the
1220
+ # screens show only the others running.
1221
+ "skip": self._skipped(),
1182
1222
  "step": self._current[0] if self._current else "Starting",
1183
1223
  "done": len(steps),
1184
1224
  "total": self._total,
@@ -1322,6 +1362,11 @@ class _CheckRun:
1322
1362
  except Exception:
1323
1363
  return ""
1324
1364
 
1365
+ def _skipped(self):
1366
+ """The checks this run leaves out (a run with `only` names what it
1367
+ covers instead), or None."""
1368
+ return (list(self.skip) or None) if not self.only else None
1369
+
1325
1370
  # ── results ──
1326
1371
  def wants(self, check_id):
1327
1372
  """False for a check this run was not asked for (Recheck of one, or a
@@ -1413,7 +1458,7 @@ class _CheckRun:
1413
1458
  data = _read_json(path, {})
1414
1459
  data["last_run"] = {"id": self.id, "at": _time.strftime("%Y-%m-%dT%H:%M:%S", _time.localtime()),
1415
1460
  "took_s": round(_time.time() - self.started, 1), "replay_of": self.replay_of,
1416
- "model": self.model}
1461
+ "model": self.model, "only": self.only or None, "skip": self._skipped()}
1417
1462
  data["running"] = None
1418
1463
  _write_json(path, data)
1419
1464
  self._finished = True
@@ -1421,7 +1466,7 @@ class _CheckRun:
1421
1466
  _history_add({
1422
1467
  "kind": "run", "id": self.id, "at": data["last_run"]["at"], "why": self.why,
1423
1468
  "replay_of": self.replay_of, "model": self.model, "took_s": data["last_run"]["took_s"],
1424
- "checks": self._results,
1469
+ "only": self.only or None, "skip": self._skipped(), "checks": self._results,
1425
1470
  })
1426
1471
  # NOBODY WAS WATCHING (its schedule, a change, a fix): what the
1427
1472
  # project's Notifications tab asks for goes to the person's phone, per
@@ -1507,7 +1552,8 @@ def _checks_notify(conf, told, line, why, project):
1507
1552
  title = template.replace("{project}", project).replace("{count}", str(len(told))).replace("{what}", what)
1508
1553
  when = "%s, %s" % (_WHY_WORDS.get(why, "Ran"), _time.strftime("%H:%M"))
1509
1554
  try:
1510
- notify(title[:40], "%s\n%s" % (line, when), key="checks-%s" % project)
1555
+ # A tap opens this project's Checks, on this computer.
1556
+ notify(title[:40], "%s\n%s" % (line, when), key="checks-%s" % project, view="checks")
1511
1557
  except Exception:
1512
1558
  pass
1513
1559
 
@@ -261,39 +261,121 @@ export function readManifest(dir) {
261
261
  // ticker writes a "Computer was off" row for any gap it finds when it wakes.
262
262
 
263
263
  const CRON_RANGES = [[0, 59], [0, 23], [1, 31], [1, 12], [0, 7]];
264
+ const CRON_FIELD_WORDS = ['minutes', 'hours', 'days of the month', 'months', 'days of the week'];
265
+ const MONTH_NAMES = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
266
+ const DAY_NAMES = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
267
+ /** The most days each month can have (February: 29, in a leap year). */
268
+ const MONTH_DAYS = [0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
269
+ export const CRON_MACROS = {
270
+ '@yearly': '0 0 1 1 *', '@annually': '0 0 1 1 *', '@monthly': '0 0 1 * *', '@weekly': '0 0 * * 0',
271
+ '@daily': '0 0 * * *', '@midnight': '0 0 * * *', '@hourly': '0 * * * *',
272
+ };
264
273
 
265
- /**
266
- * Parse a five-field cron line into sets, or null when it is not one.
267
- *
268
- * Supports what people actually write: `*`, `n`, `a-b`, `* /n` (no space),
269
- * `a-b/n` and comma lists. Day-of-week 7 is Sunday like 0. No names.
270
- */
271
- export function parseCron(expr) {
272
- const parts = String(expr || '').trim().split(/\s+/);
273
- if (parts.length !== 5) return null;
274
- const fields = [];
275
- for (let i = 0; i < 5; i += 1) {
276
- const set = new Set();
277
- for (const piece of parts[i].split(',')) {
278
- const m = /^(\*|\d+(?:-\d+)?)(?:\/(\d+))?$/.exec(piece);
279
- if (!m) return null;
280
- let [lo, hi] = CRON_RANGES[i];
281
- if (m[1] !== '*') {
282
- const [a, b] = m[1].split('-').map(Number);
283
- lo = a; hi = b === undefined ? a : b;
284
- }
285
- const step = m[2] ? Number(m[2]) : 1;
286
- if (!(step > 0) || lo < CRON_RANGES[i][0] || hi > CRON_RANGES[i][1] || lo > hi) return null;
287
- for (let v = lo; v <= hi; v += step) set.add(i === 4 && v === 7 ? 0 : v);
274
+ function cronValue(v, i, rangeEnd) {
275
+ if (/^\d{1,2}$/.test(v)) {
276
+ const n = Number(v);
277
+ return n >= CRON_RANGES[i][0] && n <= CRON_RANGES[i][1] ? n : null;
278
+ }
279
+ const name = v.toLowerCase();
280
+ if (i === 3 && MONTH_NAMES.includes(name)) return MONTH_NAMES.indexOf(name) + 1;
281
+ // SUN closing a range is the week's last day (FRI-SUN), else its first.
282
+ if (i === 4 && DAY_NAMES.includes(name)) return name === 'sun' && rangeEnd ? 7 : DAY_NAMES.indexOf(name);
283
+ return null;
284
+ }
285
+
286
+ /** One field as its sorted values, or why it is not one (a string). */
287
+ function cronField(text, i) {
288
+ const [lo, hi] = CRON_RANGES[i];
289
+ const what = CRON_FIELD_WORDS[i];
290
+ const out = new Set();
291
+ for (const piece of text.split(',')) {
292
+ const m = /^(\*|\?|[A-Za-z0-9]+(?:-[A-Za-z0-9]+)?)(?:\/(\d+))?$/.exec(piece);
293
+ if (!m) return `"${piece}" is not one of the ${what}.`;
294
+ if (m[1] === '?' && i !== 2 && i !== 4) return `"?" only stands in a day field, not in the ${what}.`;
295
+ let a = lo;
296
+ let b = hi;
297
+ if (m[1] !== '*' && m[1] !== '?') {
298
+ const [x, y] = m[1].split('-');
299
+ const va = cronValue(x, i, false);
300
+ if (va === null) return `"${x}" is not one of the ${what} (${lo} to ${hi}).`;
301
+ const vb = y === undefined ? null : cronValue(y, i, true);
302
+ if (y !== undefined && vb === null) return `"${y}" is not one of the ${what} (${lo} to ${hi}).`;
303
+ a = va;
304
+ b = vb ?? (m[2] ? hi : va);
305
+ if (a > b) return `"${piece}" runs backwards: write the smaller first.`;
288
306
  }
289
- fields.push(set);
307
+ const step = m[2] === undefined ? 1 : Number(m[2]);
308
+ if (!(step >= 1 && step <= hi)) return `A step of ${m[2]} does not fit the ${what} (1 to ${hi}).`;
309
+ for (let v = a; v <= b; v += step) out.add(v);
310
+ }
311
+ return [...out].sort((x, y) => x - y);
312
+ }
313
+
314
+ /**
315
+ * READ A CRON LINE: `{ cron }` as sets, or `{ why }` in a sentence an agent
316
+ * can act on. The ONE reading, shared word for word with Atlas Studio and the
317
+ * web (their @shared/checks readCron) and line for line with the python
318
+ * runner's read_cron (owner, 2026-10-17: "the AI stores in cron, so you need to
319
+ * parse really well"):
320
+ *
321
+ * - five fields: minute 0-59, hour 0-23, day of the month 1-31, month 1-12 or
322
+ * JAN-DEC, day of the week 0-7 or SUN-SAT (0 and 7 are Sunday);
323
+ * - `*`, a number or name, `a-b`, `* /n` (no space), `a-b/n`, `a/n` (a to the
324
+ * field's end) and comma lists of those; `?` is `*` in the two day fields; a
325
+ * range ending in SUN ends on Sunday (FRI-SUN);
326
+ * - @hourly, @daily (@midnight), @weekly, @monthly, @yearly (@annually);
327
+ * - BOTH DAY FIELDS LIMITED (neither covers every day): a day matching either
328
+ * runs, the standard cron rule;
329
+ * - refused: six or seven fields, out of range, backwards, a step of 0 or past
330
+ * the field's end, L / W / #, @reboot, a day that never comes in its months.
331
+ */
332
+ export function readCron(expr) {
333
+ let line = String(expr ?? '').trim();
334
+ if (!line) return { cron: null, why: 'It is empty.' };
335
+ if (line.startsWith('@')) {
336
+ const m = CRON_MACROS[line.toLowerCase()];
337
+ if (!m) {
338
+ return { cron: null, why: line.toLowerCase() === '@reboot' ? '@reboot runs at start-up, not at a time.'
339
+ : `${line} is not one of @hourly, @daily, @weekly, @monthly or @yearly.` };
340
+ }
341
+ line = m;
342
+ }
343
+ const parts = line.split(/\s+/);
344
+ if (parts.length !== 5) {
345
+ return { cron: null, why: `It has ${parts.length} ${parts.length === 1 ? 'part' : 'parts'}; a schedule has 5: minute, hour, day of the month, month, day of the week${parts.length > 5 ? ' (no seconds, no year)' : ''}.` };
346
+ }
347
+ const sets = [];
348
+ for (let i = 0; i < 5; i += 1) {
349
+ const r = cronField(parts[i], i);
350
+ if (typeof r === 'string') return { cron: null, why: r };
351
+ sets.push(r);
352
+ }
353
+ const [minutes, hours, days, months, week] = sets;
354
+ const weekdays = [...new Set(week.map((d) => d % 7))].sort((a, b) => a - b);
355
+ const domStar = days.length === 31;
356
+ const dowStar = weekdays.length === 7;
357
+ if (dowStar && !domStar && !months.some((mo) => days.some((d) => d <= MONTH_DAYS[mo]))) {
358
+ return { cron: null, why: 'That day never comes in those months.' };
290
359
  }
291
360
  return {
292
- minute: fields[0], hour: fields[1], dom: fields[2], month: fields[3], dow: fields[4],
293
- domStar: parts[2] === '*', dowStar: parts[4] === '*',
361
+ cron: {
362
+ minute: new Set(minutes), hour: new Set(hours), dom: new Set(days), month: new Set(months), dow: new Set(weekdays),
363
+ domStar, dowStar,
364
+ },
365
+ why: null,
294
366
  };
295
367
  }
296
368
 
369
+ /** A five-field cron line (or an @word) as sets, or null when it is not one (readCron says why). */
370
+ export function parseCron(expr) {
371
+ return readCron(expr).cron;
372
+ }
373
+
374
+ /** Why a cron line is not a schedule, or null when it is one. */
375
+ export function cronProblem(expr) {
376
+ return readCron(expr).why;
377
+ }
378
+
297
379
  const DOW = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
298
380
 
299
381
  /** The wall-clock fields of a moment in a time zone (or this machine's). */
@@ -563,7 +645,7 @@ export function readSchedule(dir) {
563
645
  if (once) { lines.push({ kind: 'once', once, text: line }); continue; }
564
646
  const cron = parseCron(line);
565
647
  if (cron) { lines.push({ kind: 'cron', cron, text: line }); continue; }
566
- bad = bad || `schedule "${line}" is not something I understand - use a cron `
648
+ bad = bad || `schedule "${line}" is not something I understand (${cronProblem(line)}) - use a cron `
567
649
  + 'line like "*/5 9-16 * * 1-5" or "once 2026-09-15 09:30"';
568
650
  }
569
651
  if (!lines.length && !bad) bad = `schedule "${expr}" has nothing in it`;
@@ -699,14 +781,19 @@ export function checksWhen(dir) {
699
781
  // The zone it is read in: the checks' own, else the project's, else UTC like
700
782
  // every schedule (Atlas Studio and the web say it in these words too).
701
783
  const tz = String([o.timezone, m.timezone].find((z) => typeof z === 'string' && z.trim()) || '').trim();
784
+ // A line that is not a valid cron is KEPT, with why: it never runs (the
785
+ // screens say "Not a valid schedule"), and the runner says why once.
702
786
  const sched = (raw) => {
703
787
  const expr = typeof raw === 'string' ? raw.trim() : '';
704
- const cron = expr ? parseCron(expr) : null;
705
- return cron ? { expr, lines: [{ kind: 'cron', cron, text: expr }], tz, error: null, cron } : null;
788
+ if (!expr) return null;
789
+ const { cron, why } = readCron(expr);
790
+ return cron ? { expr, lines: [{ kind: 'cron', cron, text: expr }], tz, error: null, cron }
791
+ : { expr, lines: [], tz, error: `"${expr}" is not a valid schedule: ${why}`, cron: null };
706
792
  };
707
793
  // A TIME PER CHECK (owner, 2026-10-16: "for each checks, let this be here"):
708
794
  // "each": {"<check id>": "<five-field cron>"}, read in the same zone. A check
709
- // with no entry (or one that is not a cron) keeps the project's schedule.
795
+ // with no entry keeps the project's schedule; one whose entry is not a valid
796
+ // cron runs on no time at all.
710
797
  const each = {};
711
798
  const e = o.each && typeof o.each === 'object' && !Array.isArray(o.each) ? o.each : {};
712
799
  for (const [id, raw] of Object.entries(e).slice(0, 200)) {