atris 3.82.26 → 3.83.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.
Files changed (39) hide show
  1. package/AGENTS.md +10 -12
  2. package/README.md +77 -0
  3. package/bin/atris.js +95 -44
  4. package/commands/activate.js +39 -33
  5. package/commands/agent-home.js +403 -0
  6. package/commands/agent-signin.js +214 -0
  7. package/commands/agent-spawn.js +1 -0
  8. package/commands/autoland.js +173 -71
  9. package/commands/brain.js +211 -410
  10. package/commands/doctor.js +38 -26
  11. package/commands/init.js +4 -4
  12. package/commands/join.js +159 -0
  13. package/commands/memory.js +2 -2
  14. package/commands/morning.js +132 -0
  15. package/commands/night.js +178 -0
  16. package/commands/recap.js +4 -4
  17. package/commands/task.js +31 -2
  18. package/lib/agent-home.js +289 -0
  19. package/lib/agent-page.js +1257 -0
  20. package/lib/cleanup-summary.js +4 -2
  21. package/lib/first-minute.js +41 -20
  22. package/lib/gm-phone-page.js +8 -2
  23. package/lib/known-commands.js +1 -1
  24. package/lib/member-export.js +31 -0
  25. package/lib/memory-folder.js +122 -24
  26. package/lib/memory-live.js +2 -2
  27. package/lib/night-learned.js +171 -0
  28. package/lib/night-shift.js +123 -0
  29. package/lib/task-core.js +2 -1
  30. package/lib/task-db.js +32 -0
  31. package/package.json +1 -1
  32. package/templates/members/designer-1/MEMBER.md +34 -0
  33. package/templates/members/designer-1/MISSION.md +11 -0
  34. package/templates/members/designer-1/SOUL.md +11 -0
  35. package/templates/members/designer-1/context/why.md +5 -0
  36. package/templates/members/designer-1/goals.json +5 -0
  37. package/templates/members/designer-1/goals.md +3 -0
  38. package/templates/members/designer-1/skills/designer-1/SKILL.md +179 -0
  39. package/templates/members/designer-1/skills/designer-1/parts.md +55 -0
