mindvest-atlas 0.14.2 → 0.14.3

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.14.2",
3
+ "version": "0.14.3",
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": {
@@ -9,7 +9,7 @@
9
9
  // A job that could NOT run is reported too: silence is indistinguishable from
10
10
  // a schedule that quietly broke, which is the failure the record exists to make
11
11
  // visible.
12
- import { spawn } from 'node:child_process';
12
+ import { spawn, spawnSync } from 'node:child_process';
13
13
  import { API, baseUrl } from '../config.js';
14
14
  import { requestJson, jobToken } from '../api/client.js';
15
15
  import { runStream } from '../util/stream-loop.js';
@@ -17,8 +17,9 @@ import { color, eprintln } from '../util/ui.js';
17
17
  import {
18
18
  DEFAULT_TIMEOUT_MS, claimRun, computerName, isPaused, jobEnv, listJobFiles, listRuns, machineId,
19
19
  osLabel, readJobFile, releaseRun, staleness, summarize, tail, workspace, workspaceRoot,
20
- setPaused, writeHelpers, askModel, listModels, chatModel,
20
+ setPaused, writeHelpers, askModel, listModels, chatModel, answerPermission,
21
21
  createProject, ensureProjectFiles, listChats, readChat, deleteChat, appendChat,
22
+ readSchedule, cronMatches, dueMinutes, SCRIPTS_DIR, projectsForAlert,
22
23
  } from '../remote-control.js';
23
24
  import fs from 'node:fs';
24
25
  import os from 'node:os';
@@ -67,12 +68,44 @@ async function report() {
67
68
  return undefined;
68
69
  }
69
70
 
71
+ /** The run going on in each project right now, by folder name, so Stop can
72
+ * reach it. One run per project means one entry per project. */
73
+ const running = new Map();
74
+
75
+ /** Kill a run's whole process tree. On Windows a plain kill leaves the
76
+ * script's own children going; taskkill /T takes the tree. */
77
+ function killTree(child) {
78
+ if (!child || child.pid === undefined) return;
79
+ if (process.platform === 'win32') {
80
+ try { spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { windowsHide: true }); return; } catch { /* fall through */ }
81
+ }
82
+ try { child.kill('SIGTERM'); } catch { /* already gone */ }
83
+ const pid = child.pid;
84
+ setTimeout(() => { try { process.kill(pid, 0); process.kill(pid, 'SIGKILL'); } catch { /* gone */ } }, 3_000).unref();
85
+ }
86
+
87
+ /** Stop the run in one project, if there is one. Returns whether there was. */
88
+ export function stopRun(slug) {
89
+ const entry = running.get(slug);
90
+ if (!entry) return false;
91
+ entry.stopped = true;
92
+ killTree(entry.child);
93
+ return true;
94
+ }
95
+
70
96
  function runOnce(argv, dir, env, timeoutMs) {
71
97
  return new Promise((resolve) => {
72
98
  let out = '';
73
99
  let done = false;
74
100
  const child = spawn(argv[0], argv.slice(1), { cwd: dir, env });
75
- const finish = (r) => { if (!done) { done = true; resolve(r); } };
101
+ const entry = { child, stopped: false };
102
+ running.set(path.basename(dir), entry);
103
+ const finish = (r) => {
104
+ if (done) return;
105
+ done = true;
106
+ running.delete(path.basename(dir));
107
+ resolve(entry.stopped ? { stopped: true, out } : r);
108
+ };
76
109
  const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ }
77
110
  finish({ timedOut: true, out }); }, timeoutMs);
78
111
  const cap = (c) => { out += String(c); if (out.length > 200_000) out = out.slice(-200_000); };
@@ -124,6 +157,13 @@ async function handleAsk(ask, root) {
124
157
  return answer(ask.request_id, { ok: true, paused: setPaused(dir, Boolean(ask.paused)) });
125
158
  }
126
159
  if (ask.op === 'runs') return answer(ask.request_id, { ok: true, runs: listRuns(root) });
160
+ if (ask.op === 'stop') {
161
+ // Stop replaces Pause on the web (owner, 2026-09-03): what a person wants
162
+ // from the button is the run going right now to end, not a flag for later.
163
+ const { dir } = workspace(ask.job || '', root);
164
+ const had = stopRun(path.basename(dir));
165
+ return answer(ask.request_id, had ? { ok: true, stopped: true } : { ok: true, stopped: false, error: 'Nothing is running.' });
166
+ }
127
167
  if (ask.op === 'read') return answer(ask.request_id, readJobFile(ask.job || '', ask.path || '', root));
128
168
  if (ask.op === 'chat') {
129
169
  // A person typing at the web, talking to the model on this machine. Each
@@ -160,6 +200,14 @@ async function handleAsk(ask, root) {
160
200
  });
161
201
  return undefined;
162
202
  }
203
+ if (ask.op === 'permission') {
204
+ // The person's answer to a question the model asked mid-turn. Its own ask,
205
+ // not part of the chat stream: the stream is one-way. `permission_id` is
206
+ // the question; `request_id` is this ask, which is what gets the ack.
207
+ void answerPermission(ask.permission_id || '', ask.option_id ?? null)
208
+ .then((r) => answer(ask.request_id, r));
209
+ return undefined;
210
+ }
163
211
  if (ask.op === 'create') return answer(ask.request_id, createProject(ask.job || '', root));
164
212
  if (ask.op === 'chats') return answer(ask.request_id, { ok: true, chats: listChats(ask.job || '', root) });
165
213
  if (ask.op === 'chat_read') return answer(ask.request_id, readChat(ask.job || '', ask.chat_id || '', root));
@@ -286,6 +334,13 @@ async function runJob(job, root, timeoutMs) {
286
334
  };
287
335
  const r = await runOnce(argv, dir, env, timeoutMs);
288
336
  const finished = Date.now();
