atris 3.2.0 → 3.11.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 (55) hide show
  1. package/GETTING_STARTED.md +65 -131
  2. package/README.md +18 -2
  3. package/atris/GETTING_STARTED.md +65 -131
  4. package/atris/PERSONA.md +5 -1
  5. package/atris/atris.md +122 -153
  6. package/atris/skills/aeo/SKILL.md +117 -0
  7. package/atris/skills/atris/SKILL.md +49 -25
  8. package/atris/skills/create-member/SKILL.md +29 -9
  9. package/atris/skills/endgame/SKILL.md +9 -0
  10. package/atris/skills/research-search/SKILL.md +167 -0
  11. package/atris/skills/research-search/arxiv_search.py +157 -0
  12. package/atris/skills/research-search/program.md +48 -0
  13. package/atris/skills/research-search/results.tsv +6 -0
  14. package/atris/skills/research-search/scholar_search.py +154 -0
  15. package/atris/skills/tidy/SKILL.md +36 -21
  16. package/atris/team/_template/MEMBER.md +2 -0
  17. package/atris/team/validator/MEMBER.md +35 -1
  18. package/atris.md +118 -178
  19. package/bin/atris.js +46 -12
  20. package/cli/__pycache__/atris_code.cpython-314.pyc +0 -0
  21. package/cli/__pycache__/runtime_guard.cpython-312.pyc +0 -0
  22. package/cli/__pycache__/runtime_guard.cpython-314.pyc +0 -0
  23. package/cli/atris_code.py +889 -0
  24. package/cli/runtime_guard.py +693 -0
  25. package/commands/align.js +16 -0
  26. package/commands/app.js +316 -0
  27. package/commands/autopilot.js +863 -23
  28. package/commands/brainstorm.js +7 -5
  29. package/commands/business.js +677 -2
  30. package/commands/clean.js +19 -3
  31. package/commands/computer.js +2022 -43
  32. package/commands/context-sync.js +5 -0
  33. package/commands/integrations.js +14 -9
  34. package/commands/lifecycle.js +12 -0
  35. package/commands/plugin.js +24 -0
  36. package/commands/pull.js +86 -11
  37. package/commands/push.js +153 -9
  38. package/commands/serve.js +1 -0
  39. package/commands/sync.js +272 -76
  40. package/commands/verify.js +50 -1
  41. package/commands/wiki.js +27 -2
  42. package/commands/workflow.js +24 -9
  43. package/lib/file-ops.js +13 -1
  44. package/lib/journal.js +23 -0
  45. package/lib/manifest.js +3 -0
  46. package/lib/scorecard.js +42 -4
  47. package/lib/sync-telemetry.js +59 -0
  48. package/lib/todo.js +6 -0
  49. package/lib/wiki.js +150 -6
  50. package/lib/workspace-safety.js +87 -0
  51. package/package.json +2 -1
  52. package/utils/api.js +19 -0
  53. package/utils/auth.js +25 -1
  54. package/utils/config.js +24 -0
  55. package/utils/update-check.js +16 -0
package/lib/scorecard.js CHANGED
@@ -4,16 +4,31 @@ const { parseTodo } = require('./todo');
4
4
 
5
5
  const PRIVATE_MEMORY_ROOT = '.atris/presidio';
6
6
 
7
+ /**
8
+ * Ensure the private memory directory exists.
9
+ * @param {string} atrisDir - Path to the atris directory
10
+ * @returns {string} Path to the private memory directory
11
+ */
7
12
  function ensurePrivateMemoryDir(atrisDir) {
8
13
  const privateDir = path.join(path.dirname(atrisDir), PRIVATE_MEMORY_ROOT);
9
14
  fs.mkdirSync(privateDir, { recursive: true });
10
15
  return privateDir;
11
16
  }
12
17
 
18
+ /**
19
+ * Get the path to the scorecards file.
20
+ * @param {string} atrisDir - Path to the atris directory
21
+ * @returns {string} Path to scorecards.md
22
+ */
13
23
  function getScorecardsPath(atrisDir) {
14
24
  return path.join(ensurePrivateMemoryDir(atrisDir), 'scorecards.md');
15
25
  }
16
26
 
27
+ /**
28
+ * Parse a "picked at" timestamp from scorecard data.
29
+ * @param {string} value - Timestamp string in YYYY-MM-DD [HH:MM] format
30
+ * @returns {Date|null} Parsed date or null if invalid
31
+ */
17
32
  function parsePickedAt(value) {
18
33
  if (!value) return null;
19
34
  const match = String(value).trim().match(/^(\d{4}-\d{2}-\d{2})(?:\s+(\d{2}:\d{2}))?/);
@@ -24,6 +39,12 @@ function parsePickedAt(value) {
24
39
  return Number.isNaN(parsed.getTime()) ? null : parsed;
25
40
  }
26
41
 
42
+ /**
43
+ * Parse a tick timestamp combining date and time label.
44
+ * @param {string} dateStr - Date in YYYY-MM-DD format
45
+ * @param {string} timeLabel - Time label like "2:30 PM"
46
+ * @returns {Date|null} Parsed date or null if invalid
47
+ */
27
48
  function parseTickDate(dateStr, timeLabel) {
28
49
  const match = String(timeLabel || '').trim().toLowerCase().match(/^(\d{1,2}):(\d{2})(?:\s*(am|pm))?$/);
29
50
  if (!match) return null;
@@ -40,6 +61,13 @@ function parseTickDate(dateStr, timeLabel) {
40
61
  return Number.isNaN(parsed.getTime()) ? null : parsed;
41
62
  }
42
63
 
64
+ /**
65
+ * List journal log files within a date range.
66
+ * @param {string} atrisDir - Path to the atris directory
67
+ * @param {Date} startDate - Start of date range
68
+ * @param {Date} [endDate] - End of date range (defaults to today)
69
+ * @returns {Array} Array of log file paths
70
+ */
43
71
  function listLogFiles(atrisDir, startDate, endDate = new Date()) {
44
72
  const logsDir = path.join(atrisDir, 'logs');
45
73
  if (!fs.existsSync(logsDir)) return [];
@@ -153,16 +181,20 @@ function buildScorecardData(atrisDir, { slug, pickedAt } = {}) {
153
181
  const todo = parseTodo(todoPath);
154
182
  const startAt = parsePickedAt(pickedAt) || new Date();
155
183
  const rewardStats = collectRewardStats(atrisDir, pickedAt);
156
- const completedEndgame = todo.completed.filter(t => t.tag === 'endgame').length;
184
+ // Count shipped tasks from journal completions (tasks get deleted from TODO.md after completion)
185
+ const completedFromTodo = todo.completed.filter(t => t.tag === 'endgame').length;
157
186
  const activeEndgame = todo.backlog.filter(t => t.tag === 'endgame').length
158
187
  + todo.inProgress.filter(t => t.tag === 'endgame').length;
188
+ // Fall back to reward tick count if TODO completions were already pruned
189
+ const shipped = completedFromTodo > 0 ? completedFromTodo : rewardStats.totalTicks - rewardStats.haltedTicks;
190
+ const attempted = shipped + activeEndgame + rewardStats.haltedTicks;
159
191
 
160
192
  return {
161
193
  slug,
162
194
  startDate: startAt.toISOString().slice(0, 10),
163
195
  endDate: new Date().toISOString().slice(0, 10),
164
- tasksShipped: completedEndgame,
165
- tasksAttempted: completedEndgame + activeEndgame,
196
+ tasksShipped: Math.max(shipped, 0),
197
+ tasksAttempted: Math.max(attempted, shipped),
166
198
  wallClockHours: Math.max(0, (Date.now() - startAt.getTime()) / (1000 * 60 * 60)),
167
199
  haltRatio: rewardStats.totalTicks > 0 ? rewardStats.haltedTicks / rewardStats.totalTicks : 0,
168
200
  totalReward: rewardStats.totalReward,
@@ -205,6 +237,12 @@ function writeScorecard(atrisDir, data) {
205
237
 
206
238
  const scorecardsPath = getScorecardsPath(atrisDir);
207
239
 
240
+ // Dedupe guard: don't write the same slug twice
241
+ const existing = readScorecards(atrisDir);
242
+ if (existing.some(sc => sc.slug === slug)) {
243
+ return; // already written
244
+ }
245
+
208
246
  // Ensure scorecards.md exists
209
247
  if (!fs.existsSync(scorecardsPath)) {
210
248
  const template = `# scorecards.md — Endgame Results\n\n> Append-only. One line per closed endgame. Records outcome metrics from the horizon.\n\n---\n\n`;
@@ -263,7 +301,7 @@ function readScorecards(atrisDir) {
263
301
  const scorecards = [];
264
302
 
265
303
  for (const line of content.split('\n')) {
266
- const match = line.match(/^- \*\*\[(.+?)\]\s+(.+?)\*\*\s*—\s*shipped:\s*(\d+)\/(\d+)\s*—\s*wall-clock:\s*(.+?)\s*—\s*halt:\s*(\d+)%\s*—\s*reward:\s*(\d+)\s*—\s*lessons:\s*(\d+)$/);
304
+ const match = line.match(/^- \*\*\[(.+?)\]\s+(.+?)\*\*\s*—\s*shipped:\s*(\d+)\/(\d+)\s*—\s*wall-clock:\s*(.+?)\s*—\s*halt:\s*(\d+)%\s*—\s*reward:\s*(-?\d+)\s*—\s*lessons:\s*(\d+)$/);
267
305
  if (!match) continue;
268
306
 
269
307
  const [, endDate, slug, shipped, attempted, wallClockStr, haltPercent, reward, lessons] = match;
@@ -0,0 +1,59 @@
1
+ // Atris sync telemetry — emit one event per `atris push` / `atris pull`.
2
+ //
3
+ // Why awaited (not fire-and-forget): push.js / pull.js call process.exit(1)
4
+ // on most failure paths. A fire-and-forget POST gets killed mid-flight and
5
+ // the RL loop loses signals on the exact failures it most needs to learn.
6
+ //
7
+ // Best-effort: any error swallowed silently — telemetry never blocks UX.
8
+ // Hard 2s timeout so a flaky control plane can't slow real operations.
9
+
10
+ const { apiRequestJson } = require('../utils/api');
11
+
12
+ let _cachedVersion = null;
13
+ function cliVersion() {
14
+ if (_cachedVersion) return _cachedVersion;
15
+ try {
16
+ _cachedVersion = require('../package.json').version || 'unknown';
17
+ } catch {
18
+ _cachedVersion = 'unknown';
19
+ }
20
+ return _cachedVersion;
21
+ }
22
+
23
+ async function emitSyncEvent(token, businessId, workspaceId, op, outcome, latencyMs, extras = {}) {
24
+ if (!token || !businessId || !workspaceId || !op || !outcome) return false;
25
+ const event = {
26
+ business_id: businessId,
27
+ workspace_id: workspaceId,
28
+ op,
29
+ outcome,
30
+ latency_ms: Math.max(0, Math.round(latencyMs || 0)),
31
+ bytes_transferred: extras.bytes_transferred || 0,
32
+ bytes_changed: extras.bytes_changed || 0,
33
+ files_pushed: extras.files_pushed || 0,
34
+ files_deleted: extras.files_deleted || 0,
35
+ files_unchanged: extras.files_unchanged || 0,
36
+ cli_version: cliVersion(),
37
+ };
38
+ if (extras.error_detail) event.error_detail = String(extras.error_detail).slice(0, 500);
39
+
40
+ try {
41
+ await apiRequestJson('/atris-sync/telemetry', {
42
+ method: 'POST',
43
+ token,
44
+ body: event,
45
+ timeoutMs: 2000,
46
+ retries: 0,
47
+ });
48
+ return true;
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
53
+
54
+ function startTimer() {
55
+ const t0 = Date.now();
56
+ return () => Date.now() - t0;
57
+ }
58
+
59
+ module.exports = { emitSyncEvent, startTimer };
package/lib/todo.js CHANGED
@@ -17,6 +17,12 @@ function parseTodo(todoPath) {
17
17
  };
18
18
  }
19
19
 
20
+ /**
21
+ * Parse a specific section from TODO.md content into task objects.
22
+ * @param {string} content - Full TODO.md content
23
+ * @param {string} sectionName - Section name to extract (e.g., 'Backlog')
24
+ * @returns {Array} Array of parsed task objects
25
+ */
20
26
  function parseSection(content, sectionName) {
21
27
  const escaped = sectionName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
22
28
  const match = content.match(new RegExp(`##\\s+${escaped}\\n([\\s\\S]*?)(?=\\n##|$)`, 'i'));
package/lib/wiki.js CHANGED
@@ -2,7 +2,9 @@ const fs = require('fs');
2
2
  const path = require('path');
3
3
 
4
4
  const WIKI_ROOT = 'atris/wiki';
5
+ const CONTEXT_ROOT = 'atris/context';
5
6
  const PRIVATE_WIKI_ROOT = '.atris/presidio';
7
+ const PRIVATE_CONTEXT_ROOT = `${PRIVATE_WIKI_ROOT}/context`;
6
8
  const LEGACY_WIKI_ROOT = 'wiki';
7
9
  const WIKI_BRIEFS_SUBDIR = 'briefs';
8
10
  const LEGACY_WIKI_BRIEFS_SUBDIR = 'syntheses';
@@ -14,6 +16,10 @@ function getWikiRoot(mode = 'public') {
14
16
  return mode === 'private' ? PRIVATE_WIKI_ROOT : WIKI_ROOT;
15
17
  }
16
18
 
19
+ function getContextRoot(mode = 'public') {
20
+ return mode === 'private' ? PRIVATE_CONTEXT_ROOT : CONTEXT_ROOT;
21
+ }
22
+
17
23
  function getWikiLinkRoot(mode = 'public') {
18
24
  return mode === 'private' ? PRIVATE_WIKI_ROOT : 'atris/wiki';
19
25
  }
@@ -89,6 +95,23 @@ function statusMarkdown() {
89
95
  `;
90
96
  }
91
97
 
98
+ function contextMarkdown(mode = 'public') {
99
+ const contextRoot = getContextRoot(mode);
100
+ const wikiRoot = getWikiRoot(mode);
101
+ return `# Context
102
+
103
+ Raw source material lives in \`${contextRoot}/\`.
104
+ Compiled memory belongs in \`${wikiRoot}/\`.
105
+
106
+ ## Rules
107
+
108
+ - Drop source files or source packs here before compiling them into the wiki.
109
+ - Treat source files as immutable evidence. If the source changes, add a new dated copy.
110
+ - Keep compiled summaries, briefs, and durable facts in the wiki instead of this folder.
111
+ - Use \`${contextRoot}/_ingest/\` for staged ingest packs and receipts.
112
+ `;
113
+ }
114
+
92
115
  function ensureFile(filePath, content) {
93
116
  if (!fs.existsSync(filePath)) {
94
117
  fs.writeFileSync(filePath, content, 'utf8');
@@ -185,6 +208,122 @@ function ensureWikiScaffold(projectRoot = process.cwd(), mode = 'public') {
185
208
  return wikiDir;
186
209
  }
187
210
 
211
+ function ensureContextScaffold(projectRoot = process.cwd(), mode = 'public') {
212
+ const contextRoot = getContextRoot(mode);
213
+ const contextDir = path.join(projectRoot, contextRoot);
214
+ fs.mkdirSync(path.join(contextDir, '_ingest'), { recursive: true });
215
+ ensureFile(path.join(contextDir, 'README.md'), contextMarkdown(mode));
216
+ return contextDir;
217
+ }
218
+
219
+ function slugifyLabel(value) {
220
+ return String(value || 'source')
221
+ .toLowerCase()
222
+ .replace(/[^a-z0-9]+/g, '-')
223
+ .replace(/^-+|-+$/g, '')
224
+ .slice(0, 40) || 'source';
225
+ }
226
+
227
+ function isInsideDirectory(candidate, rootDir) {
228
+ const relative = path.relative(rootDir, candidate);
229
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
230
+ }
231
+
232
+ function walkFiles(dir, output = []) {
233
+ if (!fs.existsSync(dir)) return output;
234
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
235
+ for (const entry of entries) {
236
+ const fullPath = path.join(dir, entry.name);
237
+ if (entry.isDirectory()) {
238
+ walkFiles(fullPath, output);
239
+ } else if (entry.isFile()) {
240
+ output.push(fullPath);
241
+ }
242
+ }
243
+ return output;
244
+ }
245
+
246
+ function stageWikiIngest(projectRoot = process.cwd(), sourceValue, mode = 'public') {
247
+ const contextDir = ensureContextScaffold(projectRoot, mode);
248
+ const ingestDir = path.join(contextDir, '_ingest');
249
+ const raw = String(sourceValue || '').trim();
250
+ const timestamp = `${today()}-${nowTime().replace(':', '')}`;
251
+ const labelSeed = raw ? path.basename(raw) : 'source-pack';
252
+ const packDir = path.join(ingestDir, `${timestamp}-${slugifyLabel(labelSeed)}`);
253
+ fs.mkdirSync(packDir, { recursive: true });
254
+
255
+ const manifest = {
256
+ ingested_at: `${today()} ${nowTime()}`,
257
+ mode,
258
+ source_input: raw,
259
+ pack_path: path.relative(projectRoot, packDir).replace(/\\/g, '/'),
260
+ entries: [],
261
+ };
262
+
263
+ const maybeUrl = /^https?:\/\//i.test(raw);
264
+ const sourcePath = raw ? path.resolve(projectRoot, raw) : null;
265
+
266
+ if (raw && sourcePath && fs.existsSync(sourcePath)) {
267
+ const stat = fs.statSync(sourcePath);
268
+ if (isInsideDirectory(sourcePath, contextDir)) {
269
+ const files = stat.isDirectory() ? walkFiles(sourcePath) : [sourcePath];
270
+ manifest.entries.push({
271
+ kind: stat.isDirectory() ? 'directory' : 'file',
272
+ original: path.relative(projectRoot, sourcePath).replace(/\\/g, '/'),
273
+ staged: path.relative(projectRoot, sourcePath).replace(/\\/g, '/'),
274
+ file_count: files.length,
275
+ files: files.map((filePath) => path.relative(projectRoot, filePath).replace(/\\/g, '/')),
276
+ });
277
+ } else {
278
+ const stagedPath = path.join(packDir, path.basename(sourcePath));
279
+ if (stat.isDirectory()) {
280
+ fs.cpSync(sourcePath, stagedPath, { recursive: true });
281
+ } else {
282
+ fs.copyFileSync(sourcePath, stagedPath);
283
+ }
284
+ const files = stat.isDirectory() ? walkFiles(stagedPath) : [stagedPath];
285
+ manifest.entries.push({
286
+ kind: stat.isDirectory() ? 'directory' : 'file',
287
+ original: path.relative(projectRoot, sourcePath).replace(/\\/g, '/'),
288
+ staged: path.relative(projectRoot, stagedPath).replace(/\\/g, '/'),
289
+ file_count: files.length,
290
+ files: files.map((filePath) => path.relative(projectRoot, filePath).replace(/\\/g, '/')),
291
+ });
292
+ }
293
+ } else if (maybeUrl) {
294
+ const linksPath = path.join(packDir, 'links.txt');
295
+ fs.writeFileSync(linksPath, `${raw}\n`, 'utf8');
296
+ manifest.entries.push({
297
+ kind: 'url',
298
+ original: raw,
299
+ staged: path.relative(projectRoot, linksPath).replace(/\\/g, '/'),
300
+ file_count: 1,
301
+ files: [path.relative(projectRoot, linksPath).replace(/\\/g, '/')],
302
+ });
303
+ } else {
304
+ const requestPath = path.join(packDir, 'request.txt');
305
+ fs.writeFileSync(requestPath, `${raw || '(empty)'}\n`, 'utf8');
306
+ manifest.entries.push({
307
+ kind: 'unresolved',
308
+ original: raw || '(empty)',
309
+ staged: path.relative(projectRoot, requestPath).replace(/\\/g, '/'),
310
+ file_count: 1,
311
+ files: [path.relative(projectRoot, requestPath).replace(/\\/g, '/')],
312
+ });
313
+ }
314
+
315
+ const manifestPath = path.join(packDir, 'manifest.json');
316
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
317
+
318
+ return {
319
+ contextDir: path.relative(projectRoot, contextDir).replace(/\\/g, '/'),
320
+ packPath: path.relative(projectRoot, packDir).replace(/\\/g, '/'),
321
+ manifestPath: path.relative(projectRoot, manifestPath).replace(/\\/g, '/'),
322
+ promptSource: manifest.entries[0]?.staged || path.relative(projectRoot, packDir).replace(/\\/g, '/'),
323
+ manifest,
324
+ };
325
+ }
326
+
188
327
  function findLocalWikiDir(projectRoot = process.cwd(), slug = null, mode = 'public') {
189
328
  if (mode === 'private') {
190
329
  const privateDir = path.join(projectRoot, PRIVATE_WIKI_ROOT);
@@ -444,7 +583,7 @@ function parseStatusBullets(content) {
444
583
  return bullets;
445
584
  }
446
585
 
447
- function writeWikiStatus(projectRoot = process.cwd(), report, mode = 'public') {
586
+ function writeWikiStatus(projectRoot = process.cwd(), report, mode = 'public', overrides = {}) {
448
587
  const wikiDir = ensureWikiScaffold(projectRoot, mode);
449
588
  const statusPath = path.join(wikiDir, WIKI_STATUS_FILE);
450
589
  const existing = fs.existsSync(statusPath) ? fs.readFileSync(statusPath, 'utf8') : '';
@@ -453,9 +592,9 @@ function writeWikiStatus(projectRoot = process.cwd(), report, mode = 'public') {
453
592
  const lines = [
454
593
  '# Atris Wiki Status',
455
594
  '',
456
- `- Last ingest: ${bullets.get('Last ingest') || 'never'}`,
457
- `- Last lint: ${bullets.get('Last lint') || 'never'}`,
458
- `- Last loop: ${today()} ${nowTime()}`,
595
+ `- Last ingest: ${overrides.lastIngest || bullets.get('Last ingest') || 'never'}`,
596
+ `- Last lint: ${overrides.lastLint || bullets.get('Last lint') || 'never'}`,
597
+ `- Last loop: ${overrides.lastLoop || `${today()} ${nowTime()}`}`,
459
598
  `- Health: ${report.health}`,
460
599
  `- Next move: ${report.nextMove}`,
461
600
  '',
@@ -465,7 +604,7 @@ function writeWikiStatus(projectRoot = process.cwd(), report, mode = 'public') {
465
604
  return statusPath;
466
605
  }
467
606
 
468
- function appendWikiLog(projectRoot = process.cwd(), summary, details = [], mode = 'public') {
607
+ function appendWikiLog(projectRoot = process.cwd(), summary, details = [], mode = 'public', kind = 'LOOP') {
469
608
  const wikiDir = ensureWikiScaffold(projectRoot, mode);
470
609
  const logPath = path.join(wikiDir, 'log.md');
471
610
  let content = fs.existsSync(logPath) ? fs.readFileSync(logPath, 'utf8') : '# Atris Wiki Log\n';
@@ -476,7 +615,7 @@ function appendWikiLog(projectRoot = process.cwd(), summary, details = [], mode
476
615
  }
477
616
 
478
617
  if (!content.endsWith('\n')) content += '\n';
479
- content += `- ${nowTime()} LOOP ${summary}\n`;
618
+ content += `- ${nowTime()} ${kind} ${summary}\n`;
480
619
  for (const detail of details) {
481
620
  content += ` - ${detail}\n`;
482
621
  }
@@ -586,14 +725,18 @@ Output:
586
725
 
587
726
  module.exports = {
588
727
  WIKI_ROOT,
728
+ CONTEXT_ROOT,
589
729
  PRIVATE_WIKI_ROOT,
730
+ PRIVATE_CONTEXT_ROOT,
590
731
  LEGACY_WIKI_ROOT,
591
732
  WIKI_SUBDIRS,
592
733
  WIKI_CONTENT_SUBDIRS,
593
734
  WIKI_SCHEMA: buildWikiSchema(),
594
735
  WIKI_STATUS_FILE,
595
736
  getWikiRoot,
737
+ getContextRoot,
596
738
  ensureWikiScaffold,
739
+ ensureContextScaffold,
597
740
  findLocalWikiDir,
598
741
  normalizeWikiOnlyPrefix,
599
742
  readWikiStatus,
@@ -601,6 +744,7 @@ module.exports = {
601
744
  findStaleWikiPages,
602
745
  findWikiOrphans,
603
746
  findSuggestedSources,
747
+ stageWikiIngest,
604
748
  writeWikiStatus,
605
749
  appendWikiLog,
606
750
  buildIngestPrompt,
@@ -0,0 +1,87 @@
1
+ const path = require('path');
2
+ const os = require('os');
3
+
4
+ // Returns a human-readable reason string if `dir` is a path we must never
5
+ // treat as an Atris workspace root (pull/push would walk and mutate it).
6
+ // Returns null if the dir is safe.
7
+ //
8
+ // The real bug this guards against: if outputDir resolves to $HOME, pull's
9
+ // force mirror sweep walks ~/Library, ~/Documents, ~/Downloads, ... and
10
+ // deletes any local file not on cloud. That wipes the user's home dir.
11
+ function dangerousWorkspaceReason(dir) {
12
+ if (!dir) return 'empty path';
13
+ const abs = path.resolve(dir);
14
+ const home = os.homedir();
15
+ const rootParsed = path.parse(abs).root;
16
+
17
+ if (abs === home) return 'your home directory';
18
+ if (abs === rootParsed) return 'the filesystem root';
19
+ if (abs === path.dirname(home)) return `the users root (${path.dirname(home)})`;
20
+
21
+ const systemPaths = [
22
+ '/tmp', '/var', '/etc', '/usr', '/bin', '/sbin', '/opt',
23
+ '/private', '/Library', '/Applications', '/System', '/Volumes',
24
+ '/Users', '/home', '/root',
25
+ ];
26
+ for (const p of systemPaths) {
27
+ if (abs === p) return `the system path ${p}`;
28
+ }
29
+
30
+ // Reserved top-level folders inside the user's home — never workspaces.
31
+ const homeReservedNames = new Set([
32
+ 'Library', 'Applications', 'Documents', 'Downloads', 'Desktop',
33
+ 'Pictures', 'Music', 'Movies', 'Public', 'Sites', 'Dropbox',
34
+ 'OneDrive', 'iCloud Drive',
35
+ ]);
36
+ if (path.dirname(abs) === home && homeReservedNames.has(path.basename(abs))) {
37
+ return `a reserved home folder (~/${path.basename(abs)})`;
38
+ }
39
+
40
+ return null;
41
+ }
42
+
43
+ // Pick a safe fallback directory for a business workspace when the
44
+ // originally-resolved path is dangerous. Prefers cwd/slug, falls back to
45
+ // ~/atris-workspaces/slug if cwd itself is unsafe.
46
+ function safeFallbackDir(slug, cwd) {
47
+ const cwdFallback = path.join(cwd, slug);
48
+ if (!dangerousWorkspaceReason(cwdFallback)) return cwdFallback;
49
+ return path.join(os.homedir(), 'atris-workspaces', slug);
50
+ }
51
+
52
+ // Resolve an outputDir, auto-relocating to a safe fallback if the originally
53
+ // chosen path is dangerous. Non-technical users don't need to know about
54
+ // mkdir/cd — atris picks a safe folder and tells them where it landed.
55
+ function resolveSafeOutputDir(requested, { slug, cwd = process.cwd(), op = 'use' } = {}) {
56
+ const reason = dangerousWorkspaceReason(requested);
57
+ if (!reason) return { dir: path.resolve(requested), relocated: false };
58
+
59
+ const fallback = safeFallbackDir(slug, cwd);
60
+ console.log('');
61
+ console.log(` ${requested} is ${reason} — not safe to ${op}.`);
62
+ console.log(` Using ${fallback} instead.`);
63
+ console.log('');
64
+ return { dir: fallback, relocated: true, originalReason: reason };
65
+ }
66
+
67
+ // Hard-refuse variant — used by push where we can't auto-relocate (the
68
+ // source has to be where the user actually has their files).
69
+ function assertSafeWorkspaceRoot(dir, { slug, op } = {}) {
70
+ const reason = dangerousWorkspaceReason(dir);
71
+ if (!reason) return;
72
+ const label = op || 'operate on';
73
+ console.error('');
74
+ console.error(` Refusing to ${label} ${dir} (${reason}).`);
75
+ console.error('');
76
+ console.error(' Atris would walk this folder and sync files inside it.');
77
+ console.error(' Cd into a dedicated workspace folder first.');
78
+ if (slug) console.error(` For example: mkdir -p ~/code/${slug} && cd ~/code/${slug}`);
79
+ process.exit(1);
80
+ }
81
+
82
+ module.exports = {
83
+ dangerousWorkspaceReason,
84
+ safeFallbackDir,
85
+ resolveSafeOutputDir,
86
+ assertSafeWorkspaceRoot,
87
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.2.0",
3
+ "version": "3.11.0",
4
4
  "description": "Atris — an operating system for intelligence. Integrates with any agent.",
5
5
  "main": "bin/atris.js",
6
6
  "bin": {
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "files": [
10
10
  "bin/",
11
+ "cli/",
11
12
  "commands/",
12
13
  "utils/",
13
14
  "lib/",
package/utils/api.js CHANGED
@@ -20,22 +20,41 @@ try {
20
20
  const DEFAULT_CLIENT_ID = `AtrisCLI/${CLI_VERSION}`;
21
21
  const DEFAULT_USER_AGENT = `${DEFAULT_CLIENT_ID} (node ${process.version}; ${os.platform()} ${os.release()} ${os.arch()})`;
22
22
 
23
+ /**
24
+ * Get the base URL for the Atris API.
25
+ * @returns {string} API base URL
26
+ */
23
27
  function getApiBaseUrl() {
24
28
  const raw = process.env.ATRIS_API_URL || 'https://api.atris.ai/api';
25
29
  return raw.replace(/\/$/, '');
26
30
  }
27
31
 
32
+ /**
33
+ * Get the base URL for the Atris web app.
34
+ * @returns {string} App base URL
35
+ */
28
36
  function getAppBaseUrl() {
29
37
  const raw = process.env.ATRIS_APP_URL || 'https://atris.ai';
30
38
  return raw.replace(/\/$/, '');
31
39
  }
32
40
 
41
+ /**
42
+ * Build a full API URL from a path.
43
+ * @param {string} pathname - API endpoint path
44
+ * @returns {string} Full API URL
45
+ */
33
46
  function buildApiUrl(pathname) {
34
47
  const base = getApiBaseUrl();
35
48
  const normalizedPath = pathname.startsWith('/') ? pathname : `/${pathname}`;
36
49
  return `${base}${normalizedPath}`;
37
50
  }
38
51
 
52
+ /**
53
+ * Make an HTTP/HTTPS request.
54
+ * @param {string} urlString - Full URL to request
55
+ * @param {Object} options - Request options (method, headers, body, timeoutMs)
56
+ * @returns {Promise<Object>} Response with statusCode, headers, and data
57
+ */
39
58
  function httpRequest(urlString, options) {
40
59
  return new Promise((resolve, reject) => {
41
60
  const parsed = new URL(urlString);
package/utils/auth.js CHANGED
@@ -4,6 +4,10 @@ const fs = require('fs');
4
4
  const { exec } = require('child_process');
5
5
  const readline = require('readline');
6
6
 
7
+ /**
8
+ * Open a URL in the system's default browser.
9
+ * @param {string} url - URL to open
10
+ */
7
11
  function openBrowser(url) {
8
12
  const platform = os.platform();
9
13
  // Sanitize URL to prevent shell injection — only allow valid URL characters
@@ -30,6 +34,11 @@ let sharedRl = null;
30
34
  let inputLines = [];
31
35
  let inputIndex = 0;
32
36
 
37
+ /**
38
+ * Prompt the user for input, handling both TTY and piped input.
39
+ * @param {string} question - Prompt text to display
40
+ * @returns {Promise<string>} User's input
41
+ */
33
42
  function promptUser(question) {
34
43
  // If stdin is not a TTY (piped input), read all lines upfront
35
44
  if (!process.stdin.isTTY && inputLines.length === 0 && !sharedRl) {
@@ -74,7 +83,11 @@ function promptUser(question) {
74
83
 
75
84
  const TOKEN_REFRESH_BUFFER_SECONDS = 300;
76
85
 
77
- // JWT helpers
86
+ /**
87
+ * Decode and parse the claims from a JWT token.
88
+ * @param {string} token - JWT token string
89
+ * @returns {Object|null} Decoded claims or null if invalid
90
+ */
78
91
  function decodeJwtClaims(token) {
79
92
  if (!token || typeof token !== 'string') {
80
93
  return null;
@@ -93,6 +106,11 @@ function decodeJwtClaims(token) {
93
106
  }
94
107
  }
95
108
 
109
+ /**
110
+ * Get the expiration time of a JWT token in epoch seconds.
111
+ * @param {string} token - JWT token string
112
+ * @returns {number|null} Expiry epoch seconds or null if invalid
113
+ */
96
114
  function getTokenExpiryEpochSeconds(token) {
97
115
  const claims = decodeJwtClaims(token);
98
116
  if (!claims || typeof claims.exp !== 'number') {
@@ -101,6 +119,12 @@ function getTokenExpiryEpochSeconds(token) {
101
119
  return claims.exp;
102
120
  }
103
121
 
122
+ /**
123
+ * Check if a JWT token should be refreshed based on expiry.
124
+ * @param {string} token - JWT token string
125
+ * @param {number} [bufferSeconds=300] - Seconds before expiry to trigger refresh
126
+ * @returns {boolean} True if token needs refresh
127
+ */
104
128
  function shouldRefreshToken(token, bufferSeconds = TOKEN_REFRESH_BUFFER_SECONDS) {
105
129
  const exp = getTokenExpiryEpochSeconds(token);
106
130
  if (!exp) {
package/utils/config.js CHANGED
@@ -1,11 +1,19 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
 
4
+ /**
5
+ * Get the path to atris/.config in the current project.
6
+ * @returns {string} Config file path
7
+ */
4
8
  function getConfigPath() {
5
9
  const targetDir = path.join(process.cwd(), 'atris');
6
10
  return path.join(targetDir, '.config');
7
11
  }
8
12
 
13
+ /**
14
+ * Load config from atris/.config, returning empty object if missing/invalid.
15
+ * @returns {Object} Config object
16
+ */
9
17
  function loadConfig() {
10
18
  const configPath = getConfigPath();
11
19
 
@@ -21,6 +29,10 @@ function loadConfig() {
21
29
  }
22
30
  }
23
31
 
32
+ /**
33
+ * Save config to atris/.config. Exits if atris/ folder doesn't exist.
34
+ * @param {Object} config - Config object to save
35
+ */
24
36
  function saveConfig(config) {
25
37
  const configPath = getConfigPath();
26
38
  const targetDir = path.dirname(configPath);
@@ -33,11 +45,19 @@ function saveConfig(config) {
33
45
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
34
46
  }
35
47
 
48
+ /**
49
+ * Get the path to atris/.log_sync_state.json for tracking sync timestamps.
50
+ * @returns {string} Log sync state file path
51
+ */
36
52
  function getLogSyncStatePath() {
37
53
  const targetDir = path.join(process.cwd(), 'atris');
38
54
  return path.join(targetDir, '.log_sync_state.json');
39
55
  }
40
56
 
57
+ /**
58
+ * Load log sync state, returning empty object if missing/invalid.
59
+ * @returns {Object} Sync state object with last sync timestamps
60
+ */
41
61
  function loadLogSyncState() {
42
62
  const statePath = getLogSyncStatePath();
43
63
  if (!fs.existsSync(statePath)) {
@@ -51,6 +71,10 @@ function loadLogSyncState() {
51
71
  }
52
72
  }
53
73
 
74
+ /**
75
+ * Save log sync state to atris/.log_sync_state.json.
76
+ * @param {Object} state - Sync state object to save
77
+ */
54
78
  function saveLogSyncState(state) {
55
79
  const statePath = getLogSyncStatePath();
56
80
  fs.writeFileSync(statePath, JSON.stringify(state, null, 2));