atris 3.14.0 → 3.15.11

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/commands/init.js CHANGED
@@ -423,6 +423,13 @@ function initAtris() {
423
423
  console.log('✓ Created TODO.md placeholder');
424
424
  }
425
425
 
426
+ const nowFile = path.join(targetDir, 'now.md');
427
+ if (!fs.existsSync(nowFile)) {
428
+ const { renderDefaultNow } = require('./now');
429
+ fs.writeFileSync(nowFile, renderDefaultNow(cwd), 'utf8');
430
+ console.log('✓ Created now.md');
431
+ }
432
+
426
433
  // Create lessons.md (feedback loop for learning across features)
427
434
  const lessonsFile = path.join(targetDir, 'lessons.md');
428
435
  if (!fs.existsSync(lessonsFile)) {
@@ -605,7 +612,9 @@ This is the Atris boot sequence. Show the output to the user, then respond natur
605
612
  | File | Purpose |
606
613
  |------|---------|
607
614
  | \`atris/PERSONA.md\` | Communication style (read first) |
608
- | \`atris/TODO.md\` | Current tasks |
615
+ | \`atris task\` | Current tasks, claims, dialogue, proof |
616
+ | \`.atris/state/tasks.projection.json\` | Readable task projection for UIs/agents |
617
+ | \`atris/TODO.md\` | Rendered/legacy task view only |
609
618
  | \`atris/MAP.md\` | Navigation (where is X?) |
610
619
 
611
620
  ## Workflow
@@ -621,14 +630,17 @@ CHECK → atris review (verify + cleanup)
621
630
  - [ ] 3-4 sentences max per response
622
631
  - [ ] Use ASCII visuals for planning
623
632
  - [ ] Check MAP.md before touching code
624
- - [ ] Claim tasks in TODO.md before working
625
- - [ ] Delete tasks when done
633
+ - [ ] Run \`atris task list\` or \`atris task next\` before picking work
634
+ - [ ] Claim tasks with \`atris task claim <id> --as <agent>\`
635
+ - [ ] Finish tasks with proof via \`atris task finish <id> --proof "..."\`
636
+ - [ ] Treat \`atris/TODO.md\` as a rendered view; do not manually use it as the source of truth
626
637
 
627
638
  ## Anti-patterns
628
639
 
629
640
  - Don't explore codebase manually (use MAP.md)
630
641
  - Don't skip visualization step
631
642
  - Don't leave stale tasks
643
+ - Don't hand-edit TODO.md for active task ownership
632
644
  - Don't write verbose docs
633
645
 
634
646
  ---
@@ -12,6 +12,10 @@
12
12
 
13
13
  const { loadCredentials, ensureValidCredentials } = require('../utils/auth');
14
14
  const { apiRequestJson } = require('../utils/api');
15
+ const fs = require('fs');
16
+ const os = require('os');
17
+ const path = require('path');
18
+ const { spawnSync } = require('child_process');
15
19
 
16
20
  async function getAuth() {
17
21
  const ensured = await ensureValidCredentials(apiRequestJson);
@@ -310,6 +314,125 @@ async function slackCommand(subcommand, ...args) {
310
314
  }
311
315
  }
312
316
 
317
+ // ============================================================================
318
+ // IMESSAGE
319
+ // ============================================================================
320
+
321
+ function imessageDoctor() {
322
+ const chatDb = path.join(os.homedir(), 'Library', 'Messages', 'chat.db');
323
+ const checks = {
324
+ macos: process.platform === 'darwin',
325
+ chat_db_exists: false,
326
+ chat_db_readable: false,
327
+ sqlite3_available: false,
328
+ osascript_available: false,
329
+ };
330
+ const issues = [];
331
+
332
+ checks.chat_db_exists = fs.existsSync(chatDb);
333
+ if (checks.chat_db_exists) {
334
+ try {
335
+ fs.accessSync(chatDb, fs.constants.R_OK);
336
+ checks.chat_db_readable = true;
337
+ } catch {
338
+ issues.push('Messages database exists but is not readable. Grant Full Disk Access to this terminal or Atris.');
339
+ }
340
+ } else {
341
+ issues.push('Messages database not found on this Mac.');
342
+ }
343
+
344
+ checks.sqlite3_available = spawnSync('sqlite3', ['--version'], { encoding: 'utf8' }).status === 0;
345
+ if (!checks.sqlite3_available) issues.push('sqlite3 is not available.');
346
+
347
+ checks.osascript_available = spawnSync('osascript', ['-e', 'return "ok"'], { encoding: 'utf8' }).status === 0;
348
+ if (!checks.osascript_available) issues.push('osascript is not available.');
349
+
350
+ if (!checks.macos) issues.push('Local iMessage requires macOS.');
351
+
352
+ const connected = checks.macos && checks.chat_db_exists && checks.chat_db_readable && checks.sqlite3_available && checks.osascript_available;
353
+ return {
354
+ connected,
355
+ provider: 'local_imessage',
356
+ mode: 'local',
357
+ checks,
358
+ issues,
359
+ next_step: connected
360
+ ? 'iMessage is available on this Mac.'
361
+ : 'Open System Settings -> Privacy & Security -> Full Disk Access and allow your terminal or Atris, then run this check again.',
362
+ };
363
+ }
364
+
365
+ function printImessageDoctor(result, json = false) {
366
+ if (json) {
367
+ console.log(JSON.stringify(result, null, 2));
368
+ return;
369
+ }
370
+ console.log('iMessage local check\n');
371
+ console.log(`Status: ${result.connected ? 'Connected on this Mac' : 'Needs permission or setup'}`);
372
+ for (const [name, ok] of Object.entries(result.checks)) {
373
+ console.log(` ${ok ? '✓' : '✗'} ${name.replace(/_/g, ' ')}`);
374
+ }
375
+ if (result.issues.length) {
376
+ console.log('\nNext:');
377
+ for (const issue of result.issues) console.log(` - ${issue}`);
378
+ console.log(` - ${result.next_step}`);
379
+ }
380
+ }
381
+
382
+ function imessageRecent(handle, options = {}) {
383
+ if (!handle) {
384
+ console.error('Usage: atris imessage recent <phone-or-email> [--limit 20]');
385
+ process.exit(1);
386
+ }
387
+ const doctor = imessageDoctor();
388
+ if (!doctor.connected) {
389
+ printImessageDoctor(doctor, Boolean(options.json));
390
+ process.exit(1);
391
+ }
392
+
393
+ const limit = Number(options.limit || 20);
394
+ const chatDb = path.join(os.homedir(), 'Library', 'Messages', 'chat.db');
395
+ const sql = `
396
+ SELECT datetime(m.date/1000000000 + 978307200, 'unixepoch', 'localtime') AS ts,
397
+ CASE m.is_from_me WHEN 1 THEN 'me' ELSE h.id END AS sender,
398
+ replace(replace(COALESCE(m.text,''), char(10), ' '), char(13), ' ') AS text
399
+ FROM message m
400
+ JOIN handle h ON h.rowid = m.handle_id
401
+ WHERE h.id = '${String(handle).replace(/'/g, "''")}'
402
+ ORDER BY m.date DESC
403
+ LIMIT ${Math.max(1, Math.min(100, limit))};
404
+ `;
405
+ const result = spawnSync('sqlite3', ['-readonly', chatDb, sql], { encoding: 'utf8' });
406
+ if (result.status !== 0) {
407
+ console.error(result.stderr || 'Failed to read Messages database.');
408
+ process.exit(1);
409
+ }
410
+ console.log(result.stdout.trim() || 'No recent messages found.');
411
+ }
412
+
413
+ async function imessageCommand(subcommand, ...args) {
414
+ switch (subcommand) {
415
+ case 'doctor': {
416
+ const json = args.includes('--json');
417
+ const result = imessageDoctor();
418
+ printImessageDoctor(result, json);
419
+ if (!result.connected && args.includes('--strict')) process.exit(1);
420
+ break;
421
+ }
422
+ case 'recent': {
423
+ const handle = args[0];
424
+ const limitFlag = args.findIndex((x) => x === '--limit');
425
+ const limit = limitFlag >= 0 ? args[limitFlag + 1] : 20;
426
+ imessageRecent(handle, { limit, json: args.includes('--json') });
427
+ break;
428
+ }
429
+ default:
430
+ console.log('iMessage commands:');
431
+ console.log(' atris imessage doctor [--json] - Check local Messages access');
432
+ console.log(' atris imessage recent <handle> - Read recent local messages');
433
+ }
434
+ }
435
+
313
436
  // ============================================================================
314
437
  // STATUS
315
438
  // ============================================================================
@@ -337,6 +460,9 @@ async function integrationsStatus() {
337
460
  }
338
461
  }
339
462
 
463
+ const imessage = imessageDoctor();
464
+ console.log(` ${imessage.connected ? '✅' : '❌'} iMessage (local Mac)`);
465
+
340
466
  console.log('\nConnect integrations at: https://atris.ai/dashboard/settings');
341
467
  }
342
468
 
@@ -345,5 +471,7 @@ module.exports = {
345
471
  calendarCommand,
346
472
  twitterCommand,
347
473
  slackCommand,
474
+ imessageCommand,
475
+ imessageDoctor,
348
476
  integrationsStatus,
349
477
  };
@@ -0,0 +1,311 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { spawn } = require('child_process');
4
+
5
+ const DEFAULT_INTERVAL_SEC = 120;
6
+ const DEFAULT_DEBOUNCE_SEC = 30;
7
+ const DEFAULT_TIMEOUT_SEC = 600;
8
+
9
+ const IGNORED_DIRS = new Set(['.git', 'node_modules', 'dist', 'build', 'release', '.next', '__pycache__']);
10
+ const IGNORED_FILES = new Set(['.DS_Store']);
11
+
12
+ function parseNumberFlag(args, name, fallback) {
13
+ const eq = args.find((arg) => arg.startsWith(`--${name}=`));
14
+ if (eq) {
15
+ const value = Number(eq.slice(name.length + 3));
16
+ return Number.isFinite(value) && value > 0 ? value : fallback;
17
+ }
18
+ const idx = args.indexOf(`--${name}`);
19
+ if (idx !== -1 && args[idx + 1]) {
20
+ const value = Number(args[idx + 1]);
21
+ return Number.isFinite(value) && value > 0 ? value : fallback;
22
+ }
23
+ return fallback;
24
+ }
25
+
26
+ function parseStringFlag(args, name) {
27
+ const eq = args.find((arg) => arg.startsWith(`--${name}=`));
28
+ if (eq) return eq.slice(name.length + 3);
29
+ const idx = args.indexOf(`--${name}`);
30
+ if (idx !== -1 && args[idx + 1] && !args[idx + 1].startsWith('-')) return args[idx + 1];
31
+ return null;
32
+ }
33
+
34
+ function firstPositionalArg(args) {
35
+ const flagsWithValues = new Set(['--interval', '--debounce', '--timeout', '--only', '--root']);
36
+ for (let i = 0; i < args.length; i++) {
37
+ const arg = args[i];
38
+ if (flagsWithValues.has(arg)) {
39
+ i += 1;
40
+ continue;
41
+ }
42
+ if (arg.startsWith('-')) continue;
43
+ return arg;
44
+ }
45
+ return null;
46
+ }
47
+
48
+ function readBusinessMetaFromCwd(cwd) {
49
+ const file = path.join(cwd, '.atris', 'business.json');
50
+ if (!fs.existsSync(file)) return null;
51
+ try {
52
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ function readBusinessSlugFromCwd(cwd) {
59
+ const data = readBusinessMetaFromCwd(cwd);
60
+ return data ? data.slug || data.canonical_slug || data.name || null : null;
61
+ }
62
+
63
+ function resolveWorkspaceCwd(slug, cwd) {
64
+ if (!slug) return cwd;
65
+
66
+ const currentSlug = readBusinessSlugFromCwd(cwd);
67
+ if (currentSlug === slug) return cwd;
68
+
69
+ const child = path.join(cwd, slug);
70
+ if (fs.existsSync(child) && fs.statSync(child).isDirectory()) {
71
+ const childSlug = readBusinessSlugFromCwd(child);
72
+ const hasAtris = fs.existsSync(path.join(child, 'atris'));
73
+ if (childSlug || hasAtris) return child;
74
+ }
75
+
76
+ return cwd;
77
+ }
78
+
79
+ function parseLiveOptions(args, cwd = process.cwd()) {
80
+ const first = firstPositionalArg(args);
81
+ const slug = first || readBusinessSlugFromCwd(cwd);
82
+ const workspaceCwd = resolveWorkspaceCwd(slug, cwd);
83
+ return {
84
+ slug,
85
+ once: args.includes('--once'),
86
+ dryRun: args.includes('--dry-run'),
87
+ noDoctor: args.includes('--no-doctor'),
88
+ noPush: args.includes('--no-push'),
89
+ intervalSec: parseNumberFlag(args, 'interval', DEFAULT_INTERVAL_SEC),
90
+ debounceSec: parseNumberFlag(args, 'debounce', DEFAULT_DEBOUNCE_SEC),
91
+ timeoutSec: parseNumberFlag(args, 'timeout', DEFAULT_TIMEOUT_SEC),
92
+ only: parseStringFlag(args, 'only'),
93
+ root: parseStringFlag(args, 'root') || path.dirname(workspaceCwd),
94
+ cwd: workspaceCwd,
95
+ };
96
+ }
97
+
98
+ function printLiveHelp() {
99
+ console.log('Usage: atris live [business] [options]');
100
+ console.log('');
101
+ console.log('Keeps a business brain fresh: doctor, pull, watch local changes, push after quiet, and periodically pull.');
102
+ console.log('');
103
+ console.log('Examples:');
104
+ console.log(' atris live atris-labs');
105
+ console.log(' atris live --once');
106
+ console.log(' atris live atris-labs --dry-run');
107
+ console.log(' atris live atris-labs --interval=120 --debounce=30');
108
+ console.log('');
109
+ console.log('Options:');
110
+ console.log(' --once Run one freshness cycle and exit');
111
+ console.log(' --dry-run Print the plan without running pull/push');
112
+ console.log(' --interval <sec> Seconds between cloud pulls (default: 120)');
113
+ console.log(' --debounce <sec> Quiet seconds before pushing local changes (default: 30)');
114
+ console.log(' --timeout <sec> Pull timeout passed through to atris pull (default: 600)');
115
+ console.log(' --only <prefix> Limit pull/push to a path prefix');
116
+ console.log(' --no-doctor Skip business doctor --fix');
117
+ console.log(' --no-push Pull/watch only; never push');
118
+ }
119
+
120
+ function shouldIgnore(relativePath) {
121
+ if (!relativePath) return true;
122
+ const parts = relativePath.split(path.sep);
123
+ if (parts.some((part) => IGNORED_DIRS.has(part))) return true;
124
+ if (IGNORED_FILES.has(path.basename(relativePath))) return true;
125
+ if (relativePath.startsWith(path.join('.atris', 'state'))) return true;
126
+ return false;
127
+ }
128
+
129
+ function collectSnapshot(root) {
130
+ const snapshot = new Map();
131
+
132
+ function walk(dir) {
133
+ let entries;
134
+ try {
135
+ entries = fs.readdirSync(dir, { withFileTypes: true });
136
+ } catch {
137
+ return;
138
+ }
139
+
140
+ for (const entry of entries) {
141
+ const full = path.join(dir, entry.name);
142
+ const rel = path.relative(root, full);
143
+ if (shouldIgnore(rel)) continue;
144
+ if (entry.isDirectory()) {
145
+ walk(full);
146
+ } else if (entry.isFile()) {
147
+ try {
148
+ const stat = fs.statSync(full);
149
+ snapshot.set(rel, `${stat.size}:${Math.floor(stat.mtimeMs)}`);
150
+ } catch {
151
+ // Files can disappear while the operator is saving.
152
+ }
153
+ }
154
+ }
155
+ }
156
+
157
+ walk(root);
158
+ return snapshot;
159
+ }
160
+
161
+ function snapshotsDiffer(a, b) {
162
+ if (a.size !== b.size) return true;
163
+ for (const [key, value] of a.entries()) {
164
+ if (b.get(key) !== value) return true;
165
+ }
166
+ return false;
167
+ }
168
+
169
+ function commandLine(argv) {
170
+ return ['atris', ...argv].join(' ');
171
+ }
172
+
173
+ function runCli(argv, options) {
174
+ if (options.dryRun) {
175
+ console.log(` dry-run: ${commandLine(argv)}`);
176
+ return Promise.resolve({ status: 0 });
177
+ }
178
+
179
+ return new Promise((resolve, reject) => {
180
+ const child = spawn(process.execPath, [path.join(__dirname, '..', 'bin', 'atris.js'), ...argv], {
181
+ cwd: options.cwd,
182
+ stdio: 'inherit',
183
+ env: { ...process.env, ATRIS_SKIP_UPDATE_CHECK: '1' },
184
+ });
185
+ child.on('error', reject);
186
+ child.on('exit', (status) => {
187
+ if (status === 0) resolve({ status });
188
+ else reject(new Error(`${commandLine(argv)} exited ${status}`));
189
+ });
190
+ });
191
+ }
192
+
193
+ function buildPullArgs(options) {
194
+ const args = ['pull', options.slug, '--timeout', String(options.timeoutSec)];
195
+ if (options.only) args.push('--only', options.only);
196
+ return args;
197
+ }
198
+
199
+ function buildPushArgs(options) {
200
+ const args = ['push', options.slug, '--from', options.cwd];
201
+ if (options.only) args.push('--only', options.only);
202
+ return args;
203
+ }
204
+
205
+ async function runFreshnessCycle(options, reason) {
206
+ console.log('');
207
+ console.log(`atris live: ${reason}`);
208
+ if (!options.noDoctor) {
209
+ await runCli(['business', 'doctor', '--fix', '--root', options.root], options);
210
+ }
211
+ if (!options.noPush) {
212
+ await runCli(buildPushArgs(options), options);
213
+ }
214
+ await runCli(buildPullArgs(options), options);
215
+ }
216
+
217
+ async function liveCommand(args = process.argv.slice(3)) {
218
+ if (args.includes('--help') || args.includes('-h') || args[0] === 'help') {
219
+ printLiveHelp();
220
+ return;
221
+ }
222
+
223
+ const options = parseLiveOptions(args);
224
+ if (!options.slug) {
225
+ console.error('Usage: atris live [business]');
226
+ console.error('Run inside a business workspace or pass a business slug.');
227
+ process.exit(1);
228
+ }
229
+
230
+ console.log('');
231
+ console.log(`Atris Live: ${options.slug}`);
232
+ console.log(` workspace: ${options.cwd}`);
233
+ console.log(` interval: ${options.intervalSec}s`);
234
+ console.log(` debounce: ${options.debounceSec}s`);
235
+ console.log(` push: ${options.noPush ? 'off' : 'on'}`);
236
+ if (options.only) console.log(` only: ${options.only}`);
237
+
238
+ if (options.dryRun) {
239
+ await runFreshnessCycle(options, 'planned startup cycle');
240
+ if (!options.once) console.log(` dry-run: would watch ${options.cwd} and sync every ${options.intervalSec}s`);
241
+ return;
242
+ }
243
+
244
+ await runFreshnessCycle(options, 'startup freshness cycle');
245
+ if (options.once) return;
246
+
247
+ console.log('');
248
+ console.log('Brain fresh. Watching for local changes. Press Ctrl+C to stop.');
249
+
250
+ let lastSnapshot = collectSnapshot(options.cwd);
251
+ let pendingPush = false;
252
+ let quietTicks = 0;
253
+ let running = false;
254
+
255
+ async function guarded(label, fn) {
256
+ if (running) return;
257
+ running = true;
258
+ try {
259
+ await fn();
260
+ lastSnapshot = collectSnapshot(options.cwd);
261
+ pendingPush = false;
262
+ quietTicks = 0;
263
+ } catch (err) {
264
+ console.error(`\natris live paused after ${label}: ${err.message || err}`);
265
+ console.error('Fix the issue, then restart `atris live`.');
266
+ process.exit(1);
267
+ } finally {
268
+ running = false;
269
+ }
270
+ }
271
+
272
+ setInterval(() => {
273
+ if (running) return;
274
+ const current = collectSnapshot(options.cwd);
275
+ if (snapshotsDiffer(lastSnapshot, current)) {
276
+ pendingPush = true;
277
+ quietTicks = 0;
278
+ lastSnapshot = current;
279
+ process.stdout.write('\rLocal brain changed. Waiting for quiet before push... ');
280
+ return;
281
+ }
282
+ if (pendingPush) {
283
+ quietTicks += 1;
284
+ if (quietTicks >= options.debounceSec) {
285
+ void guarded('push', async () => {
286
+ console.log('\nLocal brain quiet. Pushing fresh state...');
287
+ if (!options.noPush) await runCli(buildPushArgs(options), options);
288
+ });
289
+ }
290
+ }
291
+ }, 1000);
292
+
293
+ setInterval(() => {
294
+ if (pendingPush) {
295
+ process.stdout.write('\rLocal changes pending; skipping cloud pull until local brain is pushed... ');
296
+ return;
297
+ }
298
+ void guarded('periodic pull', async () => {
299
+ console.log('\nChecking cloud for fresher brain...');
300
+ await runCli(buildPullArgs(options), options);
301
+ });
302
+ }, options.intervalSec * 1000);
303
+ }
304
+
305
+ module.exports = {
306
+ collectSnapshot,
307
+ liveCommand,
308
+ parseLiveOptions,
309
+ shouldIgnore,
310
+ snapshotsDiffer,
311
+ };