@@ -0,0 +1,289 @@
1
+ 'use strict';
2
+
3
+ // An Atris agent's home folder on this computer: the rules for which files
4
+ // belong in it, the small state file that remembers what the server had at
5
+ // the last pull, the adapter blocks that let any engine boot as the agent,
6
+ // and the private key file a spawn returns.
7
+ //
8
+ // The server copy (POST/PUT /api/agents/{id}/home/...) is the durable one.
9
+ // This module never talks to the network; commands/agent-home.js does.
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const crypto = require('crypto');
14
+ const { atrisHome } = require('./state-home');
15
+ const { frontmatterOf, identityText } = require('./member-export');
16
+
17
+ const STATE_REL = path.join('.atris', 'agent-home.json');
18
+ const MAX_FILE_BYTES = 1000000;
19
+ const MAX_FILES = 2000;
20
+ const MAX_DEPTH = 24;
21
+ // The adapters are generated here for the local engine and never sent up.
22
+ const ADAPTER_FILES = ['AGENTS.md', 'CLAUDE.md'];
23
+ const BLOCK_START = '<!-- atris agent home: start. generated by atris agent pull from MEMBER.md, SOUL.md and MISSION.md; edit those, this block gets rewritten -->';
24
+ const BLOCK_END = '<!-- atris agent home: end -->';
25
+
26
+ const utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
27
+
28
+ function sha256(data) {
29
+ return crypto.createHash('sha256').update(data).digest('hex');
30
+ }
31
+
32
+ // A path that may live in a home, or null. Matches the server's refusals:
33
+ // absolute, '..', hidden parts (.env*, .atris/, .git/), secrets/, *.pem, *.key.
34
+ function homeRel(rel) {
35
+ if (typeof rel !== 'string' || !rel || rel.length > 400) return null;
36
+ if (rel.startsWith('/') || rel.includes('\\') || rel.includes('\0') || /^[A-Za-z]:/.test(rel)) return null;
37
+ const parts = rel.split('/');
38
+ if (parts.length > MAX_DEPTH) return null;
39
+ if (parts.some((part) => part === '' || part === '.' || part === '..' || part.startsWith('.'))) return null;
40
+ if (parts.slice(0, -1).includes('secrets')) return null;
41
+ if (/\.(pem|key)$/i.test(parts[parts.length - 1])) return null;
42
+ return rel;
43
+ }
44
+
45
+ function isAdapter(rel) {
46
+ return ADAPTER_FILES.includes(rel);
47
+ }
48
+
49
+ function statePath(dir) {
50
+ return path.join(dir, STATE_REL);
51
+ }
52
+
53
+ // { agent_id, handle, files: { rel: { etag, sha256 } }, pulled_at } or null.
54
+ function loadState(dir) {
55
+ try {
56
+ const state = JSON.parse(fs.readFileSync(targetIn(dir, '.atris/agent-home.json'), 'utf8'));
57
+ if (!state || typeof state.agent_id !== 'string' || !state.agent_id) return null;
58
+ if (!state.files || typeof state.files !== 'object') state.files = {};
59
+ return state;
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ function saveState(dir, state) {
66
+ const file = targetIn(dir, '.atris/agent-home.json');
67
+ fs.mkdirSync(path.dirname(file), { recursive: true });
68
+ const tmp = `${file}.${crypto.randomBytes(16).toString('hex')}.tmp`;
69
+ fs.writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, { flag: 'wx' });
70
+ fs.renameSync(tmp, file);
71
+ }
72
+
73
+ // The nearest folder at or above `start` that holds a pulled home.
74
+ function findHomeDir(start) {
75
+ let dir = path.resolve(start);
76
+ for (;;) {
77
+ if (loadState(dir)) return dir;
78
+ const parent = path.dirname(dir);
79
+ if (parent === dir) return null;
80
+ dir = parent;
81
+ }
82
+ }
83
+
84
+ // Absolute path for `rel` inside `rootReal`, refusing any link on the way so
85
+ // a server path can never write outside the folder.
86
+ function targetIn(rootReal, rel) {
87
+ let current = rootReal;
88
+ for (const part of rel.split('/').slice(0, -1)) {
89
+ current = path.join(current, part);
90
+ let stat = null;
91
+ try { stat = fs.lstatSync(current); } catch {}
92
+ if (stat && !stat.isDirectory()) throw new Error(`${rel} would pass through a file or link`);
93
+ }
94
+ const abs = path.join(rootReal, rel);
95
+ let stat = null;
96
+ try { stat = fs.lstatSync(abs); } catch {}
97
+ if (stat && !stat.isFile()) throw new Error(`${rel} is not a plain file here`);
98
+ return abs;
99
+ }
100
+
101
+ function writeText(rootReal, rel, text) {
102
+ const abs = targetIn(rootReal, rel);
103
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
104
+ fs.writeFileSync(abs, text, 'utf8');
105
+ }
106
+
107
+ // Local text, or null when the file is missing.
108
+ function readLocal(rootReal, rel) {
109
+ try {
110
+ return fs.readFileSync(targetIn(rootReal, rel), 'utf8');
111
+ } catch {
112
+ return null;
113
+ }
114
+ }
115
+
116
+ // Every file under the home that could go up: { files: Map(rel -> text), skipped: [{path, reason}] }.
117
+ function scanHome(rootReal) {
118
+ const files = new Map();
119
+ const skipped = [];
120
+ const walk = (dir, prefix, depth) => {
121
+ let entries = [];
122
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
123
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
124
+ for (const entry of entries) {
125
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
126
+ if (entry.name.startsWith('.')) continue;
127
+ if (!prefix && isAdapter(rel)) continue;
128
+ if (entry.isSymbolicLink()) { skipped.push({ path: rel, reason: 'is a link' }); continue; }
129
+ if (entry.isDirectory()) {
130
+ if (depth >= MAX_DEPTH || entry.name === 'secrets') { skipped.push({ path: rel, reason: 'is not allowed in a home' }); continue; }
131
+ walk(path.join(dir, entry.name), rel, depth + 1);
132
+ continue;
133
+ }
134
+ if (!entry.isFile()) continue;
135
+ if (!homeRel(rel)) { skipped.push({ path: rel, reason: 'is not allowed in a home' }); continue; }
136
+ if (files.size >= MAX_FILES) { skipped.push({ path: rel, reason: `is past the ${MAX_FILES} file limit` }); continue; }
137
+ const abs = path.join(dir, entry.name);
138
+ let data;
139
+ try { data = fs.readFileSync(abs); } catch { skipped.push({ path: rel, reason: 'could not be read' }); continue; }
140
+ if (data.length > MAX_FILE_BYTES) { skipped.push({ path: rel, reason: 'is over 1 MB' }); continue; }
141
+ let text;
142
+ try { text = utf8.decode(data); } catch { skipped.push({ path: rel, reason: 'is not text' }); continue; }
143
+ files.set(rel, text);
144
+ }
145
+ };
146
+ walk(rootReal, '', 1);
147
+ return { files, skipped };
148
+ }
149
+
150
+ // What changed here since the last pull or push: new or edited files to send,
151
+ // and files the state knows that were deleted here.
152
+ function localChanges(rootReal, state) {
153
+ const { files, skipped } = scanHome(rootReal);
154
+ const changed = [];
155
+ for (const [rel, text] of files) {
156
+ const base = state.files[rel];
157
+ if (!base || base.sha256 !== sha256(text)) changed.push({ path: rel, text });
158
+ }
159
+ const deleted = Object.keys(state.files).filter((rel) => homeRel(rel) && !isAdapter(rel) && !files.has(rel) && readLocal(rootReal, rel) === null).sort();
160
+ return { changed, deleted, skipped };
161
+ }
162
+
163
+ // A short, file-name-safe handle: "Scout Two" -> "scout-two".
164
+ function handleOf(name) {
165
+ return String(name || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64);
166
+ }
167
+
168
+ // ---- adapters ---------------------------------------------------------------
169
+
170
+ // The block both adapters carry: who the agent is, its soul, mission and
171
+ // goals in full, and the folder rules. Engines read one of AGENTS.md (Codex,
172
+ // Muse, Grok) or CLAUDE.md (Claude Code), so each carries the whole identity.
173
+ function adapterBlock(rootReal) {
174
+ const memberText = readLocal(rootReal, 'MEMBER.md');
175
+ if (memberText === null) return null;
176
+ const { fields, body } = frontmatterOf(memberText);
177
+ const name = String(fields.name || '').trim() || 'this agent';
178
+ const role = String(fields.role || '').trim();
179
+ const lines = [
180
+ BLOCK_START,
181
+ '',
182
+ `# You are ${name}${role ? `, ${role}` : ''}`,
183
+ '',
184
+ `You are the Atris agent ${name}${fields.member_id ? ` (id ${fields.member_id})` : ''}. This folder is your home: your manifest, soul, mission, skills, memory and journal.`,
185
+ 'Act as this agent for the whole session. Read MEMBER.md, SOUL.md and MISSION.md below as your own.',
186
+ '',
187
+ '## Folder rules',
188
+ '',
189
+ '- MEMBER.md is your manifest. Only your owner may change it; the server refuses your edits to it.',
190
+ '- Consolidated lessons go in memory/MEMORY.md.',
191
+ '- Each session adds one new note at memory/notes/<utc-timestamp>-<session>.md. Never edit another session\'s note.',
192
+ '- The day\'s journal is journal/YYYY/MM/DD.md.',
193
+ '- Secrets never go in this folder: no keys, tokens, .env files or secrets/.',
194
+ '- Save your work with `atris agent push`, then mark a version with `atris agent snapshot`.',
195
+ '- Also read when it helps: card.md, memory/MEMORY.md, skills/*/SKILL.md, tools/, context/.',
196
+ '',
197
+ '## Manifest (MEMBER.md)',
198
+ '',
199
+ body.trim() || '(empty)',
200
+ '',
201
+ ];
202
+ for (const [file, title] of [['SOUL.md', 'Soul'], ['MISSION.md', 'Mission'], ['goals.md', 'Goals']]) {
203
+ const text = identityText(rootReal, file, readLocal(rootReal, file));
204
+ if (text) lines.push(`## ${title} (${file})`, '', text, '');
205
+ }
206
+ lines.push(BLOCK_END);
207
+ return lines.join('\n');
208
+ }
209
+
210
+ // File text with the block inserted or replaced. Anything outside the block,
211
+ // like a repo's own CLAUDE.md, is kept as it was.
212
+ function withBlock(existing, block) {
213
+ if (existing === null || !existing.trim()) return `${block}\n`;
214
+ const start = existing.indexOf(BLOCK_START);
215
+ const end = existing.indexOf(BLOCK_END, start);
216
+ if (start >= 0 && end > start) {
217
+ return existing.slice(0, start) + block + existing.slice(end + BLOCK_END.length);
218
+ }
219
+ return `${existing.replace(/\s*$/, '')}\n\n${block}\n`;
220
+ }
221
+
222
+ // Write or refresh both adapters. Returns the files written, or [] when the
223
+ // folder has no MEMBER.md yet.
224
+ function writeAdapters(rootReal) {
225
+ const block = adapterBlock(rootReal);
226
+ if (!block) return [];
227
+ const written = [];
228
+ for (const rel of ADAPTER_FILES) {
229
+ const existing = readLocal(rootReal, rel);
230
+ const next = withBlock(existing, block);
231
+ if (next !== existing) {
232
+ writeText(rootReal, rel, next);
233
+ written.push(rel);
234
+ }
235
+ }
236
+ return written;
237
+ }
238
+
239
+ // ---- private keys -------------------------------------------------------------
240
+
241
+ function keysDir(env = process.env) {
242
+ return path.join(atrisHome(env), 'agents');
243
+ }
244
+
245
+ function keyPath(handle, env = process.env) {
246
+ return path.join(keysDir(env), `${handle}.json`);
247
+ }
248
+
249
+ function readKeyFile(handle, env = process.env) {
250
+ if (!handle) return null;
251
+ try {
252
+ return JSON.parse(fs.readFileSync(keyPath(handle, env), 'utf8'));
253
+ } catch {
254
+ return null;
255
+ }
256
+ }
257
+
258
+ // Owner-only folder and file; written through a temp file so a crash never
259
+ // leaves half a key behind, and created 0600 so it is never readable by others.
260
+ function writeKeyFile(handle, record, env = process.env) {
261
+ const dir = keysDir(env);
262
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
263
+ try { fs.chmodSync(dir, 0o700); } catch {}
264
+ const file = keyPath(handle, env);
265
+ const tmp = `${file}.${crypto.randomBytes(16).toString('hex')}.tmp`;
266
+ fs.writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
267
+ fs.renameSync(tmp, file);
268
+ return file;
269
+ }
270
+
271
+ module.exports = {
272
+ BLOCK_START,
273
+ BLOCK_END,
274
+ MAX_FILE_BYTES,
275
+ findHomeDir,
276
+ handleOf,
277
+ homeRel,
278
+ keyPath,
279
+ loadState,
280
+ localChanges,
281
+ readKeyFile,
282
+ readLocal,
283
+ saveState,
284
+ sha256,
285
+ withBlock,
286
+ writeAdapters,
287
+ writeKeyFile,
288
+ writeText,
289
+ };