atris 3.30.1 → 3.30.3

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 (67) hide show
  1. package/AGENTS.md +6 -0
  2. package/atris/AGENTS.md +11 -0
  3. package/atris/CLAUDE.md +5 -0
  4. package/atris/atris.md +19 -4
  5. package/atris/policies/atris-design.md +71 -0
  6. package/atris/policies/design-seed.md +187 -0
  7. package/atris/skills/atris/SKILL.md +26 -3
  8. package/atris/skills/design/SKILL.md +3 -2
  9. package/atris/skills/loop/SKILL.md +5 -3
  10. package/atris/team/_template/MEMBER.md +19 -0
  11. package/atris.md +63 -23
  12. package/ax +1434 -1698
  13. package/bin/atris.js +5 -1
  14. package/commands/agent-spawn.js +2 -2
  15. package/commands/brain.js +92 -7
  16. package/commands/brainstorm.js +62 -22
  17. package/commands/business-sync.js +92 -8
  18. package/commands/business.js +13 -7
  19. package/commands/chat-scan.js +102 -0
  20. package/commands/computer.js +758 -15
  21. package/commands/deck.js +505 -105
  22. package/commands/init.js +6 -1
  23. package/commands/launchpad.js +638 -0
  24. package/commands/log-sync.js +44 -13
  25. package/commands/loop-front.js +290 -0
  26. package/commands/member.js +124 -3
  27. package/commands/mission.js +717 -32
  28. package/commands/pull.js +37 -28
  29. package/commands/pulse.js +11 -8
  30. package/commands/run.js +79 -39
  31. package/commands/sync.js +36 -17
  32. package/commands/task.js +342 -66
  33. package/commands/xp.js +3 -0
  34. package/commands/youtube.js +221 -7
  35. package/decks/README.md +89 -0
  36. package/decks/archetype-catalog.json +180 -0
  37. package/decks/atris-antislop-pitch.json +48 -0
  38. package/decks/atris-archetypes-v2.json +83 -0
  39. package/decks/atris-one-loop-pitch.json +80 -0
  40. package/decks/atris-seed-pitch-v3.json +118 -0
  41. package/decks/atris-seed-pitch-v4-skeleton.json +106 -0
  42. package/decks/atris-seed-pitch-v5.json +109 -0
  43. package/decks/atris-seed-pitch-v6.json +137 -0
  44. package/decks/atris-seed-pitch-v7.json +133 -0
  45. package/decks/atris-single-shot-proof.json +74 -0
  46. package/decks/mark-pincus-narrative.json +102 -0
  47. package/decks/mark-pincus-sourcery.json +94 -0
  48. package/decks/yash-applied-compute-detailed.json +150 -0
  49. package/decks/yash-applied-compute-generalist.json +82 -0
  50. package/decks/yash-applied-compute-narrative.json +54 -0
  51. package/lib/ax-prefs.js +5 -12
  52. package/lib/chat-log-scan.js +377 -0
  53. package/lib/context-gatherer.js +35 -1
  54. package/lib/deck-compose.js +145 -0
  55. package/lib/deck-history.js +64 -0
  56. package/lib/deck-layout.js +169 -0
  57. package/lib/deck-review.js +431 -0
  58. package/lib/deck-schema.js +154 -0
  59. package/lib/file-ops.js +2 -2
  60. package/lib/functional-owner.js +189 -0
  61. package/lib/slides-deck.js +512 -58
  62. package/lib/task-db.js +109 -2
  63. package/package.json +2 -1
  64. package/templates/business-starter/team/START_HERE.md +12 -8
  65. package/utils/auth.js +4 -0
  66. package/utils/config.js +4 -0
  67. package/atris/atrisDev.md +0 -717
package/commands/deck.js CHANGED
@@ -4,133 +4,302 @@
4
4
  //
5
5
  // Usage:
6
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
7
+ // atris deck build <spec.json> [--title T] [--theme terminal|paper|ink|noir] [--review]
8
+ // atris deck lint <spec.json> pre-build layout warnings
9
+ // atris deck review <id|url> [--spec path] [--confirm NOTE] [--json]
10
+ // atris deck sample [--theme paper] print a starter spec
9
11
  //
