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/commands/sync.js CHANGED
@@ -48,6 +48,79 @@ function _substituteParams(content, params) {
48
48
  .replace(/\{\{workspace_template\}\}/g, params.workspace_template || 'business');
49
49
  }
50
50
 
51
+ /**
52
+ * Sync the canonical skill set from atris-cli/atris/skills/* into a
53
+ * workspace's atris/skills/* (plus ensure .claude/skills/ symlinks).
54
+ *
55
+ * Shared by business mode (via syncWorkspaceTemplate) and legacy/dev mode
56
+ * (via syncAtris). Single source of truth = atris-cli/atris/skills/.
57
+ *
58
+ * Returns the number of files updated (0 if everything was up to date).
59
+ */
60
+ function syncPackageSkills(targetAtrisDir, opts = {}) {
61
+ const packageSkillsDir = path.join(__dirname, '..', 'atris', 'skills');
62
+ const userSkillsDir = path.join(targetAtrisDir, 'skills');
63
+ const claudeSkillsBaseDir = path.join(path.dirname(targetAtrisDir), '.claude', 'skills');
64
+ const verbose = opts.verbose !== false;
65
+ let updated = 0;
66
+
67
+ if (!fs.existsSync(packageSkillsDir)) return 0;
68
+
69
+ if (!fs.existsSync(userSkillsDir)) fs.mkdirSync(userSkillsDir, { recursive: true });
70
+ if (!fs.existsSync(claudeSkillsBaseDir)) fs.mkdirSync(claudeSkillsBaseDir, { recursive: true });
71
+
72
+ const skillFolders = fs.readdirSync(packageSkillsDir).filter(f => {
73
+ try { return fs.statSync(path.join(packageSkillsDir, f)).isDirectory(); }
74
+ catch { return false; }
75
+ });
76
+
77
+ for (const skill of skillFolders) {
78
+ const srcSkillDir = path.join(packageSkillsDir, skill);
79
+ const destSkillDir = path.join(userSkillsDir, skill);
80
+ const symlinkPath = path.join(claudeSkillsBaseDir, skill);
81
+
82
+ const syncRecursive = (src, dest, skillName, basePath = '') => {
83
+ if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
84
+ for (const entry of fs.readdirSync(src)) {
85
+ const srcPath = path.join(src, entry);
86
+ const destPath = path.join(dest, entry);
87
+ const relPath = basePath ? `${basePath}/${entry}` : entry;
88
+ if (fs.statSync(srcPath).isDirectory()) {
89
+ syncRecursive(srcPath, destPath, skillName, relPath);
90
+ } else {
91
+ const srcContent = fs.readFileSync(srcPath, 'utf8');
92
+ const destContent = fs.existsSync(destPath) ? fs.readFileSync(destPath, 'utf8') : '';
93
+ if (srcContent !== destContent) {
94
+ fs.writeFileSync(destPath, srcContent);
95
+ if (entry.endsWith('.sh')) fs.chmodSync(destPath, 0o755);
96
+ if (verbose) console.log(`✓ Updated atris/skills/${skillName}/${relPath}`);
97
+ updated++;
98
+ }
99
+ }
100
+ }
101
+ };
102
+
103
+ syncRecursive(srcSkillDir, destSkillDir, skill);
104
+
105
+ if (!fs.existsSync(symlinkPath)) {
106
+ const relativePath = path.relative(claudeSkillsBaseDir, destSkillDir);
107
+ try {
108
+ fs.symlinkSync(relativePath, symlinkPath);
109
+ if (verbose) console.log(`✓ Linked .claude/skills/${skill}`);
110
+ } catch (e) {
111
+ fs.mkdirSync(symlinkPath, { recursive: true });
112
+ const skillFile = path.join(destSkillDir, 'SKILL.md');
113
+ if (fs.existsSync(skillFile)) {
114
+ fs.copyFileSync(skillFile, path.join(symlinkPath, 'SKILL.md'));
115
+ }
116
+ if (verbose) console.log(`✓ Copied .claude/skills/${skill} (symlink failed)`);
117
+ }
118
+ }
119
+ }
120
+
121
+ return updated;
122
+ }
123
+
51
124
  function resolveWorkspaceTemplate(templateName = 'business') {
52
125
  const normalized = String(templateName || 'business').toLowerCase();
53
126
  if (normalized === 'research-lab' || normalized === 'researchlab' || normalized === 'lab') {
@@ -58,7 +131,7 @@ function resolveWorkspaceTemplate(templateName = 'business') {
58
131
  return { key: normalized, ...template };
59
132
  }
60
133
 
61
- function _ensureWorkspaceState(targetRoot, params, options = {}) {
134
+ function ensureWorkspaceStateFiles(targetRoot, params, options = {}) {
62
135
  const dryRun = options.dryRun === true;
63
136
  const metaDir = path.join(targetRoot, '.atris');
64
137
  const stateDir = path.join(metaDir, 'state');
@@ -164,12 +237,22 @@ function syncWorkspaceTemplate(targetRoot, bizMeta, options = {}) {
164
237
  }
165
238
  }
166
239
 
167
- const stateAddedList = _ensureWorkspaceState(targetRoot, params, { dryRun });
240
+ const stateAddedList = ensureWorkspaceStateFiles(targetRoot, params, { dryRun });
241
+
242
+ // Skills: sync the canonical skill set from atris-cli package into the
243
+ // customer workspace. Business-starter template ships skill infra (README,
244
+ // folders) but skill files live in atris-cli/atris/skills/ — single source
245
+ // of truth. Any new skill (e.g. AEO) auto-propagates to every customer.
246
+ let skillsUpdated = 0;
247
+ if (!dryRun) {
248
+ skillsUpdated = syncPackageSkills(targetAtrisDir, { verbose: false });
249
+ }
168
250
 
169
251
  console.log(` Added: ${added}`);
170
252
  console.log(` Updated: ${updated} ${force ? '' : '(--force to enable)'}`);
171
253
  console.log(` Preserved: ${preserved} (existing customizations kept)`);
172
254
  console.log(` Skipped: ${skipped} (already match template)`);
255
+ console.log(` Skills: ${skillsUpdated} updated from atris-cli/atris/skills/`);
173
256
  console.log('');
174
257
 
175
258
  if (addedList.length > 0) {
@@ -324,80 +407,8 @@ function syncAtris() {
324
407
  console.log('✓ Migrated TASK_CONTEXTS.md to TODO.md');
325
408
  }
326
409
 
327
- // Sync all skills from package to user's project
328
- const packageSkillsDir = path.join(__dirname, '..', 'atris', 'skills');
329
- const userSkillsDir = path.join(targetDir, 'skills');
330
- const claudeSkillsBaseDir = path.join(process.cwd(), '.claude', 'skills');
331
-
332
- if (fs.existsSync(packageSkillsDir)) {
333
- // Ensure directories exist
334
- if (!fs.existsSync(userSkillsDir)) {
335
- fs.mkdirSync(userSkillsDir, { recursive: true });
336
- }
337
- if (!fs.existsSync(claudeSkillsBaseDir)) {
338
- fs.mkdirSync(claudeSkillsBaseDir, { recursive: true });
339
- }
340
-
341
- // Get all skill folders from package
342
- const skillFolders = fs.readdirSync(packageSkillsDir).filter(f => {
343
- const skillPath = path.join(packageSkillsDir, f);
344
- return fs.statSync(skillPath).isDirectory();
345
- });
346
-
347
- for (const skill of skillFolders) {
348
- const srcSkillDir = path.join(packageSkillsDir, skill);
349
- const destSkillDir = path.join(userSkillsDir, skill);
350
- const symlinkPath = path.join(claudeSkillsBaseDir, skill);
351
-
352
- // Recursive sync function for skills (handles subdirs like hooks/)
353
- const syncRecursive = (src, dest, skillName, basePath = '') => {
354
- if (!fs.existsSync(dest)) {
355
- fs.mkdirSync(dest, { recursive: true });
356
- }
357
- const entries = fs.readdirSync(src);
358
- for (const entry of entries) {
359
- const srcPath = path.join(src, entry);
360
- const destPath = path.join(dest, entry);
361
- const relPath = basePath ? `${basePath}/${entry}` : entry;
362
-
363
- if (fs.statSync(srcPath).isDirectory()) {
364
- syncRecursive(srcPath, destPath, skillName, relPath);
365
- } else {
366
- const srcContent = fs.readFileSync(srcPath, 'utf8');
367
- const destContent = fs.existsSync(destPath) ? fs.readFileSync(destPath, 'utf8') : '';
368
- if (srcContent !== destContent) {
369
- fs.writeFileSync(destPath, srcContent);
370
- // Preserve executable permission for shell scripts
371
- if (entry.endsWith('.sh')) {
372
- fs.chmodSync(destPath, 0o755);
373
- }
374
- console.log(`✓ Updated atris/skills/${skillName}/${relPath}`);
375
- updated++;
376
- }
377
- }
378
- }
379
- };
380
-
381
- syncRecursive(srcSkillDir, destSkillDir, skill);
382
-
383
- // Create symlink if doesn't exist
384
- if (!fs.existsSync(symlinkPath)) {
385
- const relativePath = path.join('..', '..', 'atris', 'skills', skill);
386
- try {
387
- fs.symlinkSync(relativePath, symlinkPath);
388
- console.log(`✓ Linked .claude/skills/${skill}`);
389
- } catch (e) {
390
- // Fallback: copy instead of symlink
391
- fs.mkdirSync(symlinkPath, { recursive: true });
392
- const skillFile = path.join(destSkillDir, 'SKILL.md');
393
- if (fs.existsSync(skillFile)) {
394
- fs.copyFileSync(skillFile, path.join(symlinkPath, 'SKILL.md'));
395
- }
396
- console.log(`✓ Copied .claude/skills/${skill} (symlink failed)`);
397
- }
398
- }
399
- }
400
- }
410
+ // Sync all skills from package to user's project via shared helper.
411
+ updated += syncPackageSkills(targetDir, { verbose: true });
401
412
 
402
413
  // Update .claude/skills/atris/SKILL.md (legacy - now handled above, keeping for compatibility)
403
414
  const claudeSkillsDir = path.join(process.cwd(), '.claude', 'skills', 'atris');
@@ -654,10 +665,195 @@ function syncSkills({ silent = false } = {}) {
654
665
  return updated;
655
666
  }
656
667
 
668
+ /**
669
+ * Discover atris-managed projects under a root directory.
670
+ * A project is any directory whose immediate child `atris/` folder contains `atris.md`.
671
+ * Skips noise: node_modules, .git, .claude, dist, build, _archive, worktrees.
672
+ */
673
+ function _findAtrisProjects(rootDir, maxDepth = 8) {
674
+ const skip = new Set([
675
+ 'node_modules', '.git', '.claude', '.next', 'dist', 'build',
676
+ '_archive', 'worktrees', '.codex', '.venv', 'venv', '__pycache__',
677
+ ]);
678
+ const found = [];
679
+ function walk(dir, depth) {
680
+ if (depth > maxDepth) return;
681
+ const atrisMd = path.join(dir, 'atris', 'atris.md');
682
+ if (fs.existsSync(atrisMd)) found.push(dir);
683
+ let entries;
684
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
685
+ catch { return; }
686
+ for (const e of entries) {
687
+ if (!e.isDirectory()) continue;
688
+ if (skip.has(e.name)) continue;
689
+ if (e.name.startsWith('.')) continue;
690
+ walk(path.join(dir, e.name), depth + 1);
691
+ }
692
+ }
693
+ walk(path.resolve(rootDir), 0);
694
+ return found;
695
+ }
696
+
697
+ /**
698
+ * Sync canonical atris.md (and core docs) across every atris project under cwd.
699
+ * Walks the subtree, finds every `atris/atris.md`, copies the package source.
700
+ *
701
+ * Flags: --dry-run (preview only), --yes/--force (skip confirm).
702
+ * Skips business workspaces (they use syncWorkspaceTemplate via syncAtris).
703
+ */
704
+ // Canonical files shipped from the package root. Must match syncAtris's filesToSync.
705
+ const SYNC_ALL_FILES = [
706
+ { source: 'atris.md', target: 'atris.md' },
707
+ { source: 'atris/atrisDev.md', target: 'atrisDev.md' },
708
+ { source: 'PERSONA.md', target: 'PERSONA.md' },
709
+ { source: 'GETTING_STARTED.md', target: 'GETTING_STARTED.md' },
710
+ { source: 'atris/CLAUDE.md', target: 'CLAUDE.md' },
711
+ ];
712
+
713
+ /**
714
+ * Pure function: build the sync plan for every atris project under root.
715
+ * No console output, no writes. Returns { projects, plan } for inspection.
716
+ * Plan entries: { projectRoot, isBusiness, isCustomized, changes }.
717
+ *
718
+ * Exported for tests — the production syncAtrisAll wraps this.
719
+ */
720
+ function buildSyncAllPlan({ root, pkgRoot, filesToSync = SYNC_ALL_FILES } = {}) {
721
+ const projects = _findAtrisProjects(root);
722
+ const plan = [];
723
+ for (const projectRoot of projects) {
724
+ const atrisDir = path.join(projectRoot, 'atris');
725
+ const bizFile = path.join(projectRoot, '.atris', 'business.json');
726
+ const isSelf = path.resolve(projectRoot) === path.resolve(pkgRoot);
727
+ const isInBusinessDir = projectRoot.split(path.sep).includes('atris-business');
728
+ let isBusiness = isInBusinessDir || (fs.existsSync(bizFile) && !isSelf);
729
+ if (isBusiness && !isInBusinessDir) {
730
+ try {
731
+ const head = fs.readFileSync(path.join(atrisDir, 'atris.md'), 'utf8').slice(0, 300);
732
+ if (!/^#\s+Atris Boot Protocol/i.test(head)) isBusiness = false;
733
+ } catch {}
734
+ }
735
+ let isCustomized = false;
736
+ if (!isSelf && !isBusiness) {
737
+ try {
738
+ const head = fs.readFileSync(path.join(atrisDir, 'atris.md'), 'utf8').slice(0, 500);
739
+ const isNewCanonical = /^#\s+atris\s*\n\nAtris exists because/m.test(head);
740
+ const isOldGeneric = /^#\s+atris\.md\s*\n\n>\s+Drop this file anywhere/m.test(head);
741
+ if (!isNewCanonical && !isOldGeneric) isCustomized = true;
742
+ } catch {}
743
+ }
744
+ const changes = [];
745
+ for (const { source, target } of filesToSync) {
746
+ const sourceFile = path.join(pkgRoot, source);
747
+ const targetFile = path.join(atrisDir, target);
748
+ if (!fs.existsSync(sourceFile)) continue;
749
+ const newContent = fs.readFileSync(sourceFile, 'utf8');
750
+ const currentContent = fs.existsSync(targetFile) ? fs.readFileSync(targetFile, 'utf8') : '';
751
+ if (currentContent !== newContent) changes.push(target);
752
+ }
753
+ plan.push({ projectRoot, isBusiness, isCustomized, changes });
754
+ }
755
+ return { projects, plan };
756
+ }
757
+
758
+ function syncAtrisAll({ dryRun = false, force = false } = {}) {
759
+ const root = process.cwd();
760
+ const pkgRoot = path.join(__dirname, '..');
761
+
762
+ console.log('');
763
+ console.log(`Scanning ${root} for atris projects...`);
764
+
765
+ const filesToSync = SYNC_ALL_FILES;
766
+ const { projects, plan: initialPlan } = buildSyncAllPlan({ root, pkgRoot, filesToSync });
767
+
768
+ if (projects.length === 0) {
769
+ console.log('No atris projects found under current directory.');
770
+ return;
771
+ }
772
+
773
+ const plan = initialPlan;
774
+
775
+ // Report.
776
+ console.log(`Found ${projects.length} project(s).`);
777
+ console.log('');
778
+ let wouldUpdate = 0, unchanged = 0, skipped = 0;
779
+ for (const p of plan) {
780
+ const rel = path.relative(root, p.projectRoot) || '.';
781
+ if (p.isBusiness) {
782
+ console.log(` ⏭ ${rel} (business workspace — run "atris update" in that dir)`);
783
+ skipped++;
784
+ } else if (p.isCustomized) {
785
+ console.log(` ⏭ ${rel} (customized atris.md — review manually)`);
786
+ skipped++;
787
+ } else if (p.changes.length === 0) {
788
+ console.log(` · ${rel} (up to date)`);
789
+ unchanged++;
790
+ } else {
791
+ console.log(` → ${rel} — ${p.changes.length} file(s): ${p.changes.join(', ')}`);
792
+ wouldUpdate++;
793
+ }
794
+ }
795
+ console.log('');
796
+
797
+ if (dryRun) {
798
+ console.log(`Dry run: ${wouldUpdate} project(s) would update, ${unchanged} unchanged, ${skipped} skipped.`);
799
+ return;
800
+ }
801
+
802
+ if (wouldUpdate === 0) {
803
+ console.log('Nothing to sync.');
804
+ return;
805
+ }
806
+
807
+ // Confirm unless forced.
808
+ if (!force) {
809
+ const readline = require('readline');
810
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
811
+ return new Promise((resolve) => {
812
+ rl.question(`Sync ${wouldUpdate} project(s)? (y/N) `, (answer) => {
813
+ rl.close();
814
+ if (!/^y(es)?$/i.test(answer.trim())) {
815
+ console.log('Cancelled.');
816
+ resolve();
817
+ return;
818
+ }
819
+ _executeSyncAll(plan, pkgRoot, filesToSync, root);
820
+ resolve();
821
+ });
822
+ });
823
+ }
824
+
825
+ _executeSyncAll(plan, pkgRoot, filesToSync, root);
826
+ }
827
+
828
+ function _executeSyncAll(plan, pkgRoot, filesToSync, root) {
829
+ let updated = 0;
830
+ for (const p of plan) {
831
+ if (p.isBusiness || p.isCustomized || p.changes.length === 0) continue;
832
+ const atrisDir = path.join(p.projectRoot, 'atris');
833
+ for (const { source, target } of filesToSync) {
834
+ const sourceFile = path.join(pkgRoot, source);
835
+ const targetFile = path.join(atrisDir, target);
836
+ if (!fs.existsSync(sourceFile)) continue;
837
+ const newContent = fs.readFileSync(sourceFile, 'utf8');
838
+ const currentContent = fs.existsSync(targetFile) ? fs.readFileSync(targetFile, 'utf8') : '';
839
+ if (currentContent === newContent) continue;
840
+ fs.mkdirSync(path.dirname(targetFile), { recursive: true });
841
+ fs.copyFileSync(sourceFile, targetFile);
842
+ }
843
+ updated++;
844
+ }
845
+ console.log('');
846
+ console.log(`✓ Synced ${updated} project(s).`);
847
+ }
848
+
657
849
  module.exports = {
658
850
  syncAtris,
851
+ syncAtrisAll,
852
+ buildSyncAllPlan,
853
+ SYNC_ALL_FILES,
659
854
  syncSkills,
660
855
  syncBusinessCanonical,
661
856
  syncWorkspaceTemplate,
662
857
  resolveWorkspaceTemplate,
858
+ ensureWorkspaceStateFiles,
663
859
  };
@@ -479,6 +479,55 @@ function checkMapForFiles(atrisDir, changes) {
479
479
  return { documented: documented >= fileChanges.length / 2 };
480
480
  }
481
481
 
482
+ /**
483
+ * atris verify <slug> --section <name>
484
+ *
485
+ * Extract the first fenced bash block under "## <name>" in
486
+ * atris/features/<slug>/validate.md and execute it. Returns the exit code
487
+ * from bash. Used as the machine-checkable Verify command in TODO.md tasks.
488
+ *
489
+ * Contract (per atris.md): the rubric must be read-only, deterministic, and
490
+ * reference only the working tree. The command fails loudly when the rubric
491
+ * or section is missing — that prevents silent "trivial Verify" regressions.
492
+ */
493
+ function verifyRubric(slug, section, opts = {}) {
494
+ const cwd = opts.cwd || process.cwd();
495
+ if (!slug || !section) {
496
+ console.error('Usage: atris verify <feature-slug> --section <name>');
497
+ return 2;
498
+ }
499
+ const validateFile = path.join(cwd, 'atris', 'features', slug, 'validate.md');
500
+ if (!fs.existsSync(validateFile)) {
501
+ console.error(`✗ No rubric at ${path.relative(cwd, validateFile)}`);
502
+ return 2;
503
+ }
504
+ const content = fs.readFileSync(validateFile, 'utf8');
505
+ // Match "## <section>" (case-insensitive, anchored), skipping optional
506
+ // prose until the first ```bash or ```sh fence. Extract until the closing ```.
507
+ const escaped = section.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
508
+ const pattern = new RegExp(
509
+ `^##\\s+${escaped}\\s*$[\\s\\S]*?\\n\`\`\`(?:bash|sh)?\\s*\\n([\\s\\S]*?)\\n\`\`\``,
510
+ 'mi'
511
+ );
512
+ const match = content.match(pattern);
513
+ if (!match) {
514
+ console.error(`✗ No fenced bash block under "## ${section}" in ${path.relative(cwd, validateFile)}`);
515
+ return 2;
516
+ }
517
+ const script = match[1];
518
+ const os = require('os');
519
+ const tmpFile = path.join(os.tmpdir(), `atris-verify-${Date.now()}-${Math.floor(Math.random() * 1e6)}.sh`);
520
+ fs.writeFileSync(tmpFile, `#!/usr/bin/env bash\nset -e\n${script}\n`);
521
+ fs.chmodSync(tmpFile, 0o755);
522
+ try {
523
+ const proc = spawnSync('bash', [tmpFile], { cwd, stdio: opts.silent ? 'pipe' : 'inherit' });
524
+ return typeof proc.status === 'number' ? proc.status : 1;
525
+ } finally {
526
+ try { fs.unlinkSync(tmpFile); } catch {}
527
+ }
528
+ }
529
+
482
530
  module.exports = {
483
- verifyAtris
531
+ verifyAtris,
532
+ verifyRubric
484
533
  };
package/commands/wiki.js CHANGED
@@ -8,10 +8,14 @@ const {
8
8
  PRIVATE_WIKI_ROOT,
9
9
  getWikiRoot,
10
10
  ensureWikiScaffold,
11
+ ensureContextScaffold,
11
12
  findLocalWikiDir,
13
+ stageWikiIngest,
12
14
  buildIngestPrompt,
13
15
  buildQueryPrompt,
14
16
  buildLintPrompt,
17
+ writeWikiStatus,
18
+ appendWikiLog,
15
19
  } = require('../lib/wiki');
16
20
 
17
21
  function autoDetectSlug() {
@@ -170,9 +174,30 @@ async function wikiIngest(mode, slug, sourceValue) {
170
174
  if (mode === 'local' || mode === 'private') {
171
175
  const wikiMode = mode === 'private' ? 'private' : 'public';
172
176
  const wikiDir = ensureWikiScaffold(process.cwd(), wikiMode);
173
- printLocalPrompt(mode === 'private' ? 'Private wiki ingest' : 'Local wiki ingest', buildIngestPrompt(sourceValue, wikiMode), getWikiRoot(wikiMode), [
177
+ const contextDir = ensureContextScaffold(process.cwd(), wikiMode);
178
+ const staged = stageWikiIngest(process.cwd(), sourceValue, wikiMode);
179
+ writeWikiStatus(process.cwd(), {
180
+ health: `ingest staged from ${staged.packPath}`,
181
+ nextMove: `compile ${staged.promptSource} into ${getWikiRoot(wikiMode)}`,
182
+ }, wikiMode, { lastIngest: staged.manifest.ingested_at });
183
+ appendWikiLog(
184
+ process.cwd(),
185
+ `${staged.manifest.entries.length} source item(s) staged from ${sourceValue}`,
186
+ [
187
+ `context ${contextDir}`,
188
+ `pack ${staged.packPath}`,
189
+ `manifest ${staged.manifestPath}`,
190
+ ...staged.manifest.entries.map((entry) => `${entry.kind} ${entry.staged}`),
191
+ ],
192
+ wikiMode,
193
+ 'INGEST'
194
+ );
195
+ printLocalPrompt(mode === 'private' ? 'Private wiki ingest' : 'Local wiki ingest', buildIngestPrompt(staged.promptSource, wikiMode), getWikiRoot(wikiMode), [
174
196
  `Wiki dir: ${wikiDir}`,
175
- `Sources: ${sourceValue}`,
197
+ `Context dir: ${contextDir}`,
198
+ `Pack: ${staged.packPath}`,
199
+ `Manifest: ${staged.manifestPath}`,
200
+ `Sources: ${staged.promptSource}`,
176
201
  ]);
177
202
  return;
178
203
  }
@@ -43,7 +43,8 @@ function printWorkflowBrief(lines) {
43
43
 
44
44
  async function planAtris(userInput = null) {
45
45
  const { loadConfig } = require('../utils/config');
46
- const { loadCredentials } = require('../utils/auth');
46
+ const { loadCredentials, ensureValidCredentials } = require('../utils/auth');
47
+ const { apiRequestJson } = require('../utils/api');
47
48
  const { executeCodeExecution } = require('../utils/claude_sdk');
48
49
  const args = process.argv.slice(3);
49
50
  const executeFlag = args.includes('--execute');
@@ -243,10 +244,14 @@ async function planAtris(userInput = null) {
243
244
  if (!config.agent_id) {
244
245
  throw new Error('No agent selected. Run "atris agent" first.');
245
246
  }
246
- const credentials = loadCredentials();
247
- if (!credentials || !credentials.token) {
247
+ const ensured = await ensureValidCredentials(apiRequestJson);
248
+ if (ensured.error === 'not_logged_in' || !ensured.credentials?.token) {
248
249
  throw new Error('Not logged in. Run "atris login" first.');
249
250
  }
251
+ if (ensured.error) {
252
+ throw new Error(`Authentication failed: ${ensured.detail || ensured.error}. Run "atris login" to re-authenticate.`);
253
+ }
254
+ const credentials = ensured.credentials;
250
255
 
251
256
  // Build system prompt
252
257
  let systemPrompt = '';
@@ -362,7 +367,8 @@ async function planAtris(userInput = null) {
362
367
 
363
368
  async function doAtris() {
364
369
  const { loadConfig } = require('../utils/config');
365
- const { loadCredentials } = require('../utils/auth');
370
+ const { loadCredentials, ensureValidCredentials } = require('../utils/auth');
371
+ const { apiRequestJson } = require('../utils/api');
366
372
  const { executeCodeExecution } = require('../utils/claude_sdk');
367
373
  const args = process.argv.slice(3);
368
374
  const executeFlag = args.includes('--execute');
@@ -603,10 +609,14 @@ async function doAtris() {
603
609
  if (!config.agent_id) {
604
610
  throw new Error('No agent selected. Run "atris agent" first.');
605
611
  }
606
- const credentials = loadCredentials();
607
- if (!credentials || !credentials.token) {
612
+ const ensured = await ensureValidCredentials(apiRequestJson);
613
+ if (ensured.error === 'not_logged_in' || !ensured.credentials?.token) {
608
614
  throw new Error('Not logged in. Run "atris login" first.');
609
615
  }
616
+ if (ensured.error) {
617
+ throw new Error(`Authentication failed: ${ensured.detail || ensured.error}. Run "atris login" to re-authenticate.`);
618
+ }
619
+ const credentials = ensured.credentials;
610
620
 
611
621
  // Build system prompt
612
622
  let systemPrompt = '';
@@ -704,7 +714,8 @@ async function doAtris() {
704
714
 
705
715
  async function reviewAtris() {
706
716
  const { loadConfig } = require('../utils/config');
707
- const { loadCredentials } = require('../utils/auth');
717
+ const { loadCredentials, ensureValidCredentials } = require('../utils/auth');
718
+ const { apiRequestJson } = require('../utils/api');
708
719
  const { executeCodeExecution } = require('../utils/claude_sdk');
709
720
  const args = process.argv.slice(3);
710
721
  const executeFlag = args.includes('--execute');
@@ -959,10 +970,14 @@ async function reviewAtris() {
959
970
  if (!config.agent_id) {
960
971
  throw new Error('No agent selected. Run "atris agent" first.');
961
972
  }
962
- const credentials = loadCredentials();
963
- if (!credentials || !credentials.token) {
973
+ const ensured = await ensureValidCredentials(apiRequestJson);
974
+ if (ensured.error === 'not_logged_in' || !ensured.credentials?.token) {
964
975
  throw new Error('Not logged in. Run "atris login" first.');
965
976
  }
977
+ if (ensured.error) {
978
+ throw new Error(`Authentication failed: ${ensured.detail || ensured.error}. Run "atris login" to re-authenticate.`);
979
+ }
980
+ const credentials = ensured.credentials;
966
981
 
967
982
  // Build system prompt
968
983
  let systemPrompt = '';
package/lib/file-ops.js CHANGED
@@ -1,7 +1,11 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
 
4
- // Log file operations
4
+ /**
5
+ * Get the path components for a journal log file.
6
+ * @param {string} [dateStr] - Optional date string (defaults to today)
7
+ * @returns {Object} Object with logsDir, yearDir, logFile, dateFormatted
8
+ */
5
9
  function getLogPath(dateStr) {
6
10
  const targetDir = path.join(process.cwd(), 'atris');
7
11
  const date = dateStr ? new Date(dateStr) : new Date();
@@ -17,6 +21,9 @@ function getLogPath(dateStr) {
17
21
  return { logsDir, yearDir, logFile, dateFormatted };
18
22
  }
19
23
 
24
+ /**
25
+ * Ensure the logs directory structure exists.
26
+ */
20
27
  function ensureLogDirectory() {
21
28
  const { logsDir, yearDir } = getLogPath();
22
29
 
@@ -29,6 +36,11 @@ function ensureLogDirectory() {
29
36
  }
30
37
  }
31
38
 
39
+ /**
40
+ * Create a new daily log file, carrying forward unfinished items from yesterday.
41
+ * @param {string} logFile - Path to the log file to create
42
+ * @param {string} dateFormatted - Date string in YYYY-MM-DD format
43
+ */
32
44
  function createLogFile(logFile, dateFormatted) {
33
45
  let carryInProgress = '';
34
46
  let carryBacklog = '';
package/lib/journal.js CHANGED
@@ -4,6 +4,12 @@ const path = require('path');
4
4
  const os = require('os');
5
5
  const { spawnSync } = require('child_process');
6
6
 
7
+ /**
8
+ * Check if two timestamps are effectively the same (within 5ms).
9
+ * @param {string} a - First timestamp
10
+ * @param {string} b - Second timestamp
11
+ * @returns {boolean} True if timestamps are within 5ms of each other
12
+ */
7
13
  function isSameTimestamp(a, b) {
8
14
  if (!a || !b) return false;
9
15
  const ta = new Date(a).getTime();
@@ -12,6 +18,11 @@ function isSameTimestamp(a, b) {
12
18
  return Math.abs(ta - tb) < 5;
13
19
  }
14
20
 
21
+ /**
22
+ * Compute a SHA-256 hash of content with normalized line endings.
23
+ * @param {string} content - Content to hash
24
+ * @returns {string|null} Hex hash or null if invalid input
25
+ */
15
26
  function computeContentHash(content) {
16
27
  if (typeof content !== 'string') {
17
28
  return null;
@@ -20,6 +31,11 @@ function computeContentHash(content) {
20
31
  return crypto.createHash('sha256').update(normalized).digest('hex');
21
32
  }
22
33
 
34
+ /**
35
+ * Parse journal content into named sections (by ## headers).
36
+ * @param {string} content - Journal markdown content
37
+ * @returns {Object} Map of section name to content
38
+ */
23
39
  function parseJournalSections(content) {
24
40
  const sections = {};
25
41
  const lines = content.split('\n');
@@ -48,6 +64,13 @@ function parseJournalSections(content) {
48
64
  return sections;
49
65
  }
50
66
 
67
+ /**
68
+ * Merge local and remote journal sections, detecting conflicts.
69
+ * @param {Object} localSections - Local section map
70
+ * @param {Object} remoteSections - Remote section map
71
+ * @param {string} knownRemoteHash - Hash of remote at last sync
72
+ * @returns {{merged: Object, conflicts: Array}} Merged sections and conflicts
73
+ */
51
74
  function mergeSections(localSections, remoteSections, knownRemoteHash) {
52
75
  const merged = {};
53
76
  const conflicts = [];
package/lib/manifest.js CHANGED
@@ -62,6 +62,9 @@ function buildManifest(files, commitHash) {
62
62
  const SKIP_DIRS = new Set([
63
63
  'node_modules', '__pycache__', '.git', 'venv', '.venv',
64
64
  'lost+found', '.cache', '.atris',
65
+ // Defense-in-depth: macOS/system dirs that should never be scanned as
66
+ // part of any atris workspace, even if outputDir is accidentally wide.
67
+ 'Library', 'Applications', 'System',
65
68
  ]);
66
69
 
67
70
  function computeLocalHashes(localDir) {