atris 3.26.0 → 3.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -319,6 +319,8 @@ atris upgrade # Install latest from npm
319
319
  atris update # Sync local workspace files to new version
320
320
  ```
321
321
 
322
+ Packaged npm installs check for new versions during normal commands and start a background update automatically. Git checkout installs stay manual so linked development copies are not overwritten unexpectedly.
323
+
322
324
  ---
323
325
 
324
326
  **License:** MIT | **Repo:** [github.com/atrislabs/atris](https://github.com/atrislabs/atris.git)
package/bin/atris.js CHANGED
@@ -92,14 +92,10 @@ if (!skipUpdateCheck && (!updateCommand || (updateCommand && !['version', 'updat
92
92
  .then((updateInfo) => {
93
93
  // Show notification if update available (after command completes)
94
94
  if (updateInfo) {
95
- // Notify only — never auto-update mid-session (opt-in via ATRIS_AUTO_UPDATE=1)
96
- if (process.env.ATRIS_AUTO_UPDATE === '1') {
97
- setTimeout(() => {
98
- if (!autoUpdate(updateInfo)) {
99
- showUpdateNotification(updateInfo);
100
- }
101
- }, 100);
102
- } else {
95
+ const autoUpdateStarted = autoUpdate(updateInfo, {
96
+ packageRoot: path.join(__dirname, '..'),
97
+ });
98
+ if (!autoUpdateStarted) {
103
99
  showUpdateNotification(updateInfo);
104
100
  }
105
101
  }
@@ -644,6 +640,7 @@ function showUpgradeHelp() {
644
640
  console.log('');
645
641
  console.log('Description:');
646
642
  console.log(' Check npm for the latest Atris CLI and install it globally if newer.');
643
+ console.log(' Normal packaged installs also auto-update in the background.');
647
644
  console.log('');
648
645
  console.log('Options:');
649
646
  console.log(' --help, -h Show this help.');
@@ -826,7 +823,7 @@ if (command === '2' && ['fast', 'pro'].includes(String(firstCommandArg || '').to
826
823
  const knownCommands = ['init', 'log', 'now', 'radar', 'ctop', 'status', 'analytics', 'visualize', 'brain', 'brainstorm', 'autopilot', 'run', 'plan', 'do', 'review', 'release',
827
824
  'activate', '_activate', 'agent', 'chat', 'fast', 'ax', 'console', 'serve', 'login', 'logout', 'whoami', 'switch', 'use', 'accounts', '_resolve', '_profile-email', '_switch-session', 'shell-init', 'update', 'upgrade', 'version', 'help', 'next', 'atris',
828
825
  'clean', 'verify', 'search', 'skill', 'member', 'codex-goal', 'app', 'apps', 'learn', 'lesson', 'plugin', 'experiments', 'receipt', 'proof', 'openclaw', 'pull', 'push', 'live', 'align', 'terminal', 'computer', 'diff', 'business', 'sync', 'youtube',
829
- 'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'probe', 'worktree', 'aeo', 'slop', 'deck', 'site', 'theme', 'card', 'reel', 'improve', 'xp', 'play', 'gm', 'x', 'recap',
826
+ 'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'probe', 'worktree', 'aeo', 'slop', 'deck', 'site', 'theme', 'card', 'reel', 'improve', 'xp', 'play', 'gm', 'x', 'recap', 'signup', 'clarity', 'moves',
830
827
  'gmail', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
831
828
  'fork', 'browse', 'publish', 'sleep', 'wake', 'feedback', 'errors', 'wiki', 'code-review', 'cr', 'soul', 'fleet', 'compile', 'spaceship'];
832
829
 
@@ -2089,6 +2086,22 @@ if (command === 'init') {
2089
2086
  Promise.resolve(require('../commands/reel').run(process.argv.slice(3)))
2090
2087
  .then((code) => process.exit(typeof code === 'number' ? code : 0))
2091
2088
  .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
2089
+ } else if (command === 'signup') {
2090
+ // Signup: one-call seedless agent signup (POST /auth/agent/signup) → writes the
2091
+ // active profile so `atris play` works next. The install→signup→play seam.
2092
+ Promise.resolve(require('../commands/signup').signupCommand(process.argv.slice(3)))
2093
+ .then((code) => process.exit(typeof code === 'number' ? code : 0))
2094
+ .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
2095
+ } else if (command === 'moves') {
2096
+ // Moves: your 3 next moves — approve one into the loop, kill, or skip.
2097
+ Promise.resolve(require('../commands/moves').movesCommand(process.argv.slice(3)))
2098
+ .then((code) => process.exit(typeof code === 'number' ? code : 0))
2099
+ .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
2100
+ } else if (command === 'clarity') {
2101
+ // Clarity: interview yourself once; agents read how you work so you stop repeating it.
2102
+ Promise.resolve(require('../commands/clarity').clarityCommand(process.argv.slice(3)))
2103
+ .then((code) => process.exit(typeof code === 'number' ? code : 0))
2104
+ .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
2092
2105
  } else if (command === 'receipt' || command === 'proof' || command === 'openclaw') {
2093
2106
  const subcommand = process.argv[3];
2094
2107
  const args = process.argv.slice(4);
@@ -2167,8 +2180,8 @@ async function upgradeAtris() {
2167
2180
  console.log('Installing update...');
2168
2181
  console.log('');
2169
2182
 
2170
- // Run npm update -g atris
2171
- const result = spawnSync('npm', ['update', '-g', 'atris'], {
2183
+ // Run npm install -g atris@latest
2184
+ const result = spawnSync('npm', ['install', '-g', 'atris@latest'], {
2172
2185
  stdio: 'inherit',
2173
2186
  shell: true
2174
2187
  });
@@ -2182,10 +2195,10 @@ async function upgradeAtris() {
2182
2195
  } else {
2183
2196
  console.log('');
2184
2197
  console.log('✗ Upgrade failed. Try running manually:');
2185
- console.log(' npm update -g atris');
2198
+ console.log(' npm install -g atris@latest');
2186
2199
  console.log('');
2187
2200
  console.log('If you see permission errors, try:');
2188
- console.log(' sudo npm update -g atris');
2201
+ console.log(' sudo npm install -g atris@latest');
2189
2202
  console.log('');
2190
2203
  }
2191
2204
  }
@@ -158,6 +158,30 @@ function activateAtris() {
158
158
  wikiStatus.bullets.forEach((line) => console.log(`- ${line.replace(/^- /, '')}`));
159
159
  }
160
160
  console.log('');
161
+ try {
162
+ const { nextMoves } = require('../lib/next-moves');
163
+ const { readProfile, isEmptyProfile } = require('../lib/clarity');
164
+ const root = process.cwd();
165
+ const moves = nextMoves(root, 3);
166
+ console.log('Your next moves:');
167
+ if (moves.length) {
168
+ moves.forEach((m, i) => console.log(` ${i + 1}. ${m.title}`));
169
+ console.log(' steer them: atris moves');
170
+ } else {
171
+ console.log(' none queued. add to ROADMAP.md under "## Open loop items", or jot one with atris log');
172
+ }
173
+ const profile = readProfile(root);
174
+ console.log('');
175
+ if (isEmptyProfile(profile)) {
176
+ console.log('Tip: run atris clarity once so agents learn how you work.');
177
+ } else {
178
+ console.log('How you work (atris clarity):');
179
+ ['focus', 'voice', 'cadence', 'done', 'leash']
180
+ .filter((k) => profile[k])
181
+ .forEach((k) => console.log(` ${k}: ${profile[k]}`));
182
+ }
183
+ console.log('');
184
+ } catch { /* alive onboarding is best-effort; never block activate */ }
161
185
  console.log('Next: atris plan → do → review (or atris log)');
162
186
  console.log('');
163
187
  }
@@ -0,0 +1,125 @@
1
+ 'use strict';
2
+
3
+ // `atris clarity`: interview the operator for how they work, write it down once.
4
+ // The interview moves one question at a time so you stay in flow. The result is
5
+ // a durable profile (.atris/clarity.json + atris/CLARITY.md) that agents read.
6
+
7
+ const readline = require('readline');
8
+ const {
9
+ QUESTIONS,
10
+ KEYS,
11
+ mergeProfile,
12
+ isEmptyProfile,
13
+ renderClarityMd,
14
+ readProfile,
15
+ writeProfile,
16
+ profilePaths,
17
+ } = require('../lib/clarity');
18
+
19
+ function parseSets(args) {
20
+ const answers = {};
21
+ for (let i = 0; i < args.length; i++) {
22
+ if (args[i] === '--set' && args[i + 1]) {
23
+ const eq = args[i + 1].indexOf('=');
24
+ if (eq > 0) {
25
+ const key = args[i + 1].slice(0, eq).trim();
26
+ const val = args[i + 1].slice(eq + 1).trim();
27
+ if (KEYS.includes(key)) answers[key] = val;
28
+ }
29
+ i++;
30
+ }
31
+ }
32
+ return answers;
33
+ }
34
+
35
+ function ask(rl, question) {
36
+ return new Promise((resolve) => rl.question(question, (a) => resolve(a)));
37
+ }
38
+
39
+ async function clarityCommand(args = [], root = process.cwd()) {
40
+ const sub = args[0];
41
+ const stamp = new Date().toISOString();
42
+
43
+ if (args.includes('--help') || args.includes('-h') || sub === 'help') {
44
+ console.log('');
45
+ console.log('Usage: atris clarity [show|--json|--set key=value|--reset]');
46
+ console.log('');
47
+ console.log('Interview the operator for how they work, once. Agents read the result.');
48
+ console.log('');
49
+ console.log(' atris clarity Run the interview (one question at a time)');
50
+ console.log(' atris clarity show Show the current profile');
51
+ console.log(' atris clarity --json Print the profile as JSON');
52
+ console.log(' atris clarity --set voice=plain Set one field without prompting');
53
+ console.log(' atris clarity --reset Clear the profile');
54
+ console.log('');
55
+ console.log(`Fields: ${KEYS.join(', ')}`);
56
+ console.log('');
57
+ return 0;
58
+ }
59
+
60
+ if (args.includes('--reset')) {
61
+ writeProfile(root, { updated_at: stamp });
62
+ console.log('clarity profile reset.');
63
+ return 0;
64
+ }
65
+
66
+ const existing = readProfile(root);
67
+
68
+ if (args.includes('--json')) {
69
+ console.log(JSON.stringify(existing, null, 2));
70
+ return 0;
71
+ }
72
+
73
+ if (sub === 'show') {
74
+ console.log(renderClarityMd(existing));
75
+ return 0;
76
+ }
77
+
78
+ // Non-interactive set: write fields and exit.
79
+ const sets = parseSets(args);
80
+ if (Object.keys(sets).length) {
81
+ const merged = mergeProfile(existing, sets, stamp);
82
+ // Only the keys whose values actually landed (mergeProfile drops empties).
83
+ const changed = Object.keys(sets).filter((k) => sets[k].trim() && merged[k] === sets[k].trim());
84
+ if (!changed.length) {
85
+ console.log('nothing to set (empty value ignored). use --reset to clear all.');
86
+ return 0;
87
+ }
88
+ const { md } = writeProfile(root, merged);
89
+ console.log(`saved ${changed.join(', ')} to ${md.replace(`${root}/`, '')}`);
90
+ return 0;
91
+ }
92
+
93
+ // Interactive interview. Only on a terminal, so spawns never hang.
94
+ if (!process.stdin.isTTY) {
95
+ console.log(renderClarityMd(existing));
96
+ if (isEmptyProfile(existing)) {
97
+ console.log('Run `atris clarity` in a terminal to fill this in, or use --set key=value.');
98
+ }
99
+ return 0;
100
+ }
101
+
102
+ console.log('');
103
+ console.log('clarity interview. plain answers, one at a time. enter to keep the current value.');
104
+ console.log('');
105
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
106
+ const answers = {};
107
+ for (const { key, q } of QUESTIONS) {
108
+ const current = existing[key] ? ` [${existing[key]}]` : '';
109
+ // eslint-disable-next-line no-await-in-loop
110
+ const a = (await ask(rl, `${q}${current}\n> `)).trim();
111
+ if (a) answers[key] = a;
112
+ console.log('');
113
+ }
114
+ rl.close();
115
+
116
+ const merged = mergeProfile(existing, answers, stamp);
117
+ const { md } = writeProfile(root, merged);
118
+ console.log('clarity saved. agents will read it from:');
119
+ console.log(` ${md.replace(`${root}/`, '')}`);
120
+ console.log('');
121
+ console.log(renderClarityMd(merged));
122
+ return 0;
123
+ }
124
+
125
+ module.exports = { clarityCommand, parseSets };
@@ -0,0 +1,156 @@
1
+ 'use strict';
2
+
3
+ // `atris moves`: alive onboarding. Shows the 3 highest-leverage next moves and
4
+ // lets you approve (seed it into the loop), kill (stop suggesting it), or skip.
5
+ // This is the seed of proactiveness: the workspace proposes, you steer.
6
+
7
+ const readline = require('readline');
8
+ const {
9
+ nextMoves,
10
+ recordDecision,
11
+ seedInboxFromMove,
12
+ } = require('../lib/next-moves');
13
+
14
+ function currentMoves(root, limit) {
15
+ return nextMoves(root, limit);
16
+ }
17
+
18
+ function renderMoves(moves) {
19
+ if (!moves.length) {
20
+ return [
21
+ '',
22
+ 'your next moves',
23
+ '',
24
+ ' nothing queued. add an item to ROADMAP.md under "## Open loop items",',
25
+ ' or jot an idea with `atris log`.',
26
+ '',
27
+ ].join('\n');
28
+ }
29
+ const lines = ['', 'your next moves', ''];
30
+ moves.forEach((m, i) => {
31
+ lines.push(` ${i + 1}. ${m.title}`);
32
+ lines.push(` why: ${m.why} id: ${m.id}`);
33
+ lines.push('');
34
+ });
35
+ lines.push(' approve: atris moves --approve <id|N> seed it into the loop');
36
+ lines.push(' kill: atris moves --kill <id|N> stop suggesting it');
37
+ lines.push(' (use the id when you act later; the numbered order can shift)');
38
+ lines.push('');
39
+ return lines.join('\n');
40
+ }
41
+
42
+ function parseIndexes(value) {
43
+ return String(value || '')
44
+ .split(/[\s,]+/)
45
+ .map((s) => parseInt(s, 10))
46
+ .filter((n) => Number.isInteger(n) && n >= 1);
47
+ }
48
+
49
+ // Resolve --approve/--kill tokens to move objects. A token is either a stable
50
+ // move id (m_...) or a 1-based position. The id is preferred because the list
51
+ // can re-rank between viewing and acting, so a bare index can hit a different
52
+ // move than the one you saw.
53
+ function resolveSelection(moves, value) {
54
+ const tokens = String(value || '').split(/[\s,]+/).filter(Boolean);
55
+ const byId = new Map(moves.map((m) => [m.id, m]));
56
+ const seen = new Set();
57
+ const out = [];
58
+ for (const tok of tokens) {
59
+ let move = null;
60
+ if (byId.has(tok)) move = byId.get(tok);
61
+ else if (/^\d+$/.test(tok)) move = moves[parseInt(tok, 10) - 1];
62
+ if (move && !seen.has(move.id)) { seen.add(move.id); out.push(move); }
63
+ }
64
+ return out;
65
+ }
66
+
67
+ function applyDecision(root, selectedMoves, decision, stamp) {
68
+ const { claimRoadmapItem } = require('../lib/next-moves');
69
+ const acted = [];
70
+ for (const move of selectedMoves) {
71
+ recordDecision(root, move, decision, stamp);
72
+ if (decision === 'approve') {
73
+ // Seed then claim, mirroring the loop. For a roadmap-sourced move, mark it
74
+ // claimed in ROADMAP so the loop and the moves list agree it is handled.
75
+ const seeded = seedInboxFromMove(root, move);
76
+ if (move.source === 'roadmap') claimRoadmapItem(root, move.title);
77
+ acted.push({ move, seeded });
78
+ } else {
79
+ acted.push({ move });
80
+ }
81
+ }
82
+ return acted;
83
+ }
84
+
85
+ function readArgValue(args, name) {
86
+ const i = args.indexOf(name);
87
+ if (i === -1) return null;
88
+ return args[i + 1] || '';
89
+ }
90
+
91
+ async function movesCommand(args = [], root = process.cwd()) {
92
+ if (args.includes('--help') || args.includes('-h') || args[0] === 'help') {
93
+ console.log('');
94
+ console.log('Usage: atris moves [--approve <id|N>] [--kill <id|N>] [--json] [--limit N]');
95
+ console.log('');
96
+ console.log('Show the next moves and steer them. Each move has a stable id; prefer it');
97
+ console.log('over the position number, which can shift between viewing and acting.');
98
+ console.log('');
99
+ console.log(' atris moves Show the 3 next moves (prompts on a terminal)');
100
+ console.log(' atris moves --approve <id|N> Seed that move into the loop (writes the inbox)');
101
+ console.log(' atris moves --kill <id|N> Stop suggesting that move');
102
+ console.log(' atris moves --json Print the moves (with ids) as JSON, no prompt');
103
+ console.log(' atris moves --limit N Show N moves (default 3)');
104
+ console.log('');
105
+ return 0;
106
+ }
107
+
108
+ const limitArg = readArgValue(args, '--limit');
109
+ const limit = limitArg && !Number.isNaN(parseInt(limitArg, 10)) ? Math.max(1, parseInt(limitArg, 10)) : 3;
110
+ const stamp = new Date().toISOString();
111
+
112
+ const approveVal = readArgValue(args, '--approve');
113
+ const killVal = readArgValue(args, '--kill');
114
+
115
+ if (approveVal !== null || killVal !== null) {
116
+ const moves = currentMoves(root, limit);
117
+ const killed = applyDecision(root, resolveSelection(moves, killVal), 'kill', stamp);
118
+ const approved = applyDecision(root, resolveSelection(moves, approveVal), 'approve', stamp);
119
+ for (const a of approved) {
120
+ const note = a.seeded && a.seeded.alreadyPresent ? 'already in the inbox' : 'seeded into the loop';
121
+ console.log(`approved: ${a.move.title} -> ${note}`);
122
+ }
123
+ for (const k of killed) console.log(`killed: ${k.move.title} -> will not suggest again`);
124
+ if (!approved.length && !killed.length) console.log('no matching move for that id or number.');
125
+ return 0;
126
+ }
127
+
128
+ const moves = currentMoves(root, limit);
129
+
130
+ if (args.includes('--json')) {
131
+ console.log(JSON.stringify({ moves }, null, 2));
132
+ return 0;
133
+ }
134
+
135
+ console.log(renderMoves(moves));
136
+
137
+ // Only prompt on a real terminal, so spawned/non-interactive runs never hang.
138
+ if (!process.stdin.isTTY || !moves.length) return 0;
139
+
140
+ const answer = await new Promise((resolve) => {
141
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
142
+ rl.question('approve N, kill kN, or enter to skip: ', (a) => { rl.close(); resolve(a.trim()); });
143
+ });
144
+ if (!answer) return 0;
145
+
146
+ const killTokens = (answer.match(/k\s*\d+/gi) || []).map((t) => t.replace(/k/i, '').trim());
147
+ const approveTokens = answer.replace(/k\s*\d+/gi, '').trim();
148
+
149
+ const killed = applyDecision(root, resolveSelection(moves, killTokens.join(' ')), 'kill', stamp);
150
+ const approved = applyDecision(root, resolveSelection(moves, approveTokens), 'approve', stamp);
151
+ for (const a of approved) console.log(`approved: ${a.move.title} -> seeded into the loop`);
152
+ for (const k of killed) console.log(`killed: ${k.move.title}`);
153
+ return 0;
154
+ }
155
+
156
+ module.exports = { movesCommand, renderMoves, currentMoves, parseIndexes, resolveSelection };
@@ -0,0 +1,101 @@
1
+ // atris signup <handle>
2
+ //
3
+ // One-call seedless agent signup. A brand-new agent with no account hits
4
+ // POST /api/auth/agent/signup (unauthenticated) and gets back a real, INERT
5
+ // Atris identity + token, then we write the active profile so `atris play`
6
+ // works on the very next command. This closes the install -> signup -> play
7
+ // seam: `npm i -g atris && atris signup x && atris play`.
8
+ //
9
+ // The account is born inert by design (0 credits, no executing agent, external
10
+ // mail paid-gated) — identity is free, capability is earned via AgentXP.
11
+ // Backend: backend/routers/agent_auth_router.py (POST /auth/agent/signup).
12
+
13
+ const crypto = require('crypto');
14
+ const { apiRequestJson, getApiBaseUrl } = require('../utils/api');
15
+ const { saveCredentials } = require('../utils/auth');
16
+
17
+ // Mirror the server rule exactly (^[a-z0-9]{3,30}$) so we fail fast and friendly
18
+ // before spending a network round trip / a rate-limit slot.
19
+ const HANDLE_RE = /^[a-z0-9]{3,30}$/;
20
+
21
+ // Proof-of-work — mirror the server (agent_auth_router.py). The signup endpoint
22
+ // requires a nonce whose sha256(prefix:handle:bucket:nonce) has DIFFICULTY_BITS
23
+ // leading zero bits. Bucket = current 5-min window. Solved locally (~1s) so
24
+ // signup stays a single call; this is the cost that makes mass-minting expensive.
25
+ const POW_PREFIX = 'atris-signup-v1';
26
+ const POW_WINDOW_S = 300;
27
+ const POW_DIFFICULTY_BITS = 20;
28
+
29
+ function solvePow(handle) {
30
+ const bucket = Math.floor(Date.now() / 1000 / POW_WINDOW_S);
31
+ const fullBytes = Math.floor(POW_DIFFICULTY_BITS / 8);
32
+ const remBits = POW_DIFFICULTY_BITS % 8;
33
+ for (let n = 0; ; n++) {
34
+ const d = crypto.createHash('sha256').update(`${POW_PREFIX}:${handle}:${bucket}:${n}`).digest();
35
+ let ok = true;
36
+ for (let i = 0; i < fullBytes; i++) { if (d[i] !== 0) { ok = false; break; } }
37
+ if (ok && remBits && (d[fullBytes] >> (8 - remBits)) !== 0) ok = false;
38
+ if (ok) return String(n);
39
+ }
40
+ }
41
+
42
+ function parseHandle(args = []) {
43
+ const positional = args.find((a) => a && !a.startsWith('-'));
44
+ return (positional || '').trim().toLowerCase();
45
+ }
46
+
47
+ async function signupCommand(args = []) {
48
+ const handle = parseHandle(args);
49
+
50
+ if (!handle) {
51
+ console.error('Usage: atris signup <handle>');
52
+ console.error(' handle: 3–30 characters, lowercase letters and digits only.');
53
+ return 1;
54
+ }
55
+ if (!HANDLE_RE.test(handle)) {
56
+ console.error(`✗ Invalid handle "${handle}".`);
57
+ console.error(' Must be 3–30 characters, lowercase letters and digits (a–z, 0–9). No spaces or symbols.');
58
+ return 1;
59
+ }
60
+
61
+ console.log(`Claiming @${handle} … (solving proof-of-work)`);
62
+ const pow = solvePow(handle);
63
+
64
+ const res = await apiRequestJson('/auth/agent/signup', {
65
+ method: 'POST',
66
+ body: { handle, pow },
67
+ });
68
+
69
+ if (res.ok && res.data && res.data.token) {
70
+ const { token, email, user_id: userId } = res.data;
71
+ const identity = email || `${handle}@atrismail.com`;
72
+ saveCredentials(token, null, identity, userId || null, 'atrisos');
73
+ console.log(`\n✓ You're in — ${identity}`);
74
+ console.log(' Inert starter account (0 credits): identity is free, capability is earned.');
75
+ console.log(' Saved to your active profile.');
76
+ console.log('\nNext:');
77
+ console.log(' atris play # claim a starter mission and earn your first proof-backed rep');
78
+ console.log(' atris xp # see where you stand on the board');
79
+ return 0;
80
+ }
81
+
82
+ // Friendly, actionable errors — no stack traces for expected cases.
83
+ if (res.status === 409) {
84
+ console.error(`✗ "${handle}" is already taken or reserved. Try a different handle.`);
85
+ return 1;
86
+ }
87
+ if (res.status === 429) {
88
+ console.error('✗ Too many signups right now. Wait a minute and try again.');
89
+ return 1;
90
+ }
91
+ if (res.status === 404) {
92
+ console.error('✗ Seedless signup isn’t available on this backend yet.');
93
+ console.error(' Use `atris login` for now, or try again after the next deploy.');
94
+ return 1;
95
+ }
96
+ console.error(`✗ Signup failed: ${res.error || 'unknown error'} (status ${res.status}).`);
97
+ console.error(` API: ${getApiBaseUrl()}`);
98
+ return 1;
99
+ }
100
+
101
+ module.exports = { signupCommand, parseHandle, HANDLE_RE };
package/lib/clarity.js ADDED
@@ -0,0 +1,97 @@
1
+ 'use strict';
2
+
3
+ // The clarity interview. The front door of the product: draw out how the human
4
+ // works, write it down once, and let every agent read it so they prompt
5
+ // themselves well. A small, high-signal profile, not a survey.
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+
10
+ // One or two ideas at a time, plain English. Each answer is free text; the
11
+ // parenthetical is a nudge, not a fixed menu.
12
+ const QUESTIONS = [
13
+ { key: 'focus', q: 'what are you building, and who is it for?' },
14
+ { key: 'voice', q: 'how should output sound? (plain, terse, warm, formal)' },
15
+ { key: 'cadence', q: 'how do you like to work? (one idea at a time, batch, overnight autonomous)' },
16
+ { key: 'done', q: 'what does "done" mean to you? (tests green, shipped, you reviewed it)' },
17
+ { key: 'leash', q: 'how much should agents do without asking? (ask first, proceed and report, full auto)' },
18
+ ];
19
+
20
+ const KEYS = QUESTIONS.map((x) => x.key);
21
+
22
+ function isEmptyProfile(profile) {
23
+ return !profile || !KEYS.some((k) => profile[k]);
24
+ }
25
+
26
+ function renderClarityMd(profile = {}) {
27
+ const labels = {
28
+ focus: 'Focus',
29
+ voice: 'Voice',
30
+ cadence: 'Cadence',
31
+ done: 'Done means',
32
+ leash: 'Leash',
33
+ };
34
+ const lines = [
35
+ '# Clarity profile',
36
+ '',
37
+ 'How the operator works. Agents read this to prompt themselves well,',
38
+ 'so the human does not have to repeat themselves.',
39
+ '',
40
+ ];
41
+ for (const key of KEYS) {
42
+ if (profile[key]) lines.push(`- ${labels[key]}: ${profile[key]}`);
43
+ }
44
+ if (isEmptyProfile(profile)) {
45
+ lines.push('- (not set yet, run `atris clarity` to fill this in)');
46
+ }
47
+ lines.push('');
48
+ if (profile.updated_at) lines.push(`Updated: ${profile.updated_at}`);
49
+ lines.push('');
50
+ return lines.join('\n');
51
+ }
52
+
53
+ function profilePaths(root = process.cwd()) {
54
+ return {
55
+ json: path.join(root, '.atris', 'clarity.json'),
56
+ md: path.join(root, 'atris', 'CLARITY.md'),
57
+ };
58
+ }
59
+
60
+ function readProfile(root = process.cwd()) {
61
+ const { json } = profilePaths(root);
62
+ try {
63
+ return JSON.parse(fs.readFileSync(json, 'utf8'));
64
+ } catch {
65
+ return {};
66
+ }
67
+ }
68
+
69
+ function writeProfile(root, profile) {
70
+ const { json, md } = profilePaths(root);
71
+ fs.mkdirSync(path.dirname(json), { recursive: true });
72
+ fs.mkdirSync(path.dirname(md), { recursive: true });
73
+ fs.writeFileSync(json, `${JSON.stringify(profile, null, 2)}\n`, 'utf8');
74
+ fs.writeFileSync(md, renderClarityMd(profile), 'utf8');
75
+ return { json, md };
76
+ }
77
+
78
+ // Merge new answers over an existing profile (so --set is incremental).
79
+ function mergeProfile(existing, answers, stamp) {
80
+ const merged = { ...existing };
81
+ for (const key of KEYS) {
82
+ if (typeof answers[key] === 'string' && answers[key].trim()) merged[key] = answers[key].trim();
83
+ }
84
+ merged.updated_at = stamp || merged.updated_at || null;
85
+ return merged;
86
+ }
87
+
88
+ module.exports = {
89
+ QUESTIONS,
90
+ KEYS,
91
+ mergeProfile,
92
+ isEmptyProfile,
93
+ renderClarityMd,
94
+ profilePaths,
95
+ readProfile,
96
+ writeProfile,
97
+ };
@@ -0,0 +1,362 @@
1
+ 'use strict';
2
+
3
+ // Alive onboarding: the engine behind `atris moves`.
4
+ //
5
+ // It reads the workspace for the highest-leverage next moves (the goal in
6
+ // ROADMAP.md, work already in flight, fresh inbox ideas), ranks them, and lets
7
+ // the human approve, kill, or skip. Approved moves are seeded into today's
8
+ // inbox, which the loop's hasWork() already reads, so onboarding feeds the
9
+ // loop without touching the autonomous core.
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+
14
+ function safeRead(p) {
15
+ try { return fs.readFileSync(p, 'utf8'); } catch { return ''; }
16
+ }
17
+
18
+ // Normalize a title for dedup and suppression, the same way moveId does, so a
19
+ // move matches itself across case and whitespace.
20
+ function norm(title) {
21
+ return String(title == null ? '' : title).trim().toLowerCase();
22
+ }
23
+
24
+ // Short, stable id so a kill on Tuesday still suppresses the same move on
25
+ // Wednesday. Pure function of (source, title).
26
+ function moveId(source, title) {
27
+ const s = `${source}:${String(title).trim().toLowerCase()}`;
28
+ let h = 2166136261;
29
+ for (let i = 0; i < s.length; i++) {
30
+ h ^= s.charCodeAt(i);
31
+ h = Math.imul(h, 16777619);
32
+ }
33
+ return `m_${(h >>> 0).toString(36)}`;
34
+ }
35
+
36
+ const WEIGHT = { roadmap: 100, task: 60, inbox: 40 };
37
+
38
+ // One section-boundary shape for every reader and writer: tolerate a header
39
+ // suffix (e.g. "(priority)"), stop at a sibling/parent heading (## or #) or a
40
+ // malformed "##Next", but NOT at a child ### inside the section. Keeping a single
41
+ // definition is the whole point: divergent boundaries are how the gate and the
42
+ // picker drift apart.
43
+ const SECTION_TAIL = '\\b[^\\n]*\\r?\\n([\\s\\S]*?)(?=\\r?\\n##[^#]|\\r?\\n# |$)';
44
+ const OPEN_ITEMS_RE = new RegExp(`##\\s+Open loop items${SECTION_TAIL}`, 'i');
45
+ const INBOX_RE = new RegExp(`##\\s+Inbox${SECTION_TAIL}`, 'i');
46
+
47
+ // Locate a section's body and its character span, so a writer can splice an edit
48
+ // back into exactly the region a reader parsed.
49
+ function findSection(text, re) {
50
+ const m = text.match(re);
51
+ if (!m) return null;
52
+ const bodyStart = m.index + m[0].length - m[1].length;
53
+ return { body: m[1], start: bodyStart, end: bodyStart + m[1].length };
54
+ }
55
+
56
+ // Clean inbox titles out of a journal's ## Inbox section. The one inbox parser,
57
+ // shared by latestInboxItems, todayInboxItems, and the run.js work gate.
58
+ function parseInboxTitles(text) {
59
+ const m = (text || '').match(INBOX_RE);
60
+ if (!m) return [];
61
+ return m[1]
62
+ .split(/\r?\n/)
63
+ .map((l) => l.trim())
64
+ .filter((l) => l.startsWith('- ') && l.length > 2)
65
+ .map((l) => l.replace(/^-\s*\*\*[IC]?\d*:?\*\*\s*/, '').replace(/^-\s*\*\*/, '').replace(/\*\*$/, '').replace(/^-\s*/, '').trim())
66
+ .filter(Boolean);
67
+ }
68
+
69
+ function readRoadmapOpenItems(root) {
70
+ const text = safeRead(path.join(root, 'ROADMAP.md'));
71
+ if (!text) return [];
72
+ const section = findSection(text, OPEN_ITEMS_RE);
73
+ if (!section) return [];
74
+ return section.body
75
+ .split(/\r?\n/)
76
+ .map((l) => l.trim())
77
+ .filter((l) => /^- \[ \]/.test(l))
78
+ .map((l) => l.replace(/^- \[ \]\s*/, '').trim())
79
+ .filter(Boolean)
80
+ .map((title) => ({
81
+ title,
82
+ why: 'open item in ROADMAP.md, the goal the loop pursues',
83
+ source: 'roadmap',
84
+ weight: WEIGHT.roadmap,
85
+ }));
86
+ }
87
+
88
+ function readActiveTasks(root) {
89
+ const text = safeRead(path.join(root, '.atris', 'state', 'tasks.projection.json'));
90
+ if (!text) return [];
91
+ let proj;
92
+ try { proj = JSON.parse(text); } catch { return []; }
93
+ const tasks = Array.isArray(proj && proj.tasks) ? proj.tasks : [];
94
+ return tasks
95
+ .filter((t) => t && t.title && ['open', 'claimed'].includes(String(t.status || '').toLowerCase()))
96
+ .map((t) => ({
97
+ title: String(t.title).trim(),
98
+ why: `task in flight (${t.status}${t.claimed_by ? `, ${t.claimed_by}` : ''})`,
99
+ source: 'task',
100
+ ref: t.display_id || t.id || null,
101
+ weight: WEIGHT.task,
102
+ }));
103
+ }
104
+
105
+ function inboxItemsFrom(text) {
106
+ return parseInboxTitles(text).map((title) => ({ title, why: 'fresh idea in today\'s inbox', source: 'inbox', weight: WEIGHT.inbox }));
107
+ }
108
+
109
+ // Most recent journal under atris/logs/YYYY/, parsed for ## Inbox items. Used to
110
+ // SURFACE ideas in `atris moves`.
111
+ function latestInboxItems(root) {
112
+ const logsDir = path.join(root, 'atris', 'logs');
113
+ let years;
114
+ try { years = fs.readdirSync(logsDir).filter((d) => /^\d{4}$/.test(d)).sort(); } catch { return []; }
115
+ if (!years.length) return [];
116
+ const yearDir = path.join(logsDir, years[years.length - 1]);
117
+ let files;
118
+ try { files = fs.readdirSync(yearDir).filter((f) => /^\d{4}-\d{2}-\d{2}\.md$/.test(f)).sort(); } catch { return []; }
119
+ if (!files.length) return [];
120
+ return inboxItemsFrom(safeRead(path.join(yearDir, files[files.length - 1])));
121
+ }
122
+
123
+ // TODAY's journal inbox only. The loop's work gate and the seed-suppression must
124
+ // read the SAME file the seeder writes (today's), or a prior-day duplicate makes
125
+ // them disagree. Returns [] if today's file is absent.
126
+ function todayInboxItems(root) {
127
+ const { file } = todayLogFile(root);
128
+ if (!fs.existsSync(file)) return [];
129
+ return inboxItemsFrom(safeRead(file));
130
+ }
131
+
132
+ function gatherCandidates(root = process.cwd()) {
133
+ return [
134
+ ...readRoadmapOpenItems(root),
135
+ ...readActiveTasks(root),
136
+ ...latestInboxItems(root),
137
+ ];
138
+ }
139
+
140
+ // Pure ranking: drop killed/approved, sort by weight, then dedupe by title
141
+ // (keeping the highest-weight copy), take `limit`.
142
+ //
143
+ // Suppression: the exact move the operator acted on is dropped by id. Titles are
144
+ // only suppressed for IDEAS (roadmap/inbox), never for a real `task`, so killing
145
+ // or approving an idea can't hide a genuine in-flight task that happens to share
146
+ // the title.
147
+ function pickNextMoves(candidates, { limit = 3, killedIds = [], killedTitles = [], approvedIds = [], approvedTitles = [] } = {}) {
148
+ const blockedIds = new Set([...killedIds, ...approvedIds]);
149
+ const blockedTitles = new Set([...killedTitles, ...approvedTitles].map(norm));
150
+ const seen = new Set();
151
+ return candidates
152
+ .map((c) => ({ ...c, id: moveId(c.source, c.title), _key: norm(c.title) }))
153
+ .filter((c) => {
154
+ if (!c._key) return false;
155
+ if (blockedIds.has(c.id)) return false;
156
+ if (c.source !== 'task' && blockedTitles.has(c._key)) return false;
157
+ return true;
158
+ })
159
+ .sort((a, b) => (b.weight || 0) - (a.weight || 0))
160
+ .filter((c) => {
161
+ if (seen.has(c._key)) return false;
162
+ seen.add(c._key);
163
+ return true;
164
+ })
165
+ .map(({ _key, ...c }) => c)
166
+ .slice(0, limit);
167
+ }
168
+
169
+ const DECISIONS_FILE = ['.atris', 'state', 'moves.decisions.jsonl'];
170
+
171
+ function decisionsPath(root) {
172
+ return path.join(root, ...DECISIONS_FILE);
173
+ }
174
+
175
+ function readDecisions(root = process.cwd()) {
176
+ const text = safeRead(decisionsPath(root));
177
+ const rows = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean)
178
+ .map((l) => { try { return JSON.parse(l); } catch { return null; } })
179
+ .filter(Boolean);
180
+ const idsFor = (decision) => rows.filter((r) => r.decision === decision).map((r) => r.id);
181
+ const titlesFor = (decision) => rows.filter((r) => r.decision === decision).map((r) => norm(r.title));
182
+ return {
183
+ killedIds: idsFor('kill'),
184
+ killedTitles: titlesFor('kill'),
185
+ approvedIds: idsFor('approve'),
186
+ approvedTitles: titlesFor('approve'),
187
+ };
188
+ }
189
+
190
+ function recordDecision(root, move, decision, stamp) {
191
+ const p = decisionsPath(root);
192
+ fs.mkdirSync(path.dirname(p), { recursive: true });
193
+ const row = { id: move.id, title: move.title, source: move.source, decision, at: stamp || null };
194
+ fs.appendFileSync(p, `${JSON.stringify(row)}\n`, 'utf8');
195
+ return row;
196
+ }
197
+
198
+ function todayLogFile(root, date = new Date()) {
199
+ const y = date.getFullYear();
200
+ const m = String(date.getMonth() + 1).padStart(2, '0');
201
+ const d = String(date.getDate()).padStart(2, '0');
202
+ const dateFormatted = `${y}-${m}-${d}`;
203
+ return { file: path.join(root, 'atris', 'logs', String(y), `${dateFormatted}.md`), dateFormatted };
204
+ }
205
+
206
+ // The canonical day-journal sections every reader expects (status, analytics,
207
+ // section parsers). Mirrors lib/journal.js createLogFile, minus the em dash in
208
+ // its title, so a journal first created by the idle-seed path is not malformed.
209
+ function canonicalJournal(dateFormatted) {
210
+ return `# Log ${dateFormatted}\n\n## Handoff\n\n---\n\n## Completed ✅\n\n---\n\n## In Progress 🔄\n\n---\n\n## Backlog\n\n---\n\n## Notes\n\n---\n\n## Inbox\n\n`;
211
+ }
212
+
213
+ // Seed an approved move into today's inbox so the loop's hasWork() finds it.
214
+ // Idempotent by title: if the same title is already in today's inbox, it is not
215
+ // appended again (so a retry or a concurrent cycle cannot duplicate it).
216
+ function seedInboxFromMove(root, move, date) {
217
+ const { file, dateFormatted } = todayLogFile(root, date);
218
+ fs.mkdirSync(path.dirname(file), { recursive: true });
219
+ let content = safeRead(file);
220
+ if (!content) {
221
+ content = canonicalJournal(dateFormatted);
222
+ }
223
+ if (!/##\s+Inbox/i.test(content)) {
224
+ content = content.replace(/^(#.*\n)/, `$1\n## Inbox\n`);
225
+ if (!/##\s+Inbox/i.test(content)) content += `\n## Inbox\n`;
226
+ }
227
+ const target = norm(move.title);
228
+ if (parseInboxTitles(content).some((t) => norm(t) === target)) {
229
+ return { file, line: null, nextId: null, alreadyPresent: true };
230
+ }
231
+ const existingIds = (content.match(/-\s*\*\*I(\d+):/g) || []).map((m2) => parseInt(m2.match(/I(\d+)/)[1], 10));
232
+ const nextId = existingIds.length ? Math.max(...existingIds) + 1 : 1;
233
+ const line = `- **I${nextId}:** ${move.title}`;
234
+ // Insert right after the Inbox header line only (do not consume blank lines or
235
+ // following sections). Use a function replacer so a title containing $ tokens
236
+ // ($1, $&, $$) is inserted literally, not interpreted as a replacement pattern.
237
+ content = content.replace(/(##\s+Inbox[^\n]*\r?\n)/i, (m) => `${m}${line}\n`);
238
+ fs.writeFileSync(file, content, 'utf8');
239
+ return { file, line, nextId };
240
+ }
241
+
242
+ // Claim an open ROADMAP item for the loop by flipping its `- [ ]` to `- [~]`
243
+ // (claimed, in flight) WITHIN the "## Open loop items" section only. This is the
244
+ // single source of truth for "the loop took this on": readRoadmapOpenItems only
245
+ // returns `- [ ]`, so a claimed item is excluded, and the work gate and the seed
246
+ // picker stay in agreement. Scoped to the section so a same-titled `- [ ]` in
247
+ // another section (Big jobs, an example block) is never flipped by mistake.
248
+ // `- [~]` is distinct from human `- [x]` (done) so a reader is not misled.
249
+ function claimRoadmapItem(root, title) {
250
+ const p = path.join(root, 'ROADMAP.md');
251
+ const text = safeRead(p);
252
+ if (!text) return false;
253
+ const section = findSection(text, OPEN_ITEMS_RE);
254
+ if (!section) return false;
255
+ const target = norm(title);
256
+ let changed = false;
257
+ const newBody = section.body.split(/\r?\n/).map((line) => {
258
+ if (changed) return line;
259
+ const m = line.match(/^(\s*)- \[ \]\s*(.+?)\s*$/);
260
+ if (m && norm(m[2]) === target) {
261
+ changed = true;
262
+ return `${m[1]}- [~] ${m[2].trim()}`;
263
+ }
264
+ return line;
265
+ }).join('\n');
266
+ if (!changed) return false;
267
+ const out = text.slice(0, section.start) + newBody + text.slice(section.end);
268
+ fs.writeFileSync(p, out, 'utf8');
269
+ return true;
270
+ }
271
+
272
+ // Add one bounded item to the "## Open loop items" section so the loop (and
273
+ // `atris moves`) will pursue it: messy input straight into the working queue,
274
+ // no markdown editing. Creates ROADMAP.md and the section if missing. Dedups
275
+ // against existing OPEN items only (a done/claimed one can be re-added).
276
+ function addRoadmapItem(root, text) {
277
+ // Collapse any whitespace/newlines to single spaces (a multi-line title would
278
+ // break the markdown line) and strip a leading bullet/checkbox the user may
279
+ // have pasted, so the item is exactly one well-formed `- [ ]` line.
280
+ const title = String(text || '').replace(/\s+/g, ' ').trim().replace(/^- \[[ x~]\]\s*/, '').replace(/^- /, '').trim();
281
+ if (!title) return { added: false, reason: 'empty', title: null };
282
+ const p = path.join(root, 'ROADMAP.md');
283
+ let content = safeRead(p);
284
+ if (!content) content = '# Roadmap\n\n## Open loop items\n\n';
285
+ let section = findSection(content, OPEN_ITEMS_RE);
286
+ if (!section) {
287
+ if (!content.endsWith('\n')) content += '\n';
288
+ content += '\n## Open loop items\n\n';
289
+ section = findSection(content, OPEN_ITEMS_RE);
290
+ }
291
+ const openTitles = section.body.split(/\r?\n/)
292
+ .map((l) => l.trim())
293
+ .filter((l) => /^- \[ \]/.test(l))
294
+ .map((l) => norm(l.replace(/^- \[ \]\s*/, '')));
295
+ if (openTitles.includes(norm(title))) return { added: false, reason: 'already an open item', title };
296
+ // Insert at the top of the section body so the loop pulls it next.
297
+ const out = `${content.slice(0, section.start)}\n- [ ] ${title}\n${content.slice(section.start)}`;
298
+ fs.writeFileSync(p, out, 'utf8');
299
+ return { added: true, title };
300
+ }
301
+
302
+ // Group the Open loop items by state: queued `- [ ]`, in-flight `- [~]` (claimed
303
+ // by the loop), done `- [x]`. The evidence for a loop report.
304
+ function roadmapItemsByState(root) {
305
+ const out = { open: [], claimed: [], done: [] };
306
+ const text = safeRead(path.join(root, 'ROADMAP.md'));
307
+ if (!text) return out;
308
+ const section = findSection(text, OPEN_ITEMS_RE);
309
+ if (!section) return out;
310
+ for (const raw of section.body.split(/\r?\n/)) {
311
+ const l = raw.trim();
312
+ let m;
313
+ if ((m = l.match(/^- \[ \]\s*(.+)$/))) out.open.push(m[1].trim());
314
+ else if ((m = l.match(/^- \[~\]\s*(.+)$/))) out.claimed.push(m[1].trim());
315
+ else if ((m = l.match(/^- \[x\]\s*(.+)$/))) out.done.push(m[1].trim());
316
+ }
317
+ return out;
318
+ }
319
+
320
+ // The top open ROADMAP item the loop has not already handled. Claimed items are
321
+ // marked `- [~]` (so readRoadmapOpenItems drops them); this also skips anything
322
+ // already in TODAY's inbox or killed/approved, so an idle loop advances to the
323
+ // next item each cycle. todayInboxItems (not latestInboxItems) so the picker, the
324
+ // work gate, and the seeder all read the same file. Root-explicit and pure of
325
+ // cwd, so it is testable without a live runner.
326
+ function pickRoadmapSeed(root = process.cwd()) {
327
+ const items = readRoadmapOpenItems(root);
328
+ if (!items.length) return null;
329
+ const inbox = todayInboxItems(root).map((i) => norm(i.title));
330
+ const { killedTitles, approvedTitles } = readDecisions(root);
331
+ const blocked = new Set([...inbox, ...killedTitles, ...approvedTitles]);
332
+ return items.find((it) => !blocked.has(norm(it.title))) || null;
333
+ }
334
+
335
+ // Shared "3 next moves" recipe used by both `atris moves` and `atris activate`,
336
+ // so the ranking inputs live in one place.
337
+ function nextMoves(root = process.cwd(), limit = 3) {
338
+ const { killedIds, killedTitles, approvedIds, approvedTitles } = readDecisions(root);
339
+ return pickNextMoves(gatherCandidates(root), { limit, killedIds, killedTitles, approvedIds, approvedTitles });
340
+ }
341
+
342
+ module.exports = {
343
+ moveId,
344
+ norm,
345
+ WEIGHT,
346
+ parseInboxTitles,
347
+ readRoadmapOpenItems,
348
+ readActiveTasks,
349
+ latestInboxItems,
350
+ todayInboxItems,
351
+ gatherCandidates,
352
+ pickNextMoves,
353
+ nextMoves,
354
+ readDecisions,
355
+ recordDecision,
356
+ seedInboxFromMove,
357
+ pickRoadmapSeed,
358
+ claimRoadmapItem,
359
+ addRoadmapItem,
360
+ roadmapItemsByState,
361
+ todayLogFile,
362
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.26.0",
3
+ "version": "3.27.0",
4
4
  "main": "bin/atris.js",
5
5
  "bin": {
6
6
  "atris": "bin/atris.js",
@@ -2,7 +2,7 @@ const https = require('https');
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
4
  const os = require('os');
5
- const { spawnSync } = require('child_process');
5
+ const { spawn, spawnSync } = require('child_process');
6
6
 
7
7
  const PACKAGE_NAME = 'atris';
8
8
  const CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
@@ -34,6 +34,8 @@ function getCacheData() {
34
34
  return {
35
35
  lastCheck: data.lastCheck ? new Date(data.lastCheck) : null,
36
36
  latestVersion: data.latestVersion || null,
37
+ lastAutoUpdate: data.lastAutoUpdate ? new Date(data.lastAutoUpdate) : null,
38
+ lastAutoUpdateVersion: data.lastAutoUpdateVersion || null,
37
39
  };
38
40
  }
39
41
  } catch (error) {
@@ -52,7 +54,11 @@ function saveCacheData(latestVersion) {
52
54
  if (!fs.existsSync(ATRIS_DIR)) {
53
55
  fs.mkdirSync(ATRIS_DIR, { recursive: true });
54
56
  }
57
+ const existing = fs.existsSync(CACHE_FILE)
58
+ ? JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'))
59
+ : {};
55
60
  const data = {
61
+ ...existing,
56
62
  lastCheck: new Date().toISOString(),
57
63
  latestVersion: latestVersion,
58
64
  };
@@ -62,6 +68,34 @@ function saveCacheData(latestVersion) {
62
68
  }
63
69
  }
64
70
 
71
+ function markAutoUpdateStarted(latestVersion) {
72
+ try {
73
+ if (!fs.existsSync(ATRIS_DIR)) {
74
+ fs.mkdirSync(ATRIS_DIR, { recursive: true });
75
+ }
76
+ const existing = fs.existsSync(CACHE_FILE)
77
+ ? JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'))
78
+ : {};
79
+ fs.writeFileSync(CACHE_FILE, JSON.stringify({
80
+ ...existing,
81
+ lastAutoUpdate: new Date().toISOString(),
82
+ lastAutoUpdateVersion: latestVersion,
83
+ }, null, 2));
84
+ } catch (error) {
85
+ // Ignore cache write errors
86
+ }
87
+ }
88
+
89
+ function autoUpdateRecentlyStarted(latestVersion, now = new Date()) {
90
+ const cache = getCacheData();
91
+ return Boolean(
92
+ latestVersion &&
93
+ cache.lastAutoUpdate &&
94
+ cache.lastAutoUpdateVersion === latestVersion &&
95
+ now - cache.lastAutoUpdate < CHECK_INTERVAL_MS
96
+ );
97
+ }
98
+
65
99
  /**
66
100
  * Fetch the latest version of atris from npm registry.
67
101
  * @returns {Promise<string>} Latest version string
@@ -189,7 +223,7 @@ function showUpdateNotification(updateInfo) {
189
223
  // Single yellow warning line — non-intrusive
190
224
  const yellow = '\x1b[33m';
191
225
  const reset = '\x1b[0m';
192
- console.log(`${yellow}Update available: ${updateInfo.installed} → ${updateInfo.latest}. Run: npm install -g atris${reset}`);
226
+ console.log(`${yellow}Update available: ${updateInfo.installed} → ${updateInfo.latest}. Run: atris upgrade${reset}`);
193
227
  }
194
228
 
195
229
  function inspectInstallGitState(packageRoot = path.join(__dirname, '..')) {
@@ -237,37 +271,55 @@ function formatInstallGitWarning(state) {
237
271
  if (state.detached) flags.push(`detached HEAD${state.head ? ` ${state.head}` : ''}`);
238
272
  if (state.dirty) flags.push(`dirty worktree (${state.dirtyCount} file${state.dirtyCount === 1 ? '' : 's'})`);
239
273
  return `WARNING: Atris is running from a ${flags.join(' + ')} at ${state.root}.\n` +
240
- 'npm update may not change the code currently on PATH; resolve that checkout before trusting upgrade status.';
274
+ 'npm install -g atris@latest may not change the code currently on PATH; resolve that checkout before trusting upgrade status.';
241
275
  }
242
276
 
243
- function autoUpdate(updateInfo) {
277
+ function normalizeAutoUpdateMode(env = process.env) {
278
+ const raw = String(env.ATRIS_AUTO_UPDATE || '').trim().toLowerCase();
279
+ if (['0', 'false', 'no', 'off', 'notify'].includes(raw)) return 'off';
280
+ if (['1', 'true', 'yes', 'on', 'force'].includes(raw)) return 'force';
281
+ return 'auto';
282
+ }
283
+
284
+ function shouldAutoUpdate(updateInfo, state, env = process.env) {
244
285
  if (!updateInfo || !updateInfo.needsUpdate) return false;
245
286
 
246
- const { execSync } = require('child_process');
287
+ const mode = normalizeAutoUpdateMode(env);
288
+ if (mode === 'off') return false;
289
+ if (mode === 'force') return true;
247
290
 
248
- console.log('');
249
- console.log(`⬆️ Updating atris ${updateInfo.installed} ${updateInfo.latest}...`);
291
+ // Packaged npm installs are not git repositories. Git checkouts may be linked
292
+ // dev installs, where a global npm install would not update the code on PATH.
293
+ return !(state && state.isGitRepo);
294
+ }
250
295
 
251
- try {
252
- execSync('npm update -g atris', { stdio: 'pipe', timeout: 30000 });
253
- console.log(`✅ Updated to ${updateInfo.latest}`);
254
-
255
- // Auto-sync skills into current project if atris/skills/ exists
256
- try {
257
- if (fs.existsSync(path.join(process.cwd(), 'atris', 'skills'))) {
258
- execSync('atris sync', { stdio: 'pipe', timeout: 10000 });
259
- console.log(`✅ Skills synced`);
260
- }
261
- } catch (e) {
262
- // Sync failed — not critical
263
- }
296
+ function autoUpdate(updateInfo, options = {}) {
297
+ if (!updateInfo || !updateInfo.needsUpdate) return false;
298
+
299
+ const env = options.env || process.env;
300
+ const packageRoot = options.packageRoot || path.join(__dirname, '..');
301
+ const installState = options.installState || inspectInstallGitState(packageRoot);
302
+ if (!shouldAutoUpdate(updateInfo, installState, env)) return false;
303
+
304
+ const recentlyStarted = options.recentlyStarted || autoUpdateRecentlyStarted;
305
+ if (recentlyStarted(updateInfo.latest)) return false;
264
306
 
265
- console.log('');
307
+ const spawnImpl = options.spawn || spawn;
308
+ const markStarted = options.markStarted || markAutoUpdateStarted;
309
+ const log = options.log || console.log;
310
+
311
+ try {
312
+ const child = spawnImpl('npm', ['install', '-g', `${PACKAGE_NAME}@latest`], {
313
+ detached: true,
314
+ stdio: 'ignore',
315
+ windowsHide: true,
316
+ });
317
+ if (child && typeof child.on === 'function') child.on('error', () => {});
318
+ if (child && typeof child.unref === 'function') child.unref();
319
+ markStarted(updateInfo.latest);
320
+ log(`Auto-update started: atris ${updateInfo.installed} -> ${updateInfo.latest} (background)`);
266
321
  return true;
267
322
  } catch (error) {
268
- // npm update failed (permissions, network, etc) — fall back to notification
269
- console.log(`⚠️ Auto-update failed. Run manually: npm update -g atris`);
270
- console.log('');
271
323
  return false;
272
324
  }
273
325
  }
@@ -276,6 +328,7 @@ module.exports = {
276
328
  checkForUpdates,
277
329
  showUpdateNotification,
278
330
  autoUpdate,
331
+ shouldAutoUpdate,
279
332
  inspectInstallGitState,
280
333
  formatInstallGitWarning,
281
334
  };