10
12
  // A spec is JSON: { theme, brand:{name,accent}, slides:[ {type,...} ] }.
11
- // Slide types: title, statement, columns, panel, chips, bignumber, close.
13
+ // Slide types: title, statement, columns, panel, chips, bignumber, close,
14
+ // timeline, versus, metricgrid, receipt, stack, quote, hero.
12
15
 
13
16
  const fs = require('fs');
14
17
  const https = require('https');
15
18
  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
+ const path = require('path');
20
+ const { buildDeck, THEMES, notesRequests } = require('../lib/slides-deck');
21
+ const {
22
+ lintSpec,
23
+ reviewDeck,
24
+ confirmReview,
25
+ extractPresentationId,
26
+ DEFAULT_OUT_ROOT,
27
+ } = require('../lib/deck-review');
28
+ const { validateSpec, checkSpec, hasErrors } = require('../lib/deck-schema');
29
+ const { recordBuild, historyFor } = require('../lib/deck-history');
30
+ const { composeSpec } = require('../lib/deck-compose');
19
31
 
20
32
  const BASE = 'api.atris.ai';
21
33
  const PFX = '/api/integrations/google-slides';
22
34
 
23
35
  function token() {
24
- try { return require(os.homedir() + '/.atris/credentials.json').token; }
36
+ try { return require(path.join(os.homedir(), '.atris/credentials.json')).token; }
25
37
  catch { return null; }
26
38
  }
