atris 3.25.1 → 3.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/atris.js CHANGED
@@ -826,7 +826,7 @@ if (command === '2' && ['fast', 'pro'].includes(String(firstCommandArg || '').to
826
826
  const knownCommands = ['init', 'log', 'now', 'radar', 'ctop', 'status', 'analytics', 'visualize', 'brain', 'brainstorm', 'autopilot', 'run', 'plan', 'do', 'review', 'release',
827
827
  'activate', '_activate', 'agent', 'chat', 'fast', 'ax', 'console', 'serve', 'login', 'logout', 'whoami', 'switch', 'use', 'accounts', '_resolve', '_profile-email', '_switch-session', 'shell-init', 'update', 'upgrade', 'version', 'help', 'next', 'atris',
828
828
  'clean', 'verify', 'search', 'skill', 'member', 'codex-goal', 'app', 'apps', 'learn', 'lesson', 'plugin', 'experiments', 'receipt', 'proof', 'openclaw', 'pull', 'push', 'live', 'align', 'terminal', 'computer', 'diff', 'business', 'sync', 'youtube',
829
- 'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'probe', 'worktree', 'aeo', 'improve', 'xp', 'play', 'gm', 'x', 'recap',
829
+ 'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'probe', 'worktree', 'aeo', 'slop', 'deck', 'site', 'theme', 'card', 'reel', 'improve', 'xp', 'play', 'gm', 'x', 'recap',
830
830
  'gmail', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
831
831
  'fork', 'browse', 'publish', 'sleep', 'wake', 'feedback', 'errors', 'wiki', 'code-review', 'cr', 'soul', 'fleet', 'compile', 'spaceship'];
832
832
 
@@ -1455,7 +1455,7 @@ if (command === 'init') {
1455
1455
  console.error(`✗ Chat failed: ${error.message || error}`);
1456
1456
  process.exit(1);
1457
1457
  });
1458
- } else if (command === 'fast' || (command === 'ax' && process.argv[3] === 'fast')) {
1458
+ } else if (command === 'fast' || command === 'ax') {
1459
1459
  atrisFastChat()
1460
1460
  .then(() => process.exit(0))
1461
1461
  .catch((error) => {
@@ -2059,6 +2059,36 @@ if (command === 'init') {
2059
2059
  Promise.resolve(require('../commands/compile').compileCommand(subcommand, ...args))
2060
2060
  .then(() => process.exit(process.exitCode || 0))
2061
2061
  .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
2062
+ } else if (command === 'slop') {
2063
+ // Slop: deterministic frontend-slop detector (no LLM). Exit 1 = slop found, for CI + the autopilot gate.
2064
+ Promise.resolve(require('../commands/slop').slopCommand(process.argv.slice(3)))
2065
+ .then((code) => process.exit(typeof code === 'number' ? code : 0))
2066
+ .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
2067
+ } else if (command === 'deck') {
2068
+ // Deck: premium Google Slides from a plain content spec, via the Atris deck engine (anti-slop design system).
2069
+ Promise.resolve(require('../commands/deck').run(process.argv.slice(3)))
2070
+ .then((code) => process.exit(typeof code === 'number' ? code : 0))
2071
+ .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
2072
+ } else if (command === 'site') {
2073
+ // Site: beautiful static site from a folder of markdown, in the anti-slop design system.
2074
+ Promise.resolve(require('../commands/site').run(process.argv.slice(3)))
2075
+ .then((code) => process.exit(typeof code === 'number' ? code : 0))
2076
+ .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
2077
+ } else if (command === 'theme') {
2078
+ // Theme: brand themes (.atris/theme.json) for the whole design system (deck/html/site).
2079
+ Promise.resolve(require('../commands/theme').run(process.argv.slice(3)))
2080
+ .then((code) => process.exit(typeof code === 'number' ? code : 0))
2081
+ .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
2082
+ } else if (command === 'card') {
2083
+ // Card: one line of text into an on-brand image (uses your theme + the design system).
2084
+ Promise.resolve(require('../commands/card').run(process.argv.slice(3)))
2085
+ .then((code) => process.exit(typeof code === 'number' ? code : 0))
2086
+ .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
2087
+ } else if (command === 'reel') {
2088
+ // Reel: one line of text into a short on-brand video (an animated card; frames via Chrome + ffmpeg).
2089
+ Promise.resolve(require('../commands/reel').run(process.argv.slice(3)))
2090
+ .then((code) => process.exit(typeof code === 'number' ? code : 0))
2091
+ .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
2062
2092
  } else if (command === 'receipt' || command === 'proof' || command === 'openclaw') {
2063
2093
  const subcommand = process.argv[3];
2064
2094
  const args = process.argv.slice(4);
package/commands/align.js CHANGED
@@ -466,6 +466,26 @@ async function alignHardLocalToCloud(token, biz, localDir) {
466
466
 
467
467
  async function alignAtris() {
468
468
  // Parse args
469
+ const printUsage = () => {
470
+ console.log('Usage: atris align [business] [--fix] [--hard] [--from cloud|local] [--dry-run]');
471
+ console.log('');
472
+ console.log(' atris align Diff current workspace against cloud (auto-detect)');
473
+ console.log(' atris align example-co Diff example-co workspace');
474
+ console.log(' atris align example-co --fix Fix drift (local is canonical by default)');
475
+ console.log(' atris align example-co --fix --hard Force-push: nuke cloud cruft, upload local. Skips diff. Fast.');
476
+ console.log(' atris align example-co --fix --from cloud Cloud is canonical: pull EC2-only, delete local extras');
477
+ console.log(' atris align example-co --dry-run Show what would change, do nothing');
478
+ };
479
+
480
+ // Explicit help must win before we try to auto-detect a business slug,
481
+ // otherwise `--help` gets overwritten by the .atris/business.json fallback
482
+ // and the command attempts a real align instead of printing usage.
483
+ const firstArg = process.argv[3];
484
+ if (firstArg === '--help' || firstArg === '-h' || firstArg === 'help') {
485
+ printUsage();
486
+ process.exit(0);
487
+ }
488
+
469
489
  let slug = process.argv[3];
470
490
  if (!slug || slug.startsWith('-')) {
471
491
  const bizFile = path.join(process.cwd(), '.atris', 'business.json');
@@ -475,15 +495,8 @@ async function alignAtris() {
475
495
  if (!slug || slug.startsWith('-')) slug = null;
476
496
  }
477
497
 
478
- if (!slug || slug === '--help' || slug === '-h' || slug === 'help') {
479
- console.log('Usage: atris align [business] [--fix] [--hard] [--from cloud|local] [--dry-run]');
480
- console.log('');
481
- console.log(' atris align Diff current workspace against cloud (auto-detect)');
482
- console.log(' atris align example-co Diff example-co workspace');
483
- console.log(' atris align example-co --fix Fix drift (local is canonical by default)');
484
- console.log(' atris align example-co --fix --hard Force-push: nuke cloud cruft, upload local. Skips diff. Fast.');
485
- console.log(' atris align example-co --fix --from cloud Cloud is canonical: pull EC2-only, delete local extras');
486
- console.log(' atris align example-co --dry-run Show what would change, do nothing');
498
+ if (!slug) {
499
+ printUsage();
487
500
  process.exit(0);
488
501
  }
489
502
 
package/commands/brain.js CHANGED
@@ -32,13 +32,15 @@ const CORE_STATE_FILES = [
32
32
  'agents.jsonl',
33
33
  'approvals.jsonl',
34
34
  ];
35
+ // Loop health measures loops that actually emit receipts. Channels with no writer
36
+ // anywhere in the codebase were dropped so the dashboard reports real failures, not
37
+ // never-built aspirations: "Overnight RL" duplicated the active Pulse AGI loop, and
38
+ // "Company YC" had no data source. "Master loop" is now emitted by `atris run`.
35
39
  const LOOP_HEALTH_CHANNELS = [
36
40
  { label: 'Task plane', files: ['task_events.jsonl', 'tasks.projection.json'] },
37
- { label: 'Overnight RL', files: ['overnight_rl_self_heal.jsonl'] },
38
41
  { label: 'Career XP', files: ['career_xp_receipts.jsonl', 'career_xp.projection.json', 'gm_xp.projection.json'] },
39
42
  { label: 'Master loop', files: ['master_loop_events.jsonl'] },
40
43
  { label: 'Missions', files: ['mission_events.jsonl', 'missions.jsonl'] },
41
- { label: 'Company YC', files: ['company_yc_wow_events.jsonl', 'company_yc_wow_latest.json'] },
42
44
  { label: 'Codex goal', files: ['codex_goal.json'] },
43
45
  { label: 'Pulse AGI', files: ['pulse_agi_loop_receipts.jsonl'] },
44
46
  ];
@@ -0,0 +1,121 @@
1
+ // atris card — one line of text -> a beautiful, on-brand image (your theme).
2
+ //
3
+ // atris card "Ship faster" --kind statement --theme brand --size og
4
+ // atris card "It just works" --kind quote --by "a happy user"
5
+ // atris card --kind stat --number "10x" --label "faster reviews"
6
+ //
7
+ // Writes an .html (always) and a .png (when headless Chrome is available).
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const os = require('os');
12
+ const { execFileSync, spawnSync } = require('child_process');
13
+ const { buildCard, SIZES, KINDS } = require('../lib/card');
14
+
15
+ function parseFlags(argv) {
16
+ const flags = {}; const pos = [];
17
+ for (let i = 0; i < argv.length; i++) {
18
+ const a = argv[i];
19
+ if (a === '--html-only') { flags.htmlOnly = true; continue; }
20
+ if (a.startsWith('--')) {
21
+ const key = a.slice(2);
22
+ const next = argv[i + 1];
23
+ if (next != null && !next.startsWith('--')) { flags[key] = next; i++; } else flags[key] = true;
24
+ continue;
25
+ }
26
+ pos.push(a);
27
+ }
28
+ return { flags, pos };
29
+ }
30
+
31
+ // find an installed Chrome/Chromium without adding a dependency
32
+ function findChrome() {
33
+ if (process.env.CHROME_PATH && fs.existsSync(process.env.CHROME_PATH)) return process.env.CHROME_PATH;
34
+ const macApps = [
35
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
36
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
37
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
38
+ ];
39
+ for (const p of macApps) if (fs.existsSync(p)) return p;
40
+ for (const name of ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'chrome']) {
41
+ const r = spawnSync('command', ['-v', name], { shell: true, encoding: 'utf8' });
42
+ if (r.status === 0 && r.stdout.trim()) return r.stdout.trim();
43
+ }
44
+ return null;
45
+ }
46
+
47
+ function renderPng(chrome, html, width, height, outPng) {
48
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'atris-card-'));
49
+ const htmlFile = path.join(tmp, 'card.html');
50
+ fs.writeFileSync(htmlFile, html);
51
+ const args = [
52
+ '--headless=new', '--disable-gpu', '--hide-scrollbars',
53
+ '--force-device-scale-factor=2',
54
+ `--window-size=${width},${height}`,
55
+ '--virtual-time-budget=4000',
56
+ `--screenshot=${outPng}`,
57
+ `file://${htmlFile}`,
58
+ ];
59
+ execFileSync(chrome, args, { stdio: 'ignore', timeout: 60000 });
60
+ return fs.existsSync(outPng);
61
+ }
62
+
63
+ function run(argv) {
64
+ const { flags, pos } = parseFlags(argv);
65
+
66
+ if (pos[0] === 'help' || flags.help) {
67
+ console.log(`\n atris card — one line of text into an on-brand image\n
68
+ atris card "Your headline" [--kind statement|quote|stat] [--theme <name>] [--size og|wide|square|story]
69
+ flags: --sub --kicker --by --number --label --brand --version --out <file.png> --html-only\n
70
+ examples:
71
+ atris card "Design that builds itself" --kicker "Atris v3.23.0" --theme brand
72
+ atris card "It just works." --kind quote --by "a founder" --size square
73
+ atris card --kind stat --number "1260" --label "tests, all green"\n`);
74
+ return 0;
75
+ }
76
+
77
+ const text = pos.join(' ').trim();
78
+ const kind = flags.kind || 'statement';
79
+ if (!KINDS.includes(kind)) { console.error(` unknown kind "${kind}". try: ${KINDS.join(', ')}`); return 2; }
80
+ if (flags.size && !SIZES[flags.size]) { console.error(` unknown size "${flags.size}". try: ${Object.keys(SIZES).join(', ')}`); return 2; }
81
+ if (kind === 'stat' && !flags.number && !text) { console.error(' stat cards need --number (e.g. --number "10x")'); return 2; }
82
+ if (kind !== 'stat' && !text) { console.error(' give the card some text: atris card "Your headline"'); return 2; }
83
+
84
+ const spec = {
85
+ kind, text, headline: text,
86
+ theme: flags.theme, size: flags.size,
87
+ sub: flags.sub, kicker: flags.kicker, by: flags.by,
88
+ number: flags.number, label: flags.label,
89
+ brand: flags.brand, version: flags.version,
90
+ };
91
+
92
+ let card;
93
+ try { card = buildCard(spec); }
94
+ catch (e) { console.error(` could not build card: ${e.message}`); return 1; }
95
+
96
+ const base = (flags.out ? String(flags.out).replace(/\.png$/i, '') : `card-${kind}-${card.theme}-${card.size}`);
97
+ const outPng = path.resolve(`${base}.png`);
98
+ const outHtml = path.resolve(`${base}.html`);
99
+ fs.writeFileSync(outHtml, card.html);
100
+
101
+ if (flags.htmlOnly) {
102
+ console.log(`\n ✓ ${card.kind} card (${card.width}x${card.height}, theme ${card.theme})\n html: ${outHtml}\n open it, or render a png with Chrome installed.\n`);
103
+ return 0;
104
+ }
105
+
106
+ const chrome = findChrome();
107
+ if (!chrome) {
108
+ console.log(`\n ✓ wrote html: ${outHtml}\n no Chrome found for png. open the html, or set CHROME_PATH and re-run.\n`);
109
+ return 0;
110
+ }
111
+ try {
112
+ renderPng(chrome, card.html, card.width, card.height, outPng);
113
+ console.log(`\n ✓ ${card.kind} card, ${card.width}x${card.height}, theme ${card.theme}\n ${outPng}\n`);
114
+ return 0;
115
+ } catch (e) {
116
+ console.log(`\n ! png render failed (${e.message.split('\n')[0]})\n html is ready: ${outHtml}\n`);
117
+ return 0;
118
+ }
119
+ }
120
+
121
+ module.exports = { run };
@@ -0,0 +1,184 @@
1
+ // atris deck — generate a premium, on-brand Google Slides deck from a plain
2
+ // content spec, using the Atris deck engine (lib/slides-deck.js). The pitch:
3
+ // describe the deck, get the design system for free. No Arial-on-white slop.
4
+ //
5
+ // Usage:
6
+ // atris deck themes list design themes
7
+ // atris deck build <spec.json> [--title T] [--theme terminal|paper] [--update ID]
8
+ // atris deck sample [--theme paper] print a starter spec to stdout
9
+ //
10
+ // A spec is JSON: { theme, brand:{name,accent}, slides:[ {type,...} ] }.
11
+ // Slide types: title, statement, columns, panel, chips, bignumber, close.
12
+
13
+ const fs = require('fs');
14
+ const https = require('https');
15
+ const os = require('os');
16
+ const { buildDeck, THEMES } = require('../lib/slides-deck');
17
+ const { parseMarkdownToSpec } = require('../lib/deck-from-md');
18
+ const { mergedThemes } = require('../lib/theme');
19
+
20
+ const BASE = 'api.atris.ai';
21
+ const PFX = '/api/integrations/google-slides';
22
+
23
+ function token() {
24
+ try { return require(os.homedir() + '/.atris/credentials.json').token; }
25
+ catch { return null; }
26
+ }
27
+ function api(method, path, body, tok) {
28
+ return new Promise((resolve, reject) => {
29
+ const data = body ? JSON.stringify(body) : null;
30
+ const req = https.request({ host: BASE, path: PFX + path, method,
31
+ headers: { Authorization: 'Bearer ' + tok, 'Content-Type': 'application/json',
32
+ ...(data ? { 'Content-Length': Buffer.byteLength(data) } : {}) } },
33
+ (res) => { let b = ''; res.on('data', (c) => (b += c)); res.on('end', () => {
34
+ let j; try { j = JSON.parse(b); } catch { j = b; }
35
+ if (res.statusCode >= 300) reject(new Error('HTTP ' + res.statusCode + ': ' + (typeof j === 'string' ? j : JSON.stringify(j)).slice(0, 600)));
36
+ else resolve(j); }); });
37
+ req.on('error', reject); if (data) req.write(data); req.end();
38
+ });
39
+ }
40
+
41
+ const SAMPLE = {
42
+ theme: 'terminal',
43
+ brand: { name: 'Sentinel', accent: '.' },
44
+ slides: [
45
+ { type: 'title', headline: 'Read your incidents in the **dark.**',
46
+ sub: "On-call shouldn't mean panic. Every alert ranked by blast radius, in one calm view.",
47
+ panel: { header: { title: 'Active incidents', meta: 'updated 12s ago' },
48
+ rows: [
49
+ { title: 'Checkout latency spike', sub: 'api-gateway · us-east-1', value: '42%', valueSub: 'of traffic', sev: 0, active: true },
50
+ { title: 'Stale read replica', sub: 'orders-db · eu-west-2', value: '8%', valueSub: 'of traffic', sev: 1 },
51
+ { title: 'Elevated 4xx on search', sub: 'search-svc · global', value: '1.2%', valueSub: 'of traffic', sev: 2 },
52
+ ], footer: { left: '3 active, 1 worth a page', right: 'View all' } } },
53
+ { type: 'statement', text: "On-call shouldn't mean **panic.**",
54
+ sub: 'So the console is calm by default. One screen, ranked by real impact.' },
55
+ { type: 'columns', heading: 'What makes it calm', columns: [
56
+ { h: 'Ranked by impact', b: 'Severity comes from real blast radius, so the top of the list is the thing to fix.' },
57
+ { h: 'Quiet by default', b: 'One page-worthy signal per incident. The rest stays in the log until you ask.' },
58
+ { h: 'Built for 3am', b: 'High contrast, keyboard-first, and readable before you are fully awake.' } ] },
59
+ { type: 'bignumber', number: '11 min', label: 'median time to first action', sub: 'down from 47 minutes before Sentinel.' },
60
+ { type: 'close', tagline: 'Read your incidents in the dark.',
61
+ buttons: [{ label: 'Open the console', primary: true }, { label: 'Read the docs' }], footer: 'sentinel.sh · 2026' },
62
+ ],
63
+ };
64
+
65
+ // shared: spec -> live deck. Returns the URL.
66
+ async function publishDeck(spec, { title, updateId, tok }) {
67
+ const { requests } = buildDeck(spec, { themes: mergedThemes(THEMES) });
68
+ let id, firstSlide;
69
+ if (updateId) {
70
+ id = updateId;
71
+ const got = await api('GET', `/presentations/${id}`, null, tok);
72
+ const slides = got.slides || (got.presentation && got.presentation.slides) || [];
73
+ firstSlide = slides[0] && slides[0].objectId;
74
+ } else {
75
+ const pres = await api('POST', '/presentations', { title }, tok);
76
+ id = pres.presentationId || pres.id || (pres.presentation && pres.presentation.presentationId);
77
+ const slides = pres.slides || (pres.presentation && pres.presentation.slides) || [];
78
+ firstSlide = slides[0] && slides[0].objectId;
79
+ }
80
+ const reqs = firstSlide ? [...requests, { deleteObject: { objectId: firstSlide } }] : requests;
81
+ console.log(` building ${spec.slides.length} slides (${spec.theme}) · ${reqs.length} ops...`);
82
+ await api('POST', `/presentations/${id}/batch-update`, { requests: reqs }, tok);
83
+ return `https://docs.google.com/presentation/d/${id}/edit`;
84
+ }
85
+
86
+ // beautiful HTML output (page or AppBlock JSON) from a content spec
87
+ function outputHtml(spec, argv, srcLabel) {
88
+ const { renderHtml, renderBlock, THEMES: HTML_THEMES } = require('../lib/html-render');
89
+ const themes = mergedThemes(HTML_THEMES);
90
+ if (!themes[spec.theme]) spec.theme = 'atris';
91
+ const title = flag(argv, '--title');
92
+ if (hasFlag(argv, '--block')) {
93
+ console.log(JSON.stringify(renderBlock(spec, { title, themes }), null, 2));
94
+ return 0;
95
+ }
96
+ const html = renderHtml(spec, { title, themes });
97
+ const out = flag(argv, '--out');
98
+ if (out) { fs.writeFileSync(out, html); console.log(`\n ✓ html written: ${out}${srcLabel ? ` (from ${srcLabel})` : ''}\n`); }
99
+ else process.stdout.write(html + '\n');
100
+ return 0;
101
+ }
102
+
103
+ async function run(argv) {
104
+ const sub = argv[0];
105
+
106
+ if (sub === 'from') {
107
+ const docPath = argv.slice(1).find((a) => !a.startsWith('-'));
108
+ if (!docPath) { console.error(' usage: atris deck from <doc.md> [--theme x] [--brand Name] [--build] [--title T]'); return 2; }
109
+ let md;
110
+ try { md = fs.readFileSync(docPath, 'utf8'); }
111
+ catch (e) { console.error(` cannot read doc: ${e.message}`); return 2; }
112
+ const spec = parseMarkdownToSpec(md, { theme: flag(argv, '--theme'), brandName: flag(argv, '--brand') });
113
+ if (hasFlag(argv, '--html') || hasFlag(argv, '--block')) return outputHtml(spec, argv, docPath);
114
+ { const dt = mergedThemes(THEMES); if (!dt[spec.theme]) { console.error(` unknown theme "${spec.theme}". try: ${Object.keys(dt).join(', ')}`); return 2; } }
115
+ if (!hasFlag(argv, '--build')) {
116
+ // default: print the spec so the PM can tweak before building
117
+ console.log(JSON.stringify(spec, null, 2));
118
+ return 0;
119
+ }
120
+ const tok = token();
121
+ if (!tok) { console.error(' no credentials at ~/.atris/credentials.json — run `atris login` and connect Google Drive.'); return 1; }
122
+ const title = flag(argv, '--title') || `${(spec.brand && spec.brand.name) || 'Atris'} deck`;
123
+ const url = await publishDeck(spec, { title, updateId: flag(argv, '--update'), tok });
124
+ console.log(`\n ✓ deck from ${docPath} ready: ${url}\n`);
125
+ return 0;
126
+ }
127
+
128
+ if (sub === 'themes') {
129
+ console.log('\n atris deck themes:\n');
130
+ for (const [name, t] of Object.entries(THEMES)) {
131
+ console.log(` ${name.padEnd(10)} ${t.fonts.display} + ${t.fonts.body} · accent ${t.color.accent} bg ${t.color.bg}`);
132
+ }
133
+ console.log('\n slide types: title, statement, columns, panel, chips, bignumber, close\n');
134
+ return 0;
135
+ }
136
+
137
+ if (sub === 'sample') {
138
+ const theme = flag(argv, '--theme') || 'terminal';
139
+ console.log(JSON.stringify({ ...SAMPLE, theme }, null, 2));
140
+ return 0;
141
+ }
142
+
143
+ if (sub === 'build') {
144
+ const specPath = argv.slice(1).find((a) => !a.startsWith('-'));
145
+ if (!specPath) { console.error(' usage: atris deck build <spec.json> [--title T] [--theme x] [--update ID]'); return 2; }
146
+ let spec;
147
+ try { spec = JSON.parse(fs.readFileSync(specPath, 'utf8')); }
148
+ catch (e) { console.error(` cannot read spec: ${e.message}`); return 2; }
149
+ const themeOverride = flag(argv, '--theme'); if (themeOverride) spec.theme = themeOverride;
150
+ if (hasFlag(argv, '--html') || hasFlag(argv, '--block')) return outputHtml(spec, argv, specPath);
151
+ { const dt = mergedThemes(THEMES); if (!dt[spec.theme]) { console.error(` unknown theme "${spec.theme}". try: ${Object.keys(dt).join(', ')}`); return 2; } }
152
+ const title = flag(argv, '--title') || `${(spec.brand && spec.brand.name) || 'Atris'} deck`;
153
+
154
+ const tok = token();
155
+ if (!tok) { console.error(' no credentials at ~/.atris/credentials.json — run `atris login` and connect Google Drive.'); return 1; }
156
+
157
+ const url = await publishDeck(spec, { title, updateId: flag(argv, '--update'), tok });
158
+ console.log(`\n ✓ deck ready: ${url}\n`);
159
+ return 0;
160
+ }
161
+
162
+ console.log(`
163
+ atris deck — premium Google Slides from a plain content spec or a markdown doc
164
+
165
+ atris deck from doc.md [--build] [--title T] turn a markdown doc into a deck
166
+ atris deck from doc.md --html --out page.html beautiful HTML page (theme: atris|terminal|paper)
167
+ atris deck from doc.md --block emit the AppBlock JSON for a web app
168
+ atris deck sample [--theme paper] > my.json start from a sample spec
169
+ atris deck build my.json [--title "Q3 review"] create the deck, print the URL
170
+ atris deck build my.json --html --out p.html render the spec as HTML instead of slides
171
+ atris deck themes list design themes
172
+
173
+ 'from' maps headings to slides (## with bullets -> columns, "**X** label" -> a
174
+ big number, Close -> a closing slide). Without --build it prints the spec to tweak.
175
+ Design system is baked in: distinctive fonts, one accent, real data panels, and
176
+ no AI tells (em dashes sanitized, sentence-case labels, no gradient text).
177
+ `);
178
+ return 0;
179
+ }
180
+
181
+ function flag(argv, name) { const i = argv.indexOf(name); return i !== -1 ? argv[i + 1] : null; }
182
+ function hasFlag(argv, name) { return argv.includes(name); }
183
+
184
+ module.exports = { run, SAMPLE, publishDeck };
@@ -0,0 +1,128 @@
1
+ // atris reel — one line of text into a short, on-brand video (an animated card).
2
+ //
3
+ // atris reel "Ship faster" --theme brand --size square
4
+ // atris reel "It just works." --kind quote --by "a founder" --seconds 3
5
+ //
6
+ // Renders frames with headless Chrome (the same one `atris card` uses) and encodes
7
+ // with ffmpeg. No new dependency. Falls back to a card image if either is missing.
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const os = require('os');
12
+ const { execFile, spawnSync } = require('child_process');
13
+ const { promisify } = require('util');
14
+ const pexec = promisify(execFile);
15
+ const { buildReelFrame, reelFrames } = require('../lib/reel');
16
+ const { SIZES, KINDS } = require('../lib/card');
17
+
18
+ function parseFlags(argv) {
19
+ const flags = {}; const pos = [];
20
+ for (let i = 0; i < argv.length; i++) {
21
+ const a = argv[i];
22
+ if (a.startsWith('--')) {
23
+ const key = a.slice(2); const next = argv[i + 1];
24
+ if (next != null && !next.startsWith('--')) { flags[key] = next; i++; } else flags[key] = true;
25
+ continue;
26
+ }
27
+ pos.push(a);
28
+ }
29
+ return { flags, pos };
30
+ }
31
+
32
+ function findBin(macApps, names) {
33
+ for (const p of macApps) if (fs.existsSync(p)) return p;
34
+ for (const name of names) {
35
+ const r = spawnSync('command', ['-v', name], { shell: true, encoding: 'utf8' });
36
+ if (r.status === 0 && r.stdout.trim()) return r.stdout.trim();
37
+ }
38
+ return null;
39
+ }
40
+ const findChrome = () => (process.env.CHROME_PATH && fs.existsSync(process.env.CHROME_PATH))
41
+ ? process.env.CHROME_PATH
42
+ : findBin([
43
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
44
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
45
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
46
+ ], ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'chrome']);
47
+ const findFfmpeg = () => findBin(['/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg', '/usr/bin/ffmpeg'], ['ffmpeg']);
48
+
49
+ async function renderFrames(chrome, ts, spec, dir, w, h, onDone) {
50
+ const pad = (i) => String(i).padStart(4, '0');
51
+ let idx = 0; let done = 0;
52
+ async function worker() {
53
+ while (idx < ts.length) {
54
+ const i = idx++;
55
+ const { html } = buildReelFrame(spec, ts[i]);
56
+ const hf = path.join(dir, `f-${pad(i)}.html`);
57
+ const pf = path.join(dir, `f-${pad(i)}.png`);
58
+ fs.writeFileSync(hf, html);
59
+ await pexec(chrome, [
60
+ '--headless=new', '--disable-gpu', '--hide-scrollbars', '--force-device-scale-factor=1',
61
+ `--window-size=${w},${h}`, '--virtual-time-budget=2200',
62
+ `--user-data-dir=${path.join(dir, 'ud-' + i)}`,
63
+ `--screenshot=${pf}`, `file://${hf}`,
64
+ ], { timeout: 60000 });
65
+ onDone && onDone(++done, ts.length);
66
+ }
67
+ }
68
+ const conc = Math.min(5, ts.length);
69
+ await Promise.all(Array.from({ length: conc }, worker));
70
+ }
71
+
72
+ async function run(argv) {
73
+ const { flags, pos } = parseFlags(argv);
74
+ if (pos[0] === 'help' || flags.help) {
75
+ console.log(`\n atris reel — one line of text into a short on-brand video\n
76
+ atris reel "Your headline" [--kind statement|quote|stat] [--theme <name>] [--size square|og|wide|story] [--seconds 2.6]
77
+ flags: --sub --kicker --by --number --label --brand --version --out <file.mp4>\n`);
78
+ return 0;
79
+ }
80
+
81
+ const text = pos.join(' ').trim();
82
+ const kind = flags.kind || 'statement';
83
+ if (!KINDS.includes(kind)) { console.error(` unknown kind "${kind}". try: ${KINDS.join(', ')}`); return 2; }
84
+ const size = flags.size || 'square';
85
+ if (!SIZES[size]) { console.error(` unknown size "${size}". try: ${Object.keys(SIZES).join(', ')}`); return 2; }
86
+ if (kind === 'stat' && !flags.number && !text) { console.error(' stat reels need --number'); return 2; }
87
+ if (kind !== 'stat' && !text) { console.error(' give the reel some text: atris reel "Your headline"'); return 2; }
88
+
89
+ const seconds = Math.max(1, Math.min(8, parseFloat(flags.seconds) || 2.6));
90
+ const fps = 20;
91
+ const spec = {
92
+ kind, text, headline: text, theme: flags.theme, size,
93
+ sub: flags.sub, kicker: flags.kicker, by: flags.by,
94
+ number: flags.number, label: flags.label, brand: flags.brand, version: flags.version,
95
+ };
96
+ const { w, h } = SIZES[size];
97
+ const base = flags.out ? String(flags.out).replace(/\.mp4$/i, '') : `reel-${kind}-${flags.theme || 'atris'}-${size}`;
98
+ const outMp4 = path.resolve(`${base}.mp4`);
99
+
100
+ const chrome = findChrome();
101
+ if (!chrome) { console.error('\n no Chrome found to render frames. install Chrome or set CHROME_PATH.\n'); return 1; }
102
+ const ffmpeg = findFfmpeg();
103
+ if (!ffmpeg) { console.error('\n no ffmpeg found to encode the video. install ffmpeg (brew install ffmpeg).\n'); return 1; }
104
+
105
+ const ts = reelFrames(seconds, fps);
106
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'atris-reel-'));
107
+ console.log(`\n rendering ${ts.length} frames (${seconds}s, ${size})...`);
108
+ try {
109
+ await renderFrames(chrome, ts, spec, dir, w, h, (d, n) => {
110
+ if (d === n || d % 10 === 0) process.stdout.write(`\r frames ${d}/${n} `);
111
+ });
112
+ process.stdout.write('\n encoding...\n');
113
+ await pexec(ffmpeg, [
114
+ '-y', '-framerate', String(fps), '-i', path.join(dir, 'f-%04d.png'),
115
+ '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', outMp4,
116
+ ]);
117
+ } catch (e) {
118
+ console.error(`\n render failed: ${String(e.message).split('\n')[0]}`); return 1;
119
+ } finally {
120
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
121
+ }
122
+
123
+ if (!fs.existsSync(outMp4)) { console.error(' no video produced'); return 1; }
124
+ console.log(`\n ✓ ${kind} reel, ${w}x${h}, ${seconds}s, theme ${flags.theme || 'atris'}\n ${outMp4}\n`);
125
+ return 0;
126
+ }
127
+
128
+ module.exports = { run, findChrome, findFfmpeg };
package/commands/run.js CHANGED
@@ -280,6 +280,35 @@ function hasWork(atrisDir) {
280
280
  return false;
281
281
  }
282
282
 
283
+ /**
284
+ * Append a durable Master-loop receipt for one cycle's review verdict.
285
+ *
286
+ * Closes the loop: until now the validator's pass/fail lived only in RAM
287
+ * (carried to the next cycle's plan), so a crash lost it and `atris brain`
288
+ * could never see that the review step ran. This persists the verdict to
289
+ * .atris/state/master_loop_events.jsonl, the channel the brain reads for
290
+ * loop health. Best-effort: telemetry must never fail the run.
291
+ */
292
+ function appendMasterLoopReceipt(receipt, stateDir = path.join(process.cwd(), '.atris', 'state')) {
293
+ try {
294
+ fs.mkdirSync(stateDir, { recursive: true });
295
+ const row = {
296
+ ts: new Date().toISOString(),
297
+ run_stamp: receipt.runStamp || null,
298
+ cycle: receipt.cycle,
299
+ verdict: receipt.verdict,
300
+ plan_ms: receipt.timing ? receipt.timing.plan : null,
301
+ do_ms: receipt.timing ? receipt.timing.do : null,
302
+ review_ms: receipt.timing ? receipt.timing.review : null,
303
+ review_summary: String(receipt.reviewOutput || '').replace(/\s+/g, ' ').trim().slice(0, 280),
304
+ };
305
+ fs.appendFileSync(path.join(stateDir, 'master_loop_events.jsonl'), JSON.stringify(row) + '\n');
306
+ return row;
307
+ } catch (_) {
308
+ return null;
309
+ }
310
+ }
311
+
283
312
  /**
284
313
  * Log completion to journal
285
314
  */
@@ -453,6 +482,10 @@ async function runAtris(options = {}) {
453
482
  // Carry the review output into the next cycle's plan — closes the loop
454
483
  lastReviewOutput = reviewOutput;
455
484
 
485
+ // Persist the verdict durably so the brain can see the review actually ran.
486
+ const verdict = reviewOutput.includes('[REVIEW_FAILED]') ? 'fail' : 'pass';
487
+ appendMasterLoopReceipt({ runStamp, cycle, verdict, timing, reviewOutput });
488
+
456
489
  if (reviewOutput.includes('[REVIEW_FAILED]')) {
457
490
  console.log(verbose
458
491
  ? '⚠ Review found issues. Stopping for manual check.'
@@ -989,4 +1022,4 @@ function diffRunLogs(args = []) {
989
1022
  }
990
1023
  }
991
1024
 
992
- module.exports = { runAtris, getRunLogDir, getRunLogPath, writePhaseToRunLog, listRunLogs, pruneRunLogs, searchRunLogs, statsRunLogs, exportRunLogs, diffRunLogs, buildRunPrompt };
1025
+ module.exports = { runAtris, getRunLogDir, getRunLogPath, writePhaseToRunLog, appendMasterLoopReceipt, listRunLogs, pruneRunLogs, searchRunLogs, statsRunLogs, exportRunLogs, diffRunLogs, buildRunPrompt };
@@ -0,0 +1,48 @@
1
+ // atris site — turn a folder of markdown (docs, your wiki, memory) into a
2
+ // beautiful, navigable static site in the design system. Built on lib/site.js.
3
+ //
4
+ // atris site <dir|doc.md> [--out dist] [--theme atris|terminal|paper] [--title T] [--serve]
5
+
6
+ const { buildSite, serveSite } = require('../lib/site');
7
+
8
+ function flag(argv, name) { const i = argv.indexOf(name); return i !== -1 ? argv[i + 1] : null; }
9
+ function hasFlag(argv, name) { return argv.includes(name); }
10
+
11
+ async function run(argv) {
12
+ const input = argv.find((a) => !a.startsWith('-'));
13
+ if (!input || input === 'help' || hasFlag(argv, '--help')) {
14
+ console.log(`
15
+ atris site — a beautiful static site from a folder of markdown
16
+
17
+ atris site <dir|doc.md> [--out dist] [--theme atris|terminal|paper] [--title T]
18
+ atris site atris/wiki --title "Atris Wiki" --serve
19
+
20
+ Each .md becomes a page; an index links them all. Same anti-slop design system,
21
+ semantic data-atris-block sections, ready for the web app. --serve previews it.
22
+ `);
23
+ return input === 'help' || hasFlag(argv, '--help') ? 0 : 2;
24
+ }
25
+
26
+ let res;
27
+ try {
28
+ res = buildSite(input, {
29
+ out: flag(argv, '--out') || 'dist',
30
+ theme: flag(argv, '--theme'),
31
+ title: flag(argv, '--title'),
32
+ brand: flag(argv, '--brand'),
33
+ });
34
+ } catch (e) { console.error(` ${e.message}`); return 2; }
35
+
36
+ console.log(`\n ✓ site built: ${res.pages.length} page${res.pages.length === 1 ? '' : 's'} + index -> ${res.outDir}/`);
37
+ console.log(` open ${res.indexPath}`);
38
+
39
+ if (hasFlag(argv, '--serve')) {
40
+ const port = Number(flag(argv, '--port')) || 4321;
41
+ const { url } = await serveSite(res.outDir, port);
42
+ console.log(`\n serving at ${url} (ctrl-c to stop)\n`);
43
+ await new Promise(() => {}); // keep alive until killed
44
+ }
45
+ return 0;
46
+ }
47
+
48
+ module.exports = { run };