337
+ if (r.stopped) {
338
+ // The owner pressed Stop. Not a failure: they asked, it stopped.
339
+ eprintln(color.yellow(`■ ${name} — stopped`));
340
+ writeLog(dir, job, 'stopped', started, finished, `Stopped by you.\n${tail(r.out) || ''}`);
341
+ releaseRun(dir);
342
+ return report(job, 'stopped', started, finished);
343
+ }
289
344
  if (r.timedOut) {
290
345
  eprintln(color.red(`✗ ${name} — timed out`));
291
346
  writeLog(dir, job, 'failed', started, finished,
@@ -307,6 +362,123 @@ async function runJob(job, root, timeoutMs) {
307
362
  return report(job, ok ? 'completed' : 'failed', started, finished);
308
363
  }
309
364
 
365
+ /**
366
+ * The local clock (owner decision 2026-09-03: schedule on the computer, not the
367
+ * server).
368
+ *
369
+ * Every 20s, for each project whose atlas.json names a `schedule`, look at the
370
+ * minutes since this project was last checked. The current minute firing starts
371
+ * a run through the same path a queued job takes (one run per project, same
372
+ * logs, same paused rule). Any EARLIER minute that fired while nobody was
373
+ * checking — the app was closed, the laptop asleep — is written down as one
374
+ * "Computer was off" row, because a schedule that misses in silence is the one
375
+ * failure the owner can never see. The last-checked minute lives in
376
+ * runs/.schedule so a restart knows what it slept through.
377
+ */
378
+ const SCHEDULE_STATE = '.schedule';
379
+ const TICK_MS = 20_000;
380
+ const warnedSchedules = new Set();
381
+
382
+ function lastChecked(dir) {
383
+ try {
384
+ const n = parseInt(fs.readFileSync(path.join(dir, 'runs', SCHEDULE_STATE), 'utf8'), 10);
385
+ return Number.isFinite(n) ? n : null;
386
+ } catch { return null; }
387
+ }
388
+
389
+ function markChecked(dir, minute) {
390
+ try {
391
+ fs.mkdirSync(path.join(dir, 'runs'), { recursive: true });
392
+ fs.writeFileSync(path.join(dir, 'runs', SCHEDULE_STATE), `${minute}\n`);
393
+ } catch { /* the next tick tries again */ }
394
+ }
395
+
396
+ export function scheduleTick(root, timeoutMs, nowSec = Date.now() / 1000) {
397
+ const base = path.join(workspaceRoot(root), SCRIPTS_DIR);
398
+ let dirs = [];
399
+ try {
400
+ dirs = fs.readdirSync(base, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
401
+ } catch { return; }
402
+ const now = Math.floor(nowSec / 60) * 60;
403
+ for (const slug of dirs) {
404
+ const dir = path.join(base, slug);
405
+ const sched = readSchedule(dir);
406
+ if (!sched) continue;
407
+ if (sched.error) {
408
+ if (!warnedSchedules.has(slug)) {
409
+ warnedSchedules.add(slug);
410
+ eprintln(color.yellow(`· ${slug} — ${sched.error}`));
411
+ }
412
+ continue;
413
+ }
414
+ const last = lastChecked(dir);
415
+ if (last !== null && now <= last) continue; // this minute is already handled
416
+ markChecked(dir, now);
417
+ const job = { automation_id: slug, name: slug, source: 'schedule', scheduled_for_epoch: now };
418
+ if (last === null) {
419
+ // First sight of this schedule: start from now. Nothing before it is a
420
+ // gap, because nothing before it was promised.
421
+ if (cronMatches(sched.cron, now, sched.tz)) spawnJob(job, root, timeoutMs);
422
+ continue;
423
+ }
424
+ const due = dueMinutes(sched.cron, last, now, sched.tz);
425
+ const missed = due.filter((t) => t !== now);
426
+ if (missed.length && !isPaused(dir)) {
427
+ const first = missed[0] * 1000;
428
+ const lastMissed = missed[missed.length - 1] * 1000;
429
+ eprintln(color.yellow(`· ${slug} — ${missed.length} scheduled run(s) missed while this was not running`));
430
+ writeLog(dir, job, 'skipped_offline', first, lastMissed,
431
+ `Computer was off or asleep. ${missed.length} run(s) due between `
432
+ + `${iso(first)} and ${iso(lastMissed)} did not happen.\n`);
433
+ }
434
+ if (due.includes(now)) spawnJob(job, root, timeoutMs);
435
+ }
436
+ }
437
+
438
+ function startLocalSchedule(root, timeoutMs) {
439
+ scheduleTick(root, timeoutMs);
440
+ const t = setInterval(() => scheduleTick(root, timeoutMs), TICK_MS);
441
+ t.unref?.();
442
+ return () => clearInterval(t);
443
+ }
444
+
445
+ /**
446
+ * The owner's alerts, as a trigger (owner decision 2026-09-03: triggers kept
447
+ * local, like the schedule).
448
+ *
449
+ * This computer already holds one outbound stream for jobs; this is a second,
450
+ * to the same alert feed `atlas alerts` shows, with charts left out. When an
451
+ * alert fires, every project whose atlas.json `on_alert` names it runs through
452
+ * the same path a queued job takes, with the whole alert event as the job's
453
+ * payload — so a script can read what fired and ask the model what to do about
454
+ * it. Nothing is registered on the server: the feed was always the owner's.
455
+ */
456
+ export function onAlertEvent(event, root, timeoutMs, spawn = spawnJob) {
457
+ if (!event || typeof event !== 'object') return [];
458
+ const slugs = projectsForAlert(root, event);
459
+ const now = Math.floor(Date.now() / 1000);
460
+ for (const slug of slugs) {
461
+ eprintln(color.dim(`⚡ ${event.label || event.name || 'alert'}${event.symbol ? ` ${event.symbol}` : ''} → ${slug}`));
462
+ spawn({ automation_id: slug, name: slug, source: 'alert', scheduled_for_epoch: now,
463
+ payload: { alert: event } }, root, timeoutMs);
464
+ }
465
+ return slugs;
466
+ }
467
+
468
+ function startAlertListener(root, timeoutMs) {
469
+ // Deliberately NOT awaited: it reconnects on its own for as long as the
470
+ // process lives, beside the jobs stream, and a computer with no on_alert
471
+ // projects costs the server one idle subscriber.
472
+ void runStream(`${API.alerts}?chart=0`, {
473
+ label: 'alerts',
474
+ reconnect: true,
475
+ onEvent: ({ event, payload }) => {
476
+ if (event === 'meta' || event === 'error') return;
477
+ onAlertEvent(payload, root, timeoutMs);
478
+ },
479
+ }).catch((err) => eprintln(color.dim(`● alert feed: ${err.message}`)));
480
+ }
481
+
310
482
  export async function remoteControlCommand(args) {
311
483
  const flags = args.flags;
312
484
  const root = typeof flags.dir === 'string' ? flags.dir : undefined;
@@ -315,6 +487,13 @@ export async function remoteControlCommand(args) {
315
487
  eprintln(color.dim(`${computerName()} — Atlas can run jobs here. Workspace: ${workspaceRoot(root)}`));
316
488
  eprintln(color.dim('Nothing runs while this is closed. Ctrl-C to stop.'));
317
489
 
490
+ // The schedules are kept HERE, on this computer, and keep going whether or
491
+ // not the server can be reached: the stream below only carries manual runs
492
+ // and the web's questions.
493
+ const stopSchedule = startLocalSchedule(root, timeoutMs);
494
+ process.once('exit', stopSchedule);
495
+ startAlertListener(root, timeoutMs);
496
+
318
497
  // Identity is the machine id; the name and OS are how it is DISPLAYED. Two
319
498
  // processes on one computer therefore share one row instead of splitting it.
320
499
  const q = new URLSearchParams({ computer: computerName(), machine_id: machineId(), os: osLabel() });
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.14.2';
5
+ export const VERSION = '0.14.3';
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';
@@ -0,0 +1,154 @@
1
+ // Secrets for this job. Written by Atlas — edits are overwritten.
2
+ import fs from "node:fs";
3
+ //
4
+ // Values come from the environment Atlas set up before starting this script. Do
5
+ // NOT open .env: this module is the supported way to reach a secret, and the
6
+ // file itself is not something a script or a model needs to read.
7
+
8
+ /** The value of one secret, for USE — never for printing or logging. */
9
+ export const secret = (name, fallback) => process.env[name] ?? fallback;
10
+
11
+ /** Whether a secret has been filled in. True/false only, never the value. */
12
+ export const hasSecret = (name) => Boolean(process.env[name]);
13
+
14
+ /**
15
+ * Call an Atlas tool over the REST API. Returns the parsed JSON.
16
+ *
17
+ * This is the way to do anything real — quotes, chains, orders, alerts. The
18
+ * `atlas` CLI shells out a whole process per call and hands back text to
19
+ * re-parse; this is one HTTPS request that returns data, which is what you
20
+ * actually want inside a loop over twenty tickers.
21
+ *
22
+ * The token and base URL are already in this job's environment. Do not read
23
+ * them, print them, or pass them anywhere else.
24
+ *
25
+ * const quote = await atlas("stock-quote", { symbol: "SPY" });
26
+ */
27
+ export async function atlas(tool, params = {}) {
28
+ const base = (process.env.ATLAS_BASE_URL || "https://atlasmcp.finmanagerai.com").replace(/\/+$/, "");
29
+ const token = process.env.ATLAS_TOKEN;
30
+ if (!token) throw new Error("This job has no Atlas token. Is Local Mode signed in?");
31
+ const res = await fetch(`${base}/api/v1/tools/${tool}`, {
32
+ method: "POST",
33
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
34
+ body: JSON.stringify(params),
35
+ });
36
+ if (!res.ok) throw new Error(`Atlas ${tool} failed (${res.status}): ${(await res.text()).slice(0, 500)}`);
37
+ return res.json();
38
+ }
39
+
40
+ /**
41
+ * Ask YOUR OWN model a question and get its answer back as text.
42
+ *
43
+ * For the steps only a model can do: reading a filing and pulling out the
44
+ * guidance, judging whether a headline is about the company or its sector,
45
+ * summarising what changed since yesterday. Everything else - fetching,
46
+ * counting, comparing, placing orders - is faster and more reliable as plain
47
+ * code, so use this for judgement, not for arithmetic.
48
+ *
49
+ * It runs on this computer, through the model already connected in Atlas
50
+ * Remote Control. The prompt never reaches Atlas.
51
+ *
52
+ * const verdict = await ask(`Is this headline about AAPL itself or the sector?\n${line}`);
53
+ *
54
+ * tools:true lets the model reach Atlas data while it answers; false is faster
55
+ * for pure text. fresh:false keeps ONE conversation for this job (the model
56
+ * starts up once, later questions can refer to earlier ones); fresh:true is for
57
+ * independent questions that should not pile into one context.
58
+ */
59
+ export async function ask(prompt, { tools = true, fresh = false, timeoutMs = 600000 } = {}) {
60
+ const url = process.env.ATLAS_ASK_URL;
61
+ const token = process.env.ATLAS_ASK_TOKEN;
62
+ if (!url || !token) {
63
+ throw new Error(
64
+ "No model is connected on this computer, so this script cannot ask one. " +
65
+ "Connect a model in Atlas Remote Control, or do this step in code."
66
+ );
67
+ }
68
+ const res = await fetch(url, {
69
+ method: "POST",
70
+ headers: { "Content-Type": "application/json", "X-Atlas-Token": token },
71
+ body: JSON.stringify({ job: process.env.ATLAS_JOB || "", prompt: String(prompt), tools, fresh }),
72
+ signal: AbortSignal.timeout(timeoutMs),
73
+ });
74
+ const out = await res.json().catch(() => ({}));
75
+ if (!res.ok || !out.ok) throw new Error(`The model could not answer: ${out.error || res.status}`);
76
+ return out.answer || "";
77
+ }
78
+
79
+ /**
80
+ * Call a tool on one of YOUR connected MCP servers.
81
+ *
82
+ * Robinhood, Stripe, whatever is linked to the Atlas account. One credential
83
+ * does it: this job already carries an Atlas token, and Atlas holds the
84
+ * connector's own sign-in, does its handshake and keeps the session warm. The
85
+ * script never sees that server's credentials and never speaks JSON-RPC.
86
+ *
87
+ * const quotes = await mcp("Robinhood", "get_equity_quotes", { symbols: ["SPY"] });
88
+ *
89
+ * Use mcpTools() to see what is connected and what each tool takes.
90
+ */
91
+ export async function mcp(server, tool, args = {}) {
92
+ return atlas("mcp-call", { server, tool, arguments: args });
93
+ }
94
+
95
+ /**
96
+ * What is connected, and what each tool takes. Names and input schemas only —
97
+ * narrow it with `server` or `search` rather than reading a catalogue of sixty.
98
+ */
99
+ export async function mcpTools({ server = "", search = "" } = {}) {
100
+ return atlas("mcp-tools", { server, search });
101
+ }
102
+
103
+ /**
104
+ * Stop cleanly when a secret has not been filled in yet.
105
+ *
106
+ * Names what is missing and nothing else: a blank key is the owner's to fill in
107
+ * Atlas Remote Control, not something a script should work around.
108
+ */
109
+ export function require_(...names) {
110
+ const missing = names.filter((n) => !process.env[n]);
111
+ if (missing.length) {
112
+ console.log(`Missing: ${missing.join(", ")}. Add the value in Atlas Remote Control, then run again.`);
113
+ process.exit(2);
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Write one CLOSED trade into this project's Performance page.
119
+ *
120
+ * Appends to trades.json beside the script; Atlas draws the equity curve, the
121
+ * win/loss bar and the calendar from it. Same fields as every Atlas performance
122
+ * row, so it looks exactly like a workflow's record:
123
+ *
124
+ * recordTrade({ symbol: "SPY", quantity: 2, entry_price: 1.20, exit_price: 1.85,
125
+ * opened_at: "2026-09-03T13:35:00Z", closed_at: "2026-09-03T14:10:00Z",
126
+ * option_type: "CALL", strike: 560, expiration: "2026-09-05" });
127
+ *
128
+ * option_type is "CALL" / "PUT" for a contract (P&L is per 100), null for
129
+ * shares. pnl is worked out when not given. The first real trade replaces the
130
+ * example row the project started with.
131
+ */
132
+ export function recordTrade(t) {
133
+ const mult = t.option_type ? 100 : 1;
134
+ const qty = Number(t.quantity || 0);
135
+ const entry = Number(t.entry_price || 0);
136
+ const exit = Number(t.exit_price || 0);
137
+ const pnl = t.pnl_dollars ?? Math.round((exit - entry) * qty * mult * 100) / 100;
138
+ const cost = Math.abs(entry * qty * mult);
139
+ const row = {
140
+ id: Math.random().toString(36).slice(2) + Date.now().toString(36),
141
+ symbol: String(t.symbol).toUpperCase(),
142
+ option_type: t.option_type ? String(t.option_type).toUpperCase() : null,
143
+ strike: t.strike ?? null, expiration: t.expiration ?? null, quantity: qty,
144
+ entry_price: entry, exit_price: exit, pnl_dollars: pnl,
145
+ pnl_pct: t.pnl_pct ?? (cost ? Math.round((pnl / cost) * 10000) / 100 : 0),
146
+ opened_at: t.opened_at, closed_at: t.closed_at,
147
+ };
148
+ let data = {};
149
+ try { data = JSON.parse(fs.readFileSync("trades.json", "utf8")); } catch { data = {}; }
150
+ const trades = (data.trades || []).filter((x) => !x.example);
151
+ trades.push(row);
152
+ fs.writeFileSync("trades.json", JSON.stringify({ trades }, null, 2));
153
+ return row;
154
+ }
@@ -0,0 +1,191 @@
1
+ """Secrets for this job. Written by Atlas — edits are overwritten.
2
+
3
+ Values come from the environment Atlas set up before starting this script. Do
4
+ NOT open .env: this module is the supported way to reach a secret, and the file
5
+ itself is not something a script or a model needs to read.
6
+ """
7
+ import os
8
+ import sys
9
+
10
+
11
+ def secret(name, default=None):
12
+ """The value of one secret, for USE — never for printing or logging."""
13
+ return os.environ.get(name, default)
14
+
15
+
16
+ def has_secret(name):
17
+ """Whether a secret has been filled in. True/False only, never the value."""
18
+ return bool(os.environ.get(name))
19
+
20
+
21
+ def atlas(tool, **params):
22
+ """Call an Atlas tool over the REST API. Returns the parsed JSON.
23
+
24
+ This is the way to do anything real — quotes, chains, orders, alerts. The
25
+ `atlas` CLI shells out a whole process per call and hands back text to
26
+ re-parse; this is one HTTPS request that returns data, which is what you
27
+ actually want inside a loop over twenty tickers.
28
+
29
+ The token and the base URL are already in this job's environment. Do not
30
+ read them, print them, or pass them anywhere else.
31
+
32
+ quote = atlas("stock-quote", symbol="SPY")
33
+ print(quote["price"])
34
+ """
35
+ import json as _json
36
+ import urllib.error
37
+ import urllib.request
38
+
39
+ base = os.environ.get("ATLAS_BASE_URL", "https://atlasmcp.finmanagerai.com").rstrip("/")
40
+ token = os.environ.get("ATLAS_TOKEN", "")
41
+ if not token:
42
+ raise RuntimeError("This job has no Atlas token. Is Local Mode signed in?")
43
+ req = urllib.request.Request(
44
+ "%s/api/v1/tools/%s" % (base, tool),
45
+ data=_json.dumps(params).encode("utf-8"),
46
+ headers={"Content-Type": "application/json", "Authorization": "Bearer " + token},
47
+ method="POST",
48
+ )
49
+ try:
50
+ with urllib.request.urlopen(req, timeout=120) as r:
51
+ return _json.loads(r.read().decode("utf-8"))
52
+ except urllib.error.HTTPError as e:
53
+ body = e.read().decode("utf-8", "replace")[:500]
54
+ raise RuntimeError("Atlas %s failed (%d): %s" % (tool, e.code, body))
55
+
56
+
57
+ def ask(prompt, tools=True, fresh=False, timeout=600):
58
+ """Ask YOUR OWN model a question and get its answer back as text.
59
+
60
+ For the steps only a model can do: reading a filing and pulling out the
61
+ guidance, judging whether a headline is about the company or its sector,
62
+ summarising what changed since yesterday. Everything else - fetching,
63
+ counting, comparing, placing orders - is faster and more reliable as plain
64
+ code, so use this for judgement, not for arithmetic.
65
+
66
+ It runs on this computer, through the model already connected in Atlas
67
+ Remote Control. The prompt never reaches Atlas.
68
+
69
+ verdict = ask("Is this headline about AAPL itself or the sector?\n" + line)
70
+ if "itself" in verdict.lower():
71
+ ...
72
+
73
+ tools=True lets the model reach Atlas data while it answers; pass
74
+ tools=False for pure text work, which is faster.
75
+
76
+ fresh=False keeps ONE conversation for this job, so later questions can
77
+ refer to earlier ones and the model starts up only once. Pass fresh=True
78
+ when each question is independent - twenty classifications should not pile
79
+ into one enormous context.
80
+ """
81
+ import json as _json
82
+ import urllib.error
83
+ import urllib.request
84
+
85
+ url = os.environ.get("ATLAS_ASK_URL", "")
86
+ token = os.environ.get("ATLAS_ASK_TOKEN", "")
87
+ if not url or not token:
88
+ raise RuntimeError(
89
+ "No model is connected on this computer, so this script cannot ask one. "
90
+ "Connect a model in Atlas Remote Control, or do this step in code."
91
+ )
92
+ body = {"job": os.environ.get("ATLAS_JOB", ""), "prompt": str(prompt),
93
+ "tools": bool(tools), "fresh": bool(fresh)}
94
+ req = urllib.request.Request(
95
+ url, data=_json.dumps(body).encode("utf-8"),
96
+ headers={"Content-Type": "application/json", "X-Atlas-Token": token},
97
+ method="POST",
98
+ )
99
+ try:
100
+ with urllib.request.urlopen(req, timeout=timeout) as r:
101
+ out = _json.loads(r.read().decode("utf-8"))
102
+ except urllib.error.HTTPError as e:
103
+ body_text = e.read().decode("utf-8", "replace")[:500]
104
+ try:
105
+ body_text = _json.loads(body_text).get("error") or body_text
106
+ except Exception:
107
+ pass
108
+ raise RuntimeError("The model could not answer: %s" % body_text)
109
+ if not out.get("ok"):
110
+ raise RuntimeError("The model could not answer: %s" % (out.get("error") or "unknown"))
111
+ return out.get("answer") or ""
112
+
113
+
114
+ def mcp(server, tool, **arguments):
115
+ """Call a tool on one of YOUR connected MCP servers.
116
+
117
+ Robinhood, Stripe, whatever is linked to the Atlas account. One credential
118
+ does it: this job already carries an Atlas token, and Atlas holds the
119
+ connector's own sign-in, does its handshake and keeps the session warm. The
120
+ script never sees that server's credentials and never speaks JSON-RPC.
121
+
122
+ quotes = mcp("Robinhood", "get_equity_quotes", symbols=["SPY"])
123
+
124
+ Use mcp_tools() to see what is connected and what each tool takes.
125
+ """
126
+ return atlas("mcp-call", server=server, tool=tool, arguments=arguments)
127
+
128
+
129
+ def mcp_tools(server="", search=""):
130
+ """What is connected, and what each tool takes.
131
+
132
+ Names and input schemas only. Narrow it with `server` or `search` rather
133
+ than reading a catalogue of sixty.
134
+ """
135
+ return atlas("mcp-tools", server=server or "", search=search or "")
136
+
137
+
138
+ def require(*names):
139
+ """Stop cleanly when a secret has not been filled in yet.
140
+
141
+ Names what is missing and nothing else: a blank key is the owner's to fill
142
+ in Atlas Remote Control, not something a script should work around.
143
+ """
144
+ missing = [n for n in names if not os.environ.get(n)]
145
+ if missing:
146
+ print("Missing: %s. Add the value in Atlas Remote Control, then run again."
147
+ % ", ".join(missing))
148
+ sys.exit(2)
149
+
150
+
151
+ def record_trade(symbol, quantity, entry_price, exit_price, opened_at, closed_at,
152
+ option_type=None, strike=None, expiration=None, pnl_dollars=None, pnl_pct=None):
153
+ """Write one CLOSED trade into this project's Performance page.
154
+
155
+ Appends to trades.json beside the script; Atlas draws the equity curve, the
156
+ win/loss bar and the calendar from it. Same fields as every Atlas
157
+ performance row, so it looks exactly like a workflow's record:
158
+
159
+ record_trade("SPY", 2, 1.20, 1.85, "2026-09-03T13:35:00Z", "2026-09-03T14:10:00Z",
160
+ option_type="CALL", strike=560, expiration="2026-09-05")
161
+
162
+ option_type is "CALL" / "PUT" for a contract (P&L is per 100), None for
163
+ shares. pnl is worked out when not given. The first real trade replaces the
164
+ example row the project started with.
165
+ """
166
+ import json as _json
167
+ import uuid as _uuid
168
+ mult = 100 if option_type else 1
169
+ qty = float(quantity or 0)
170
+ if pnl_dollars is None:
171
+ pnl_dollars = round((float(exit_price or 0) - float(entry_price or 0)) * qty * mult, 2)
172
+ if pnl_pct is None:
173
+ cost = abs(float(entry_price or 0) * qty * mult)
174
+ pnl_pct = round(pnl_dollars / cost * 100, 2) if cost else 0.0
175
+ row = {
176
+ "id": _uuid.uuid4().hex, "symbol": str(symbol).upper(),
177
+ "option_type": (str(option_type).upper() if option_type else None),
178
+ "strike": strike, "expiration": expiration, "quantity": qty,
179
+ "entry_price": float(entry_price or 0), "exit_price": float(exit_price or 0),
180
+ "pnl_dollars": pnl_dollars, "pnl_pct": pnl_pct,
181
+ "opened_at": opened_at, "closed_at": closed_at,
182
+ }
183
+ try:
184
+ data = _json.load(open("trades.json", encoding="utf-8"))
185
+ except Exception:
186
+ data = {}
187
+ trades = [t for t in (data.get("trades") or []) if not t.get("example")]
188
+ trades.append(row)
189
+ with open("trades.json", "w", encoding="utf-8") as fh:
190
+ _json.dump({"trades": trades}, fh, indent=2)
191
+ return row
@@ -9,6 +9,7 @@ import fs from 'node:fs';
9
9
  import os from 'node:os';
10
10
  import path from 'node:path';
11
11
  import { randomUUID } from 'node:crypto';
12
+ import { fileURLToPath } from 'node:url';
12
13
  import { configDir } from './config.js';
13
14
 
14
15
  /**
@@ -195,6 +196,181 @@ export function readManifest(dir) {
195
196
  }
196
197
  }
197
198
 
199
+ // ---- the schedule, kept IN the project ---------------------------------------
200
+ //
201
+ // LOCAL CRON (owner decision 2026-09-03, reversing 2026-08-29). The schedule
202
+ // lives in atlas.json on the computer that runs it, and this process keeps the
203
+ // time; the server is not in the loop. "Make things as local as possible": a
204
+ // project is a folder that knows how to start itself AND when. The one thing a
205
+ // local clock cannot do is record a run the computer slept through, so the
206
+ // ticker writes a "Computer was off" row for any gap it finds when it wakes.
207
+
208
+ const CRON_RANGES = [[0, 59], [0, 23], [1, 31], [1, 12], [0, 7]];
209
+
210
+ /**
211
+ * Parse a five-field cron line into sets, or null when it is not one.
212
+ *
213
+ * Supports what people actually write: `*`, `n`, `a-b`, `* /n` (no space),
214
+ * `a-b/n` and comma lists. Day-of-week 7 is Sunday like 0. No names.
215
+ */
216
+ export function parseCron(expr) {
217
+ const parts = String(expr || '').trim().split(/\s+/);
218
+ if (parts.length !== 5) return null;
219
+ const fields = [];
220
+ for (let i = 0; i < 5; i += 1) {
221
+ const set = new Set();
222
+ for (const piece of parts[i].split(',')) {
223
+ const m = /^(\*|\d+(?:-\d+)?)(?:\/(\d+))?$/.exec(piece);
224
+ if (!m) return null;
225
+ let [lo, hi] = CRON_RANGES[i];
226
+ if (m[1] !== '*') {
227
+ const [a, b] = m[1].split('-').map(Number);
228
+ lo = a; hi = b === undefined ? a : b;
229
+ }
230
+ const step = m[2] ? Number(m[2]) : 1;
231
+ if (!(step > 0) || lo < CRON_RANGES[i][0] || hi > CRON_RANGES[i][1] || lo > hi) return null;
232
+ for (let v = lo; v <= hi; v += step) set.add(i === 4 && v === 7 ? 0 : v);
233
+ }
234
+ fields.push(set);
235
+ }
236
+ return {
237
+ minute: fields[0], hour: fields[1], dom: fields[2], month: fields[3], dow: fields[4],
238
+ domStar: parts[2] === '*', dowStar: parts[4] === '*',
239
+ };
240
+ }
241
+
242
+ const DOW = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
243
+
244
+ /** The wall-clock fields of a moment in a time zone (or this machine's). */
245
+ export function localFields(epochSec, tz) {
246
+ const date = new Date(epochSec * 1000);
247
+ let fmt = null;
248
+ if (tz) {
249
+ try {
250
+ fmt = new Intl.DateTimeFormat('en-US', {
251
+ timeZone: tz, hour12: false, minute: 'numeric', hour: 'numeric',
252
+ day: 'numeric', month: 'numeric', weekday: 'short',
253
+ });
254
+ } catch { fmt = null; /* an unknown zone falls back to local time */ }
255
+ }
256
+ if (!fmt) {
257
+ return { minute: date.getMinutes(), hour: date.getHours(), dom: date.getDate(),
258
+ month: date.getMonth() + 1, dow: date.getDay() };
259
+ }
260
+ const out = {};
261
+ for (const part of fmt.formatToParts(date)) {
262
+ if (part.type === 'minute') out.minute = Number(part.value);
263
+ else if (part.type === 'hour') out.hour = Number(part.value) % 24;
264
+ else if (part.type === 'day') out.dom = Number(part.value);
265
+ else if (part.type === 'month') out.month = Number(part.value);
266
+ else if (part.type === 'weekday') out.dow = DOW[part.value] ?? 0;
267
+ }
268
+ return out;
269
+ }
270
+
271
+ /** Does this cron fire at this minute? Standard rule: when BOTH the day-of-month
272
+ * and the day-of-week are restricted, either one matching is enough. */
273
+ export function cronMatches(cron, epochSec, tz) {
274
+ if (!cron) return false;
275
+ const f = localFields(epochSec, tz);
276
+ if (!cron.minute.has(f.minute) || !cron.hour.has(f.hour) || !cron.month.has(f.month)) return false;
277
+ const domOk = cron.dom.has(f.dom);
278
+ const dowOk = cron.dow.has(f.dow);
279
+ if (cron.domStar && cron.dowStar) return true;
280
+ if (cron.domStar) return dowOk;
281
+ if (cron.dowStar) return domOk;
282
+ return domOk || dowOk;
283
+ }
284
+
285
+ /**
286
+ * Every minute in (from, to] at which the cron fires, as epoch seconds.
287
+ *
288
+ * Bounded to the last 24 hours so a laptop shut for a fortnight comes back to
289
+ * one honest gap row, not a loop over twenty thousand minutes.
290
+ */
291
+ export function dueMinutes(cron, fromEpoch, toEpoch, tz) {
292
+ const out = [];
293
+ if (!cron) return out;
294
+ const end = Math.floor(toEpoch / 60) * 60;
295
+ let t = Math.floor(fromEpoch / 60) * 60 + 60;
296
+ if (t < end - 86400) t = end - 86400;
297
+ for (; t <= end; t += 60) if (cronMatches(cron, t, tz)) out.push(t);
298
+ return out;
299
+ }
300
+
301
+ /** The schedule a project declares in atlas.json, or null when it has none.
302
+ * `{ expr, cron, tz, error }` — `error` names a line that could not be read,
303
+ * so the owner is told instead of the project silently never running. */
304
+ export function readSchedule(dir) {
305
+ const m = readManifest(dir);
306
+ const expr = typeof m.schedule === 'string' ? m.schedule.trim() : '';
307
+ if (!expr) return null;
308
+ const tz = typeof m.timezone === 'string' && m.timezone.trim() ? m.timezone.trim() : '';
309
+ const cron = parseCron(expr);
310
+ return { expr, cron, tz, error: cron ? null : `schedule "${expr}" is not a cron line I understand` };
311
+ }
312
+
313
+ /**
314
+ * The alerts a project wants to run on, from atlas.json `on_alert`.
315
+ *
316
+ * A string or a list. Each entry names an alert the owner already has: its
317
+ * label ("SPY DEX flip"), its id, its symbol ("SPY"), or its kind ("king_node").
318
+ * "*" means any alert of theirs. Matching is case-insensitive. Empty = none:
319
+ * the project runs on its schedule or Run now only. The server is not told
320
+ * about any of this; the computer listens to the owner's own alert feed and
321
+ * decides for itself (owner, 2026-09-03: triggers, kept local).
322
+ */
323
+ export function readAlertTriggers(dir) {
324
+ const raw = readManifest(dir).on_alert;
325
+ const list = Array.isArray(raw) ? raw : (typeof raw === 'string' && raw.trim() ? [raw] : []);
326
+ return list.map((v) => String(v || '').trim().toLowerCase()).filter(Boolean);
327
+ }
328
+
329
+ /** Does a fired alert (the /api/v1/alerts event) match a project's on_alert list? */
330
+ export function alertMatches(triggers, event) {
331
+ if (!triggers || !triggers.length || !event || typeof event !== 'object') return false;
332
+ const facets = [event.label, event.id, event.symbol, event.name, event.alert_type]
333
+ .map((v) => String(v || '').trim().toLowerCase()).filter(Boolean);
334
+ return triggers.some((t) => t === '*' || facets.includes(t));
335
+ }
336
+
337
+ /** Every project on this computer that wants this alert, as folder names. */
338
+ export function projectsForAlert(root, event) {
339
+ const base = path.join(workspaceRoot(root), SCRIPTS_DIR);
340
+ let dirs = [];
341
+ try {
342
+ dirs = fs.readdirSync(base, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
343
+ } catch { return []; }
344
+ return dirs.filter((slug) => alertMatches(readAlertTriggers(path.join(base, slug)), event));
345
+ }
346
+
347
+ /**
348
+ * What the running script last said about where it is, from the progress file
349
+ * the manifest names (runs/progress.json by default). Null when there is none.
350
+ * Clipped, typed and confined to the project: this is read for the web.
351
+ */
352
+ export function readProgress(dir) {
353
+ const rel = readManifest(dir).progress;
354
+ const want = typeof rel === 'string' && rel.trim() ? rel.trim() : 'runs/progress.json';
355
+ const file = path.resolve(dir, want);
356
+ if (!file.startsWith(path.resolve(dir) + path.sep)) return null;
357
+ try {
358
+ const st = fs.statSync(file);
359
+ const d = JSON.parse(fs.readFileSync(file, 'utf8'));
360
+ if (!d || typeof d !== 'object') return null;
361
+ const num = (v) => (Number.isFinite(Number(v)) && v !== null && v !== '' ? Number(v) : null);
362
+ return {
363
+ step: typeof d.step === 'string' ? d.step.slice(0, 200) : '',
364
+ done: num(d.done),
365
+ total: num(d.total),
366
+ message: typeof d.message === 'string' ? d.message.slice(0, 500) : '',
367
+ at: st.mtimeMs / 1000,
368
+ };
369
+ } catch {
370
+ return null;
371
+ }
372
+ }
373
+
198
374
  /** The command the agent declared, split for spawning, or null. */
199
375
  export function manifestCommand(dir) {
200
376
  const run = readManifest(dir).run;
@@ -277,59 +453,16 @@ export function readEnvFile(file) {
277
453
  * changes nothing.
278
454
  */
279
455
  export function writeHelpers(dir) {
280
- const py = `"""Secrets for this job. Written by Atlas — edits are overwritten.
281
-
282
- Values come from the environment Atlas set up before starting this script. Do
283
- NOT open .env: this module is the supported way to reach a secret, and the file
284
- itself is not something a script or a model needs to read.
285
- """
286
- import os
287
- import sys
288
-
289
-
290
- def secret(name, default=None):
291
- """The value of one secret, for USE — never for printing or logging."""
292
- return os.environ.get(name, default)
293
-
294
-
295
- def has_secret(name):
296
- """Whether a secret has been filled in. True/False only, never the value."""
297
- return bool(os.environ.get(name))
298
-
299
-
300
- def require(*names):
301
- """Stop cleanly when a secret has not been filled in yet."""
302
- missing = [n for n in names if not os.environ.get(n)]
303
- if missing:
304
- print("Missing: %s. Add the value in Atlas Remote Control, then run again."
305
- % ", ".join(missing))
306
- sys.exit(2)
307
- `;
308
- const js = `// Secrets for this job. Written by Atlas — edits are overwritten.
309
- //
310
- // Values come from the environment Atlas set up before starting this script. Do
311
- // NOT open .env: this module is the supported way to reach a secret, and the
312
- // file itself is not something a script or a model needs to read.
313
-
314
- /** The value of one secret, for USE — never for printing or logging. */
315
- export const secret = (name, fallback) => process.env[name] ?? fallback;
316
-
317
- /** Whether a secret has been filled in. True/false only, never the value. */
318
- export const hasSecret = (name) => Boolean(process.env[name]);
319
-
320
- /** Stop cleanly when a secret has not been filled in yet. */
321
- export function require_(...names) {
322
- const missing = names.filter((n) => !process.env[n]);
323
- if (missing.length) {
324
- console.log(\`Missing: \${missing.join(", ")}. Add the value in Atlas Remote Control, then run again.\`);
325
- process.exit(2);
326
- }
327
- }
328
- `;
456
+ // ONE helper text for both CLIs. The python CLI keeps the source of truth
457
+ // inline (HELPER_PY / HELPER_JS in remote_control.py); these files are that
458
+ // text, shipped inside the npm package. They used to be a shorter copy with
459
+ // no ask()/atlas()/mcp(), so a script written to the build prompt failed on
460
+ // a computer running THIS CLI (which is every Studio install, 2026-09-03).
461
+ const here = path.dirname(fileURLToPath(import.meta.url));
329
462
  try {
330
- fs.writeFileSync(path.join(dir, 'atlas_env.py'), py);
331
- fs.writeFileSync(path.join(dir, 'atlas_env.js'), js);
332
- } catch { /* a helper is a courtesy, never a failure */ }
463
+ fs.writeFileSync(path.join(dir, 'atlas_env.py'), fs.readFileSync(path.join(here, 'helpers', 'atlas_env.py'), 'utf8'));
464
+ fs.writeFileSync(path.join(dir, 'atlas_env.js'), fs.readFileSync(path.join(here, 'helpers', 'atlas_env.js'), 'utf8'));
465
+ } catch { /* best effort: a run without helpers still runs */ }
333
466
  }
334
467
 
335
468
  export function jobEnv(dir, root) {
@@ -581,10 +714,15 @@ export function listRuns(root, limit = 50) {
581
714
  } catch { /* mid-write */ }
582
715
  runs.push({ job, name: nm, status: 'running', started_at: started, duration_s: null, log: '' });
583
716
  }
717
+ // "If we come back later we see the progress" (owner, 2026-09-03): the
718
+ // newest row of a project carries what its progress file last said, so a
719
+ // run that is going shows a bar and a finished one shows where it ended.
720
+ const newest = runs.length;
721
+ const progress = readProgress(path.join(base, job));
584
722
  let files = [];
585
723
  try {
586
724
  files = fs.readdirSync(path.join(base, job, 'runs')).filter((f) => f.endsWith('.log')).sort().reverse();
587
- } catch { continue; }
725
+ } catch { if (progress && runs[newest]) runs[newest].progress = progress; continue; }
588
726
  for (const f of files.slice(0, limit)) {
589
727
  const stem = f.replace(/\.log$/, '');
590
728
  const parts = stem.split('-');
@@ -604,6 +742,9 @@ export function listRuns(root, limit = 50) {
604
742
  } catch { /* unreadable: the filename still tells the story */ }
605
743
  runs.push({ job, name, status, started_at: Number.isFinite(started) ? started : 0, duration_s: took, log: f });
606
744
  }
745
+ // Only when the file was touched during or after that run: a stale bar
746
+ // from last week under today's row would be a lie.
747
+ if (progress && runs[newest] && progress.at >= runs[newest].started_at - 5) runs[newest].progress = progress;
607
748
  }
608
749
  runs.sort((a, b) => b.started_at - a.started_at);
609
750
  return runs.slice(0, limit);
@@ -713,6 +854,35 @@ export async function chatModel(prompt, onEvent, { job = '', model = '', timeout
713
854
  }
714
855
  }
715
856
 
857
+ /**
858
+ * Hand the model the person's answer to a question it asked mid-turn.
859
+ *
860
+ * With "Ask before the model uses a tool" on, the bridge streams the question
861
+ * as a `permission` event and the model waits. The answer arrives from the web
862
+ * as its own ask (a stream is one-way), and this passes it to the bridge. `ok`
863
+ * false with a reason when nothing was waiting: the turn ended, it timed out,
864
+ * or the id was never asked.
865
+ */
866
+ export async function answerPermission(requestId, optionId, timeoutMs = 15000) {
867
+ const url = process.env.ATLAS_ASK_URL;
868
+ const token = process.env.ATLAS_ASK_TOKEN;
869
+ if (!url || !token) return { ok: false, error: 'No model is connected on this computer.' };
870
+ if (!requestId) return { ok: false, error: 'Which question?' };
871
+ try {
872
+ const res = await fetch(url.replace('/ask', '/chat/permission'), {
873
+ method: 'POST',
874
+ headers: { 'Content-Type': 'application/json', 'X-Atlas-Token': token },
875
+ body: JSON.stringify({ request_id: String(requestId), option_id: optionId ?? null }),
876
+ signal: AbortSignal.timeout(timeoutMs),
877
+ });
878
+ const out = await res.json().catch(() => ({}));
879
+ if (!res.ok || !out.ok) return { ok: false, error: out.error || `The model did not take the answer (${res.status}).` };
880
+ return { ok: true };
881
+ } catch (err) {
882
+ return { ok: false, error: String(err?.message || err) };
883
+ }
884
+ }
885
+
716
886
  // The starter a project gets the moment it is created, so an agent opening the
717
887
  // folder finds a shape to work in rather than an empty directory. Nothing here
718
888
  // runs: it is scaffolding, the model replaces main.* on the first build.
@@ -725,8 +895,14 @@ const STARTER_README = (name) => [
725
895
  '- `.env` your keys. Values stay on this computer and are never read',
726
896
  ' back by anything, including the model.',
727
897
  '- `atlas_env.py` helpers: secret(), require(), atlas(), ask().',
728
- '- `flow.md` a diagram of what happens, written after the first build.', '',
729
- 'Nothing here is stored on Atlas servers.', '',
898
+ '- `flow.md` a diagram of what happens, written after the first build.',
899
+ '- `atlas.json` how to start it, and WHEN: `schedule` is a cron line this',
900
+ ' computer keeps to. Empty = only when you press Run now.',
901
+ '- `runs/` one log per run, and progress.json while it works.',
902
+ '- `trades.json` the trades this project made, shown on its Performance',
903
+ ' page. record_trade() in atlas_env writes one.', '',
904
+ 'Nothing here is stored on Atlas servers. The schedule runs on this computer,',
905
+ 'while Atlas Remote Control is open.', '',
730
906
  ].join('\n');
731
907
 
732
908
  const STARTER_MAIN = (name) => [
@@ -762,11 +938,35 @@ const STARTER_FLOW = (name) => [
762
938
 
763
939
  const STARTER_MANIFEST = JSON.stringify({
764
940
  run: '',
941
+ schedule: '',
942
+ timezone: '',
943
+ on_alert: [],
765
944
  progress: 'runs/progress.json',
766
945
  _note: 'run: how to start this project, e.g. "python main.py" or "py -3 -m mypkg". '
767
- + 'Empty means Atlas looks for main.py itself. progress: a file you overwrite with '
768
- + '{step, done, total, message} while you work, and Atlas draws a bar from it. '
769
- + 'Neither is required.',
946
+ + 'Empty means Atlas looks for main.py itself. '
947
+ + 'schedule: WHEN to run it, as a five-field cron line kept by this computer, e.g. '
948
+ + '"*/5 9-16 * * 1-5" (every 5 minutes, 9am-4pm, weekdays). Empty means only when '
949
+ + 'someone presses Run now. timezone: the zone the schedule is read in, e.g. '
950
+ + '"America/New_York"; empty means this computer\'s own clock. '
951
+ + 'on_alert: run when one of the owner\'s Atlas alerts fires - a list of alert labels, '
952
+ + 'ids, symbols or kinds, or ["*"] for any; the alert arrives in $ATLAS_JOB_PAYLOAD as '
953
+ + '{"alert": {label, symbol, message, result, ...}}. '
954
+ + 'progress: a file you overwrite with {step, done, total, message} while you work, '
955
+ + 'and Atlas draws a bar from it. None of these is required.',
956
+ }, null, 2) + '\n';
957
+
958
+ // A project's Performance page starts with one EXAMPLE trade, so the model can
959
+ // see every field it must fill and the owner sees what the page looks like
960
+ // before anything real has happened. The first real trade replaces it.
961
+ const STARTER_TRADES = JSON.stringify({
962
+ _note: 'One closed trade per row, written by record_trade() / recordTrade() in atlas_env. '
963
+ + 'Fields are exactly the Atlas performance row. option_type CALL/PUT for a contract '
964
+ + '(P&L per 100), null for shares. The example row is dropped by the first real trade.',
965
+ trades: [{
966
+ id: 'example', example: true, symbol: 'SPY', option_type: 'CALL', strike: 560, expiration: '2026-09-05',
967
+ quantity: 2, entry_price: 1.20, exit_price: 1.85, pnl_dollars: 130, pnl_pct: 54.17,
968
+ opened_at: '2026-09-03T13:35:00Z', closed_at: '2026-09-03T14:10:00Z',
969
+ }],
770
970
  }, null, 2) + '\n';
771
971
 
772
972
  /**
@@ -783,6 +983,7 @@ export function starterFiles(slug) {
783
983
  ['main.py', STARTER_MAIN(slug)],
784
984
  ['flow.md', STARTER_FLOW(slug)],
785
985
  ['atlas.json', STARTER_MANIFEST],
986
+ ['trades.json', STARTER_TRADES],
786
987
  ['.env', '# KEY=value - one per line. Values stay on this computer.\n'],
787
988
  ];
788
989
  }
package/src/util/help.js CHANGED
@@ -55,6 +55,7 @@ export function printRootHelp() {
55
55
  println(c.dim(' atlas create-task --name "Close SPY 3:55" --instruction "Close my SPY position" --schedule-times 15:55'));
56
56
  println(c.dim(' atlas run-task --task-id <id>'));
57
57
  println(c.dim(' atlas workflow-create --name "GEX signal" --schedule "30 9 * * 1-5" --tools Stock-Quote --instruction "…"'));
58
+ println(c.dim(' atlas workflow-create --name "FOMC day" --run-once-at "2026-09-17 14:00" --tools Stock-Quote --instruction "…" # once, at specific dates/times; --trigger-source manual = Run now only'));
58
59
  println(c.dim(' atlas alerts --symbol SPY --name greek_exposure --save-charts ./charts'));
59
60
  println(c.dim(' atlas flow stream --symbol NVDA'));
60
61
  }