27
- function api(method, path, body, tok) {
39
+
40
+ function api(method, apiPath, body, tok) {
28
41
  return new Promise((resolve, reject) => {
29
42
  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();
43
+ const req = https.request({
44
+ host: BASE,
45
+ path: PFX + apiPath,
46
+ method,
47
+ headers: {
48
+ Authorization: `Bearer ${tok}`,
49
+ 'Content-Type': 'application/json',
50
+ ...(data ? { 'Content-Length': Buffer.byteLength(data) } : {}),
51
+ },
52
+ }, (res) => {
53
+ let b = '';
54
+ res.on('data', (c) => { b += c; });
55
+ res.on('end', () => {
56
+ let j;
57
+ try { j = JSON.parse(b); } catch { j = b; }
58
+ if (res.statusCode >= 300) {
59
+ reject(new Error(`HTTP ${res.statusCode}: ${(typeof j === 'string' ? j : JSON.stringify(j)).slice(0, 600)}`));
60
+ } else {
61
+ resolve(j);
62
+ }
63
+ });
64
+ });
65
+ req.on('error', reject);
66
+ if (data) req.write(data);
67
+ req.end();
38
68
  });
39
69
  }
40
70
 
71
+ const THEME_HINTS = {
72
+ terminal: 'warm dark · investor / product wedge',
73
+ paper: 'warm light · editorial default',
74
+ ink: 'stark light · magazine / press',
75
+ noir: 'cool dark · podcast / interview',
76
+ };
77
+
41
78
  const SAMPLE = {
42
79
  theme: 'terminal',
43
80
  brand: { name: 'Sentinel', accent: '.' },
44
81
  slides: [
45
- { type: 'title', headline: 'Read your incidents in the **dark.**',
82
+ {
83
+ type: 'title',
84
+ headline: 'Read your incidents in the **dark.**',
46
85
  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' },
86
+ panel: {
87
+ header: { title: 'Active incidents', meta: 'updated 12s ago' },
48
88
  rows: [
49
89
  { title: 'Checkout latency spike', sub: 'api-gateway · us-east-1', value: '42%', valueSub: 'of traffic', sev: 0, active: true },
50
90
  { title: 'Stale read replica', sub: 'orders-db · eu-west-2', value: '8%', valueSub: 'of traffic', sev: 1 },
51
91
  { 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.' } ] },
92
+ ],
93
+ footer: { left: '3 active, 1 worth a page', right: 'View all' },
94
+ },
95
+ },
96
+ { type: 'statement', text: "On-call shouldn't mean **panic.**", sub: 'So the console is calm by default. One screen, ranked by real impact.' },
97
+ {
98
+ type: 'columns',
99
+ heading: 'What makes it calm',
100
+ columns: [
101
+ { h: 'Ranked by impact', b: 'Severity comes from real blast radius, so the top of the list is the thing to fix.' },
102
+ { h: 'Quiet by default', b: 'One page-worthy signal per incident. The rest stays in the log until you ask.' },
103
+ { h: 'Built for 3am', b: 'High contrast, keyboard-first, and readable before you are fully awake.' },
104
+ ],
105
+ },
59
106
  { 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' },
107
+ {
108
+ type: 'close',
109
+ tagline: 'Read your incidents in the dark.',
110
+ buttons: [{ label: 'Open the console', primary: true }, { label: 'Read the docs' }],
111
+ footer: 'sentinel.sh · 2026',
112
+ },
62
113
  ],
63
114
  };
64
115
 
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;
116
+ function flag(argv, name) {
117
+ const i = argv.indexOf(name);
118
+ return i !== -1 ? argv[i + 1] : null;
119
+ }
120
+
121
+ function hasFlag(argv, name) {
122
+ return argv.includes(name);
123
+ }
124
+
125
+ // Flags that take a value, so their following token is consumed (not a path).
126
+ const VALUE_FLAGS = new Set(['--theme', '--title', '--update', '--out', '--style', '--url', '--md']);
127
+
128
+ // Positional args, skipping boolean flags and value-flag values. Lets batch
129
+ // build collect real spec paths even when flags like `--theme noir` precede them.
130
+ function positionals(argv) {
131
+ const out = [];
132
+ for (let i = 0; i < argv.length; i++) {
133
+ const a = argv[i];
134
+ if (a.startsWith('-')) { if (VALUE_FLAGS.has(a)) i += 1; continue; }
135
+ out.push(a);
136
+ }
137
+ return out;
138
+ }
139
+
140
+ function readSpec(specPath) {
141
+ return JSON.parse(fs.readFileSync(specPath, 'utf8'));
142
+ }
143
+
144
+ function printLint(findings) {
145
+ if (!findings.length) {
146
+ console.log('\n ✓ spec lint clean\n');
147
+ return;
148
+ }
149
+ console.log('');
150
+ for (const f of findings) {
151
+ const icon = f.severity === 'error' ? '✗' : f.severity === 'warn' ? '⚠' : '·';
152
+ console.log(` ${icon} slide ${f.slide} ${f.rule.padEnd(22)} ${f.message}`);
153
+ }
154
+ console.log('');
155
+ }
156
+
157
+ async function runReviewFlow({ presentationId, spec, specPath, json, auto = false }) {
158
+ const tok = token();
159
+ if (!tok) {
160
+ console.error(' no credentials at ~/.atris/credentials.json — run `atris login` and connect Google Drive.');
161
+ return 1;
162
+ }
163
+ const { packet, manifestPath, outDir } = await reviewDeck({
164
+ presentationId,
165
+ spec,
166
+ specPath,
167
+ api,
168
+ token: tok,
169
+ auto,
170
+ });
171
+ if (json) {
172
+ console.log(JSON.stringify({ ...packet, manifestPath, outDir }, null, 2));
173
+ } else {
174
+ console.log(`\n review packet: ${manifestPath}`);
175
+ console.log(` thumbnails: ${outDir}`);
176
+ console.log(` status: ${packet.status}`);
177
+ console.log(` slides: ${packet.slides.length}`);
178
+ if (packet.autoReview) {
179
+ const ar = packet.autoReview;
180
+ console.log(` auto review: ${ar.passed}/${ar.results.length} thumbnails ok${ar.failed ? ` · ${ar.failed} FLAGGED` : ''}`);
181
+ for (const r of ar.results.filter((x) => !x.ok)) {
182
+ console.log(` ✗ slide ${r.index}${r.type ? ` (${r.type})` : ''}: ${r.reason}`);
183
+ }
184
+ }
185
+ if (packet.specLint.length) {
186
+ printLint(packet.specLint);
187
+ } else {
188
+ console.log(' spec lint: clean');
189
+ }
190
+ console.log(' next: open PNGs, fix spec if needed, then:');
191
+ console.log(` atris deck review ${packet.presentationId} --confirm "looks good"\n`);
192
+ }
193
+ return 0;
194
+ }
195
+
196
+ // Pure: given the engine's build requests and what the API reports, produce the
197
+ // final batch-update request list.
198
+ // - update: delete every existing slide first, then add the fresh ones. The
199
+ // engine reuses fixed objectIds (deck_slide_N), so a prior build's slides
200
+ // must be removed before recreating them or the API rejects duplicate IDs.
201
+ // - create: append a delete of the blank default slide a new deck ships with.
202
+ function planBatch({ requests, updateId = null, existingSlides = [], newFirstSlideId = null }) {
203
+ if (updateId) {
204
+ const deletions = (existingSlides || [])
205
+ .filter((s) => s && s.objectId)
206
+ .map((s) => ({ deleteObject: { objectId: s.objectId } }));
207
+ return [...deletions, ...requests];
208
+ }
209
+ return newFirstSlideId ? [...requests, { deleteObject: { objectId: newFirstSlideId } }] : requests;
210
+ }
211
+
212
+ // Publish a deck via the Slides API (create or in-place update). api/token are
213
+ // injected so this is unit-testable with a mock transport.
214
+ async function publishDeck({ spec, title, updateId, api: apiFn, token: tok }) {
215
+ const { requests } = buildDeck(spec);
216
+ let id;
217
+ let batch;
69
218
  if (updateId) {
70
219
  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;
220
+ const got = await apiFn('GET', `/presentations/${id}`, null, tok);
221
+ const existing = got.slides || (got.presentation && got.presentation.slides) || [];
222
+ batch = planBatch({ requests, updateId, existingSlides: existing });
74
223
  } else {
75
- const pres = await api('POST', '/presentations', { title }, tok);
224
+ const pres = await apiFn('POST', '/presentations', { title }, tok);
76
225
  id = pres.presentationId || pres.id || (pres.presentation && pres.presentation.presentationId);
77
226
  const slides = pres.slides || (pres.presentation && pres.presentation.slides) || [];
78
- firstSlide = slides[0] && slides[0].objectId;
227
+ batch = planBatch({ requests, newFirstSlideId: slides[0] && slides[0].objectId });
79
228
  }
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`;
229
+ await apiFn('POST', `/presentations/${id}/batch-update`, { requests: batch }, tok);
230
+
231
+ // Second pass for speaker notes: the notes body objectIds only exist once the
232
+ // slides do, so re-read the deck and insert notes for slides that declare them.
233
+ let notes = 0;
234
+ if ((spec.slides || []).some((s) => s.notes)) {
235
+ const got = await apiFn('GET', `/presentations/${id}`, null, tok);
236
+ const apiSlides = got.slides || (got.presentation && got.presentation.slides) || [];
237
+ const nreqs = notesRequests(apiSlides, spec.slides);
238
+ if (nreqs.length) {
239
+ await apiFn('POST', `/presentations/${id}/batch-update`, { requests: nreqs }, tok);
240
+ notes = nreqs.length;
241
+ }
242
+ }
243
+ return { id, url: `https://docs.google.com/presentation/d/${id}/edit`, ops: batch.length, notes };
84
244
  }
85
245
 
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;
246
+ // One-shot: an analysis text -> composed spec -> lint gate -> published deck.
247
+ // api/token injected so the orchestration is testable without the network.
248
+ // Returns { ok:false, findings } if the composed spec fails the lint gate.
249
+ async function videoToDeck({ analysisText, title, theme, style, url, api: apiFn, token: tok }) {
250
+ const spec = composeSpec(analysisText, { theme, style, title, url });
251
+ const findings = checkSpec(spec, lintSpec);
252
+ if (hasErrors(findings)) return { ok: false, spec, findings };
253
+ const published = await publishDeck({
254
+ spec,
255
+ title: title || (spec.brand && spec.brand.name) || 'Atris deck',
256
+ updateId: null,
257
+ api: apiFn,
258
+ token: tok,
259
+ });
260
+ return { ok: true, spec, findings, ...published };
261
+ }
262
+
263
+ // Build several specs in sequence (avoids rate-limit bursts). Plain build only.
264
+ async function runBatch(specPaths, { themeOverride }) {
265
+ const tok = token();
266
+ if (!tok) {
267
+ console.error(' no credentials at ~/.atris/credentials.json — run `atris login` and connect Google Drive.');
268
+ return 1;
95
269
  }
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;
270
+ let failures = 0;
271
+ for (const specPath of specPaths) {
272
+ let spec;
273
+ try { spec = readSpec(specPath); }
274
+ catch (e) { console.error(` ${specPath}: ${e.message}`); failures += 1; continue; }
275
+ if (themeOverride) spec.theme = themeOverride;
276
+ if (!THEMES[spec.theme]) { console.error(` ✗ ${specPath}: unknown theme "${spec.theme}"`); failures += 1; continue; }
277
+ const findings = validateSpec(spec);
278
+ if (hasErrors(findings)) { console.error(` ✗ ${specPath}: invalid spec`); printLint(findings); failures += 1; continue; }
279
+ try {
280
+ const { id, url, ops } = await publishDeck({ spec, title: (spec.brand && spec.brand.name) || 'Atris deck', updateId: null, api, token: tok });
281
+ try { recordBuild({ spec, specPath, presentationId: id, url, mode: 'create' }); } catch { /* ignore */ }
282
+ console.log(` ✓ ${specPath} -> ${url} (${ops} ops)`);
283
+ } catch (e) { console.error(` ✗ ${specPath}: ${e.message}`); failures += 1; }
284
+ }
285
+ console.log(`\n built ${specPaths.length - failures}/${specPaths.length} decks\n`);
286
+ return failures ? 1 : 0;
101
287
  }
102
288
 
103
289
  async function run(argv) {
104
290
  const sub = argv[0];
105
291
 
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
292
  if (sub === 'themes') {
129
293
  console.log('\n atris deck themes:\n');
130
294
  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}`);
295
+ const hint = THEME_HINTS[name] || '';
296
+ console.log(` ${name.padEnd(10)} ${hint}`);
297
+ console.log(` ${t.fonts.display} + ${t.fonts.body} · accent ${t.color.accent} bg ${t.color.bg}`);
132
298
  }
133
- console.log('\n slide types: title, statement, columns, panel, chips, bignumber, close\n');
299
+ console.log('\n pick one: spec.json "theme" field OR atris deck build spec.json --theme noir');
300
+ console.log('\n slide types: title, statement, columns, panel, chips, bignumber, close,');
301
+ console.log(' timeline, versus, metricgrid, receipt, stack, quote, hero,');
302
+ console.log(' interstitial, lede, prose, split, bullets (narrative / box-free)\n');
134
303
  return 0;
135
304
  }
136
305
 
@@ -140,45 +309,276 @@ async function run(argv) {
140
309
  return 0;
141
310
  }
142
311
 
143
- if (sub === 'build') {
312
+ if (sub === 'history') {
313
+ const query = argv.slice(1).find((a) => !a.startsWith('-'));
314
+ const entries = historyFor(query);
315
+ if (hasFlag(argv, '--json')) {
316
+ console.log(JSON.stringify(entries, null, 2));
317
+ return 0;
318
+ }
319
+ if (!entries.length) {
320
+ console.log(`\n no deck builds recorded${query ? ` for "${query}"` : ''} yet\n`);
321
+ return 0;
322
+ }
323
+ console.log('');
324
+ for (const e of entries) {
325
+ const when = (e.at || '').replace('T', ' ').slice(0, 16);
326
+ console.log(` ${when} ${String(e.mode).padEnd(6)} ${String(e.slideCount).padStart(2)}sl ${String(e.theme || '?').padEnd(8)} ${e.specHash}`);
327
+ console.log(` ${e.specPath || '(inline)'} -> ${e.url || e.presentationId}`);
328
+ }
329
+ console.log('');
330
+ return 0;
331
+ }
332
+
333
+ if (sub === 'compose') {
334
+ const out = flag(argv, '--out');
335
+ const url = flag(argv, '--url');
336
+ const mdPath = flag(argv, '--md');
337
+ const composeOpts = {
338
+ theme: flag(argv, '--theme'),
339
+ style: flag(argv, '--style'),
340
+ title: flag(argv, '--title'),
341
+ url,
342
+ };
343
+ if (!url && !mdPath) {
344
+ console.error(' usage: atris deck compose (--url <youtube> | --md <file.md>) [--out spec.json] [--theme ink] [--style narrative|dense|pitch]');
345
+ return 2;
346
+ }
347
+ let text;
348
+ if (mdPath) {
349
+ try { text = fs.readFileSync(mdPath, 'utf8'); }
350
+ catch (e) { console.error(` cannot read ${mdPath}: ${e.message}`); return 2; }
351
+ } else {
352
+ // Network path: fetch a transcript analysis from the cloud, then compose.
353
+ try {
354
+ const { processYoutube } = require('./youtube');
355
+ console.error(` fetching analysis for ${url} (this can take a minute)...`);
356
+ const data = await processYoutube({
357
+ youtubeUrl: url,
358
+ query: 'Summarize this video into a slide outline. Use markdown: one H1 title, then ## sections. Within a section use a bullet list for parallel points, a blockquote for a memorable line (with attribution), and short paragraphs for narrative. End with a ## Takeaway.',
359
+ timeoutMs: 300000,
360
+ });
361
+ text = data.video_analysis || data.analysis || data.result || (typeof data === 'string' ? data : '');
362
+ } catch (e) {
363
+ console.error(` compose --url needs Atris cloud access (run \`atris login\`): ${e.message}`);
364
+ return 1;
365
+ }
366
+ if (!text) { console.error(' no analysis returned for that URL'); return 1; }
367
+ }
368
+ const spec = composeSpec(text, composeOpts);
369
+ const findings = checkSpec(spec, lintSpec);
370
+ if (out) {
371
+ fs.writeFileSync(out, `${JSON.stringify(spec, null, 2)}\n`);
372
+ console.error(`\n ✓ composed ${spec.slides.length} slides (${spec.theme}) -> ${out}`);
373
+ printLint(findings);
374
+ console.error(` next: atris deck build ${out} --review\n`);
375
+ } else {
376
+ // stdout stays clean JSON for piping; lint goes to stderr
377
+ console.log(JSON.stringify(spec, null, 2));
378
+ const errs = findings.filter((f) => f.severity === 'error').length;
379
+ const warns = findings.filter((f) => f.severity === 'warn').length;
380
+ console.error(` composed ${spec.slides.length} slides (${spec.theme}) · lint: ${errs} errors, ${warns} warnings`);
381
+ }
382
+ return hasErrors(findings) ? 1 : 0;
383
+ }
384
+
385
+ if (sub === 'lint') {
144
386
  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; }
387
+ if (!specPath) {
388
+ console.error(' usage: atris deck lint <spec.json>');
389
+ return 2;
390
+ }
146
391
  let spec;
147
- try { spec = JSON.parse(fs.readFileSync(specPath, 'utf8')); }
392
+ try { spec = readSpec(specPath); }
148
393
  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; } }
