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 +2 -0
- package/bin/atris.js +26 -13
- package/commands/activate.js +24 -0
- package/commands/clarity.js +125 -0
- package/commands/moves.js +156 -0
- package/commands/signup.js +101 -0
- package/lib/clarity.js +97 -0
- package/lib/next-moves.js +362 -0
- package/package.json +1 -1
- package/utils/update-check.js +77 -24
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
2171
|
-
const result = spawnSync('npm', ['
|
|
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
|
|
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
|
|
2201
|
+
console.log(' sudo npm install -g atris@latest');
|
|
2189
2202
|
console.log('');
|
|
2190
2203
|
}
|
|
2191
2204
|
}
|
package/commands/activate.js
CHANGED
|
@@ -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
package/utils/update-check.js
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
|
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
|
|
287
|
+
const mode = normalizeAutoUpdateMode(env);
|
|
288
|
+
if (mode === 'off') return false;
|
|
289
|
+
if (mode === 'force') return true;
|
|
247
290
|
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
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
|
};
|