394
+ const findings = checkSpec(spec, lintSpec);
395
+ if (hasFlag(argv, '--json')) {
396
+ console.log(JSON.stringify({ ok: !hasErrors(findings), findings }, null, 2));
397
+ } else {
398
+ printLint(findings);
399
+ }
400
+ return hasErrors(findings) ? 1 : 0;
401
+ }
402
+
403
+ if (sub === 'review') {
404
+ const idArg = argv.slice(1).find((a) => !a.startsWith('-'));
405
+ if (!idArg) {
406
+ console.error(' usage: atris deck review <presentation-id|url> [--spec spec.json] [--confirm NOTE] [--json]');
407
+ return 2;
408
+ }
409
+ const confirmNote = flag(argv, '--confirm');
410
+ if (confirmNote) {
411
+ const { manifestPath, packet, alreadyConfirmed, receiptPath } = confirmReview(extractPresentationId(idArg), confirmNote);
412
+ if (hasFlag(argv, '--json')) {
413
+ console.log(JSON.stringify({ manifestPath, packet, alreadyConfirmed, receiptPath }, null, 2));
414
+ } else {
415
+ console.log(`\n ✓ review confirmed: ${manifestPath}`);
416
+ if (receiptPath) console.log(` receipt: ${receiptPath}`);
417
+ console.log(` deck: ${packet.url}\n`);
418
+ }
419
+ return 0;
420
+ }
421
+ const specPath = flag(argv, '--spec');
422
+ let spec = null;
423
+ if (specPath) {
424
+ try { spec = readSpec(specPath); }
425
+ catch (e) { console.error(` cannot read spec: ${e.message}`); return 2; }
426
+ }
427
+ return runReviewFlow({
428
+ presentationId: idArg,
429
+ spec,
430
+ specPath,
431
+ json: hasFlag(argv, '--json'),
432
+ auto: hasFlag(argv, '--auto') || hasFlag(argv, '--review-auto'),
433
+ });
434
+ }
435
+
436
+ if (sub === 'video') {
437
+ const url = positionals(argv.slice(1))[0] || flag(argv, '--url');
438
+ if (!url) {
439
+ console.error(' usage: atris deck video <youtube-url> [--title T] [--theme noir] [--style narrative] [--review]');
440
+ return 2;
441
+ }
442
+ const tok = token();
443
+ if (!tok) {
444
+ console.error(' no credentials at ~/.atris/credentials.json — run `atris login` and connect Google Drive.');
445
+ return 1;
446
+ }
447
+ let analysisText;
448
+ try {
449
+ const { processYoutube } = require('./youtube');
450
+ console.error(` fetching analysis for ${url} (this can take a minute)...`);
451
+ const data = await processYoutube({
452
+ youtubeUrl: url,
453
+ query: 'Summarize this video into a slide outline. Use markdown: one H1 title, then ## sections. Within a section use a bullet list for parallel points, a blockquote for a memorable line (with attribution), and short paragraphs for narrative. End with a ## Takeaway.',
454
+ timeoutMs: 300000,
455
+ });
456
+ analysisText = data.video_analysis || data.analysis || data.result || (typeof data === 'string' ? data : '');
457
+ } catch (e) {
458
+ console.error(` atris deck video needs Atris cloud access (run \`atris login\`): ${e.message}`);
459
+ return 1;
460
+ }
461
+ if (!analysisText) { console.error(' no analysis returned for that URL'); return 1; }
462
+ const result = await videoToDeck({
463
+ analysisText,
464
+ title: flag(argv, '--title'),
465
+ theme: flag(argv, '--theme'),
466
+ style: flag(argv, '--style'),
467
+ url,
468
+ api,
469
+ token: tok,
470
+ });
471
+ if (!result.ok) {
472
+ console.error('\n ✗ composed spec failed lint — not building:');
473
+ printLint(result.findings);
474
+ return 1;
475
+ }
476
+ try { recordBuild({ spec: result.spec, presentationId: result.id, url: result.url, mode: 'create' }); } catch { /* ignore */ }
477
+ console.log(`\n ✓ deck ready: ${result.url} (${result.spec.slides.length} slides)\n`);
478
+ if (hasFlag(argv, '--review') || hasFlag(argv, '--review-auto')) {
479
+ return runReviewFlow({ presentationId: result.id, spec: result.spec, specPath: null, json: hasFlag(argv, '--json'), auto: hasFlag(argv, '--review-auto') });
480
+ }
481
+ return 0;
482
+ }
483
+
484
+ if (sub === 'build') {
485
+ const specPaths = positionals(argv.slice(1));
486
+ if (specPaths.length > 1) {
487
+ return runBatch(specPaths, { themeOverride: flag(argv, '--theme') });
488
+ }
489
+ const specPath = specPaths[0];
490
+ if (!specPath) {
491
+ console.error(' usage: atris deck build <spec.json> [<spec2.json> ...] [--title T] [--theme x] [--review]');
492
+ return 2;
493
+ }
494
+ let spec;
495
+ try { spec = readSpec(specPath); }
496
+ catch (e) { console.error(` cannot read spec: ${e.message}`); return 2; }
497
+ const themeOverride = flag(argv, '--theme');
498
+ if (themeOverride) spec.theme = themeOverride;
499
+ if (!THEMES[spec.theme]) {
500
+ console.error(` unknown theme "${spec.theme}". try: ${Object.keys(THEMES).join(', ')}`);
501
+ return 2;
502
+ }
503
+ // Hard schema gate: fail a malformed spec locally with a slide index,
504
+ // before spending a network round trip on a doomed batch-update.
505
+ const schemaFindings = validateSpec(spec);
506
+ if (hasErrors(schemaFindings)) {
507
+ printLint(schemaFindings);
508
+ console.error('\n ✗ spec invalid — fix the errors above before building\n');
509
+ return 2;
510
+ }
152
511
  const title = flag(argv, '--title') || `${(spec.brand && spec.brand.name) || 'Atris'} deck`;
512
+ const reviewAuto = hasFlag(argv, '--review-auto');
513
+ const doReview = hasFlag(argv, '--review') || reviewAuto;
514
+ const updateId = flag(argv, '--update');
515
+ if (doReview) {
516
+ const findings = lintSpec(spec);
517
+ printLint(findings);
518
+ if (findings.some((f) => f.severity === 'error')) {
519
+ console.error('\n ✗ lint failed — fix spec before building\n');
520
+ return 1;
521
+ }
522
+ }
153
523
 
154
524
  const tok = token();
155
- if (!tok) { console.error(' no credentials at ~/.atris/credentials.json — run `atris login` and connect Google Drive.'); return 1; }
525
+ if (!tok) {
526
+ console.error(' no credentials at ~/.atris/credentials.json — run `atris login` and connect Google Drive.');
527
+ return 1;
528
+ }
529
+
530
+ console.log(` ${updateId ? 'updating' : 'building'} ${spec.slides.length} slides (${spec.theme})...`);
531
+ const { id, url, ops } = await publishDeck({ spec, title, updateId, api, token: tok });
532
+ // Ledger the build; never let a history write failure break a build.
533
+ try { recordBuild({ spec, specPath, presentationId: id, url, mode: updateId ? 'update' : 'create' }); } catch { /* ignore */ }
534
+ console.log(`\n ✓ deck ${updateId ? 'updated' : 'ready'}: ${url} (${ops} ops)\n`);
156
535
 
157
- const url = await publishDeck(spec, { title, updateId: flag(argv, '--update'), tok });
158
- console.log(`\n ✓ deck ready: ${url}\n`);
536
+ if (doReview) {
537
+ return runReviewFlow({ presentationId: id, spec, specPath, json: hasFlag(argv, '--json'), auto: reviewAuto });
538
+ }
159
539
  return 0;
160
540
  }
161
541
 
162
542
  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).
543
+ atris deck — premium Google Slides from a plain content spec
544
+
545
+ atris deck sample [--theme paper] > my.json
546
+ atris deck compose --md notes.md --out my.json [--style narrative]
547
+ atris deck compose --url <youtube> --out my.json [--theme ink]
548
+ atris deck video <youtube> [--theme noir] [--review] compose+build in one shot
549
+ atris deck lint my.json
550
+ atris deck build my.json [more.json ...] [--title "Q3 review"] [--update ID] [--review]
551
+ atris deck review <id|url> [--spec my.json]
552
+ atris deck review <id> --confirm "looks good"
553
+ atris deck history [my.json] builds recorded for a spec
554
+ atris deck themes
555
+
556
+ --update ID replaces every slide in an existing deck in place, so the
557
+ presentation URL and id stay stable across rebuilds.
558
+
559
+ Review loop:
560
+ build --review -> downloads slide PNGs to ~/.atris/deck-review/<id>/
561
+ build --review-auto -> also auto-flags blank/failed thumbnails
562
+ agent inspects thumbnails -> fix spec/engine -> rebuild
563
+ review --confirm -> marks deck ready
564
+
565
+ Design system is baked in: distinctive fonts, one accent, real data panels,
566
+ and no AI tells (em dashes sanitized, sentence-case labels, no gradient text).
177
567
  `);
178
568
  return 0;
179
569
  }
180
570
 
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 };
571
+ module.exports = {
572
+ run,
573
+ SAMPLE,
574
+ api,
575
+ token,
576
+ lintSpec,
577
+ validateSpec,
578
+ checkSpec,
579
+ planBatch,
580
+ publishDeck,
581
+ videoToDeck,
582
+ positionals,
583
+ DEFAULT_OUT_ROOT,
584
+ };