atris 3.30.0 → 3.30.2

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 (69) 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 +653 -30
  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 +1072 -89
  33. package/commands/workflow.js +6 -6
  34. package/commands/xp.js +13 -1
  35. package/commands/youtube.js +221 -7
  36. package/decks/README.md +89 -0
  37. package/decks/archetype-catalog.json +180 -0
  38. package/decks/atris-antislop-pitch.json +48 -0
  39. package/decks/atris-archetypes-v2.json +83 -0
  40. package/decks/atris-one-loop-pitch.json +80 -0
  41. package/decks/atris-seed-pitch-v3.json +118 -0
  42. package/decks/atris-seed-pitch-v4-skeleton.json +106 -0
  43. package/decks/atris-seed-pitch-v5.json +109 -0
  44. package/decks/atris-seed-pitch-v6.json +137 -0
  45. package/decks/atris-seed-pitch-v7.json +133 -0
  46. package/decks/atris-single-shot-proof.json +74 -0
  47. package/decks/mark-pincus-narrative.json +102 -0
  48. package/decks/mark-pincus-sourcery.json +94 -0
  49. package/decks/yash-applied-compute-detailed.json +150 -0
  50. package/decks/yash-applied-compute-generalist.json +82 -0
  51. package/decks/yash-applied-compute-narrative.json +54 -0
  52. package/lib/ax-prefs.js +5 -12
  53. package/lib/chat-log-scan.js +377 -0
  54. package/lib/context-gatherer.js +35 -1
  55. package/lib/deck-compose.js +145 -0
  56. package/lib/deck-history.js +64 -0
  57. package/lib/deck-layout.js +169 -0
  58. package/lib/deck-review.js +431 -0
  59. package/lib/deck-schema.js +154 -0
  60. package/lib/file-ops.js +3 -3
  61. package/lib/functional-owner.js +189 -0
  62. package/lib/journal.js +7 -73
  63. package/lib/slides-deck.js +512 -58
  64. package/lib/task-db.js +128 -11
  65. package/package.json +2 -1
  66. package/templates/business-starter/team/START_HERE.md +12 -8
  67. package/utils/auth.js +4 -0
  68. package/utils/config.js +4 -0
  69. package/atris/atrisDev.md +0 -717
@@ -0,0 +1,377 @@
1
+ const fs = require('fs');
2
+ const os = require('os');
3
+ const path = require('path');
4
+
5
+ const SCHEMA = 'atris.chat_scan.v1';
6
+ const PROMPT_LINE = /^(?:max|pro|fast|code-fast)\s*›\s*(.+)$/i;
7
+ const ERROR_LINE = /\b(error|failed|failure|timeout|HTTP \d{3}|ECONNREFUSED|ENOTFOUND|aborted|blocked)\b/i;
8
+ const USER_BAIL = /\b(interrupted|Not completed yet|tool budget ended|max turns forcing final)\b/i;
9
+
10
+ function compact(text, max = 160) {
11
+ const one = String(text || '').replace(/\s+/g, ' ').trim();
12
+ if (!one) return '';
13
+ return one.length <= max ? one : `${one.slice(0, max - 3)}...`;
14
+ }
15
+
16
+ function safeRead(filePath, maxBytes = 2_000_000) {
17
+ try {
18
+ const stat = fs.statSync(filePath);
19
+ if (!stat.isFile() || stat.size > maxBytes) return null;
20
+ return fs.readFileSync(filePath, 'utf8');
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ function listRecentFiles(dir, { pattern, limit = 40, maxAgeMs = 14 * 86400000 } = {}) {
27
+ if (!dir || !fs.existsSync(dir)) return [];
28
+ const cutoff = Date.now() - maxAgeMs;
29
+ const out = [];
30
+ const walk = (current) => {
31
+ let entries = [];
32
+ try {
33
+ entries = fs.readdirSync(current, { withFileTypes: true });
34
+ } catch {
35
+ return;
36
+ }
37
+ for (const entry of entries) {
38
+ const full = path.join(current, entry.name);
39
+ if (entry.isDirectory()) {
40
+ walk(full);
41
+ continue;
42
+ }
43
+ if (pattern && !pattern.test(entry.name)) continue;
44
+ let mtime = 0;
45
+ try {
46
+ mtime = fs.statSync(full).mtimeMs;
47
+ } catch {
48
+ continue;
49
+ }
50
+ if (mtime < cutoff) continue;
51
+ out.push({ path: full, mtimeMs: mtime });
52
+ }
53
+ };
54
+ walk(dir);
55
+ out.sort((a, b) => b.mtimeMs - a.mtimeMs);
56
+ return out.slice(0, limit);
57
+ }
58
+
59
+ function cursorTranscriptRoots(cwd = process.cwd()) {
60
+ const base = path.join(os.homedir(), '.cursor', 'projects');
61
+ if (!fs.existsSync(base)) return [];
62
+ const slug = cwd.replace(/^\/+/, '').split(path.sep).join('-');
63
+ const exact = path.join(base, slug, 'agent-transcripts');
64
+ const roots = [];
65
+ if (fs.existsSync(exact)) roots.push(exact);
66
+ const extra = process.env.ATRIS_CHAT_SCAN_CURSOR_ROOT;
67
+ if (extra && fs.existsSync(extra)) roots.push(extra);
68
+ return roots;
69
+ }
70
+
71
+ function axLogDirs(cwd = process.cwd()) {
72
+ const dirs = [];
73
+ const local = path.join(cwd, 'atris', 'runs');
74
+ const home = path.join(os.homedir(), '.atris', 'runs');
75
+ if (fs.existsSync(local)) dirs.push(local);
76
+ if (fs.existsSync(home) && home !== local) dirs.push(home);
77
+ return dirs;
78
+ }
79
+
80
+ function parseWorkedDuration(label) {
81
+ const text = String(label || '').trim().toLowerCase();
82
+ if (!text) return 0;
83
+ let seconds = 0;
84
+ for (const match of text.matchAll(/(\d+(?:\.\d+)?)\s*(ms|m|s)\b/g)) {
85
+ const value = Number(match[1]);
86
+ if (!Number.isFinite(value)) continue;
87
+ if (match[2] === 'ms') seconds += value / 1000;
88
+ if (match[2] === 's') seconds += value;
89
+ if (match[2] === 'm') seconds += value * 60;
90
+ }
91
+ return seconds;
92
+ }
93
+
94
+ function extractUserQuery(text) {
95
+ const raw = String(text || '');
96
+ const tagged = raw.match(/<user_query>\s*([\s\S]*?)\s*<\/user_query>/i);
97
+ if (tagged) return compact(tagged[1], 240);
98
+ return compact(raw, 240);
99
+ }
100
+
101
+ function parseCursorTranscript(filePath, text) {
102
+ const lines = text.split(/\r?\n/).filter(Boolean);
103
+ const userTurns = [];
104
+ const assistantSummaries = [];
105
+ let toolCalls = 0;
106
+ let errors = 0;
107
+
108
+ for (const line of lines) {
109
+ let row;
110
+ try {
111
+ row = JSON.parse(line);
112
+ } catch {
113
+ continue;
114
+ }
115
+ const role = row.role;
116
+ const parts = row.message?.content || [];
117
+ if (role === 'user') {
118
+ for (const part of parts) {
119
+ if (part.type === 'text' && part.text) {
120
+ const q = extractUserQuery(part.text);
121
+ if (q) userTurns.push(q);
122
+ }
123
+ }
124
+ }
125
+ if (role === 'assistant') {
126
+ let turnTools = 0;
127
+ let turnText = '';
128
+ for (const part of parts) {
129
+ if (part.type === 'tool_use') turnTools += 1;
130
+ if (part.type === 'text' && part.text && !part.text.includes('[REDACTED]')) {
131
+ turnText += part.text;
132
+ }
133
+ }
134
+ toolCalls += turnTools;
135
+ if (turnText.trim()) assistantSummaries.push(compact(turnText, 280));
136
+ if (ERROR_LINE.test(turnText) || USER_BAIL.test(turnText)) errors += 1;
137
+ }
138
+ }
139
+
140
+ const lastUser = userTurns[userTurns.length - 1] || null;
141
+ const lastAssistant = assistantSummaries[assistantSummaries.length - 1] || null;
142
+ return {
143
+ source: 'cursor',
144
+ path: filePath,
145
+ user_turns: userTurns.length,
146
+ assistant_turns: assistantSummaries.length,
147
+ tool_calls: toolCalls,
148
+ last_user: lastUser,
149
+ last_assistant: lastAssistant,
150
+ sample_users: userTurns.slice(-3),
151
+ };
152
+ }
153
+
154
+ function parseAxLog(filePath, text) {
155
+ const lines = text.split(/\r?\n/);
156
+ const userTurns = [];
157
+ const findings = [];
158
+ let mode = null;
159
+ let cwd = null;
160
+ let startedAt = null;
161
+ let finishedAt = null;
162
+ let exitCode = null;
163
+ let lastWorkedSec = null;
164
+ let toolCalls = 0;
165
+ let interrupted = false;
166
+
167
+ for (const line of lines) {
168
+ if (line.startsWith('mode: ')) mode = line.slice(6).trim();
169
+ if (line.startsWith('cwd: ')) cwd = line.slice(5).trim();
170
+ if (line.startsWith('started_at: ')) startedAt = line.slice(12).trim();
171
+ if (line.startsWith('finished_at: ')) finishedAt = line.slice(13).trim();
172
+ if (line.startsWith('exit_code: ')) exitCode = Number(line.slice(11).trim());
173
+ const prompt = line.match(PROMPT_LINE);
174
+ if (prompt) userTurns.push(compact(prompt[1], 240));
175
+ if (/^●\s/.test(line)) toolCalls += 1;
176
+ if (/·\s*Interrupted/i.test(line)) interrupted = true;
177
+ const worked = line.match(/^— Worked for ([^-]+?) —/);
178
+ if (worked) lastWorkedSec = worked[1];
179
+ if (ERROR_LINE.test(line) || /^x\s/.test(line.trim())) {
180
+ findings.push(compact(line, 180));
181
+ }
182
+ if (USER_BAIL.test(line)) findings.push(compact(line, 180));
183
+ }
184
+
185
+ return {
186
+ source: 'ax',
187
+ path: filePath,
188
+ mode,
189
+ cwd,
190
+ started_at: startedAt,
191
+ finished_at: finishedAt,
192
+ exit_code: Number.isFinite(exitCode) ? exitCode : null,
193
+ user_turns: userTurns.length,
194
+ tool_calls: toolCalls,
195
+ interrupted,
196
+ last_worked: lastWorkedSec,
197
+ last_user: userTurns[userTurns.length - 1] || null,
198
+ sample_users: userTurns.slice(-3),
199
+ log_findings: [...new Set(findings)].slice(0, 8),
200
+ };
201
+ }
202
+
203
+ function buildFindings(sessions) {
204
+ const findings = [];
205
+ for (const session of sessions) {
206
+ if (session.interrupted) {
207
+ findings.push({
208
+ severity: 'medium',
209
+ code: 'interrupted_turn',
210
+ title: 'User interrupted an in-flight turn',
211
+ source: session.source,
212
+ path: session.path,
213
+ evidence: session.last_user || session.last_assistant || '',
214
+ });
215
+ }
216
+ if (session.exit_code && session.exit_code !== 0) {
217
+ findings.push({
218
+ severity: 'high',
219
+ code: 'nonzero_exit',
220
+ title: `Chat session exited with code ${session.exit_code}`,
221
+ source: session.source,
222
+ path: session.path,
223
+ evidence: session.last_user || '',
224
+ });
225
+ }
226
+ for (const line of session.log_findings || []) {
227
+ findings.push({
228
+ severity: 'medium',
229
+ code: 'log_error_pattern',
230
+ title: 'Error-like line in chat log',
231
+ source: session.source,
232
+ path: session.path,
233
+ evidence: line,
234
+ });
235
+ }
236
+ if (session.source === 'ax' && session.last_worked) {
237
+ const seconds = parseWorkedDuration(session.last_worked);
238
+ const incomplete = (session.log_findings || []).some((l) => /Not completed yet|tool budget|Blocked:|max turns forcing/i.test(l));
239
+ if (seconds >= 45 && incomplete) {
240
+ findings.push({
241
+ severity: 'high',
242
+ code: 'slow_incomplete_turn',
243
+ title: `Long turn (${session.last_worked}) ended incomplete`,
244
+ source: session.source,
245
+ path: session.path,
246
+ evidence: session.last_user || '',
247
+ });
248
+ }
249
+ }
250
+ if (session.user_turns >= 2 && session.sample_users?.length >= 2) {
251
+ const a = session.sample_users[session.sample_users.length - 2].toLowerCase();
252
+ const b = session.sample_users[session.sample_users.length - 1].toLowerCase();
253
+ if (a && b && (a.includes(b.slice(0, 24)) || b.includes(a.slice(0, 24)))) {
254
+ findings.push({
255
+ severity: 'low',
256
+ code: 'repeat_user_ask',
257
+ title: 'User may have repeated a similar ask',
258
+ source: session.source,
259
+ path: session.path,
260
+ evidence: `${a} -> ${b}`,
261
+ });
262
+ }
263
+ }
264
+ }
265
+ findings.sort((x, y) => {
266
+ const rank = { high: 3, medium: 2, low: 1 };
267
+ return (rank[y.severity] || 0) - (rank[x.severity] || 0);
268
+ });
269
+ return findings.slice(0, 20);
270
+ }
271
+
272
+ function scanChatLogs(options = {}) {
273
+ const cwd = options.cwd || process.cwd();
274
+ const limit = Number(options.limit) || 12;
275
+ const maxAgeHours = Number(options.hours) || 720;
276
+ const maxAgeMs = maxAgeHours * 3600000;
277
+ const sessions = [];
278
+
279
+ for (const dir of axLogDirs(cwd)) {
280
+ for (const file of listRecentFiles(dir, { pattern: /^ax-.*\.log$/, limit, maxAgeMs })) {
281
+ const text = safeRead(file.path);
282
+ if (!text) continue;
283
+ sessions.push(parseAxLog(file.path, text));
284
+ }
285
+ }
286
+
287
+ for (const root of cursorTranscriptRoots(cwd)) {
288
+ for (const file of listRecentFiles(root, { pattern: /\.jsonl$/, limit, maxAgeMs })) {
289
+ const text = safeRead(file.path);
290
+ if (!text) continue;
291
+ const parsed = parseCursorTranscript(file.path, text);
292
+ if (parsed.user_turns > 0 || parsed.assistant_turns > 0) sessions.push(parsed);
293
+ }
294
+ }
295
+
296
+ sessions.sort((a, b) => {
297
+ const ta = fs.statSync(a.path).mtimeMs;
298
+ const tb = fs.statSync(b.path).mtimeMs;
299
+ return tb - ta;
300
+ });
301
+
302
+ const findings = buildFindings(sessions);
303
+ const report = {
304
+ schema: SCHEMA,
305
+ scanned_at: new Date().toISOString(),
306
+ cwd,
307
+ window_hours: maxAgeHours,
308
+ sources: {
309
+ ax_dirs: axLogDirs(cwd),
310
+ cursor_roots: cursorTranscriptRoots(cwd),
311
+ },
312
+ summary: {
313
+ sessions: sessions.length,
314
+ ax_sessions: sessions.filter((s) => s.source === 'ax').length,
315
+ cursor_sessions: sessions.filter((s) => s.source === 'cursor').length,
316
+ findings: findings.length,
317
+ high_findings: findings.filter((f) => f.severity === 'high').length,
318
+ },
319
+ sessions: sessions.slice(0, limit).map((s) => ({
320
+ ...s,
321
+ path: s.path,
322
+ rel_path: s.path.startsWith(cwd) ? path.relative(cwd, s.path) : s.path,
323
+ })),
324
+ findings,
325
+ next_command: findings.length
326
+ ? 'atris member wake auto-improver --execute --confirm-autonomy-policy'
327
+ : 'atris chat scan --hours 168',
328
+ };
329
+ return report;
330
+ }
331
+
332
+ function writeLatestScan(root, report) {
333
+ const stateDir = path.join(root, '.atris', 'state');
334
+ fs.mkdirSync(stateDir, { recursive: true });
335
+ const latestPath = path.join(stateDir, 'chat_scan.latest.json');
336
+ fs.writeFileSync(latestPath, `${JSON.stringify(report, null, 2)}\n`);
337
+ return latestPath;
338
+ }
339
+
340
+ function readLatestScan(root) {
341
+ const latestPath = path.join(root, '.atris', 'state', 'chat_scan.latest.json');
342
+ try {
343
+ if (!fs.existsSync(latestPath)) return null;
344
+ return JSON.parse(fs.readFileSync(latestPath, 'utf8'));
345
+ } catch {
346
+ return null;
347
+ }
348
+ }
349
+
350
+ function ensureChatScan(root, options = {}) {
351
+ const maxAgeMs = Number(options.maxAgeMs ?? 3600000);
352
+ const existing = readLatestScan(root);
353
+ if (existing && existing.scanned_at && !options.force) {
354
+ const age = Date.now() - new Date(existing.scanned_at).getTime();
355
+ if (Number.isFinite(age) && age >= 0 && age <= maxAgeMs) {
356
+ return existing;
357
+ }
358
+ }
359
+ const report = scanChatLogs({ cwd: root, hours: options.hours, limit: options.limit });
360
+ if (options.write !== false) writeLatestScan(root, report);
361
+ return report;
362
+ }
363
+
364
+ function topChatFindings(report, limit = 3) {
365
+ return (report && Array.isArray(report.findings) ? report.findings : []).slice(0, limit);
366
+ }
367
+
368
+ module.exports = {
369
+ SCHEMA,
370
+ scanChatLogs,
371
+ writeLatestScan,
372
+ readLatestScan,
373
+ ensureChatScan,
374
+ topChatFindings,
375
+ cursorTranscriptRoots,
376
+ axLogDirs,
377
+ };
@@ -117,11 +117,14 @@ function shouldGatherContext({
117
117
  wipCount = 0,
118
118
  backlogCount = 0,
119
119
  inboxCount = 0,
120
+ completedTasksCount = 0,
120
121
  } = {}) {
121
122
  if (hasContextProfile(root)) return false;
122
123
  if (String(userInput || '').trim()) return true;
123
124
  if (mapStatus !== 'ready') return true;
124
- if (liveMissionsCount > 0 || wipCount > 0 || backlogCount > 0 || inboxCount > 0) return false;
125
+ // Any signal that the workspace has been used (pending work OR completed
126
+ // history) means it is not a fresh project to onboard.
127
+ if (liveMissionsCount > 0 || wipCount > 0 || backlogCount > 0 || inboxCount > 0 || completedTasksCount > 0) return false;
125
128
  return true;
126
129
  }
127
130
 
@@ -137,6 +140,35 @@ function renderPrompt({ projectName = 'this workspace' } = {}) {
137
140
  ].join('\n');
138
141
  }
139
142
 
143
+ function normalizeQuestionText(value) {
144
+ return String(value || '')
145
+ .toLowerCase()
146
+ .replace(/[^\w\s']/g, ' ')
147
+ .replace(/\s+/g, ' ')
148
+ .trim();
149
+ }
150
+
151
+ // True when the input is a question ABOUT Atris itself ("what is atris?",
152
+ // "what can you do?") rather than a task request. bin/atris.js uses this to
153
+ // show the product overview instead of routing the words into plan/do.
154
+ // A clear task verb always wins, so "build a website" is never a meta-question.
155
+ function isAtrisMetaQuestion(value) {
156
+ const text = normalizeQuestionText(value);
157
+ if (!text) return false;
158
+
159
+ const taskVerb = /\b(add|audit|build|change|create|debug|deploy|edit|fix|implement|make|patch|refactor|remove|review|run|ship|test|update|write)\b/;
160
+ if (taskVerb.test(text)) return false;
161
+
162
+ return [
163
+ /^(what'?s|what is|what are|who is|who are)\s+(atris|you|this)\b/,
164
+ /^what\s+atris\s+is\b/,
165
+ /^(what|how)\s+(does|do|can)\s+(atris|you|this)\b/,
166
+ /^(explain|describe|define)\s+(atris|this)\b/,
167
+ /^tell me\s+(about|what)\s+(atris|this)\b/,
168
+ /^why\s+atris\b/,
169
+ ].some((pattern) => pattern.test(text));
170
+ }
171
+
140
172
  module.exports = {
141
173
  PROFILE_REL_PATH,
142
174
  profilePath,
@@ -149,4 +181,6 @@ module.exports = {
149
181
  renderPrompt,
150
182
  starterTaskTitle,
151
183
  inferDomain,
184
+ normalizeQuestionText,
185
+ isAtrisMetaQuestion,
152
186
  };
@@ -0,0 +1,145 @@
1
+ // Compose a deck spec from a prose/markdown analysis (e.g. a transcript summary
2
+ // the cloud returns for a YouTube video). Pure and deterministic: text in,
3
+ // lint-clean spec out. The network fetch lives in the CLI; this just turns
4
+ // structured text into layout SECTIONS and runs them through the layout planner
5
+ // so the result picks narrative archetypes and stays under template fatigue.
6
+
7
+ const { planLayout } = require('./deck-layout');
8
+
9
+ function stripNoise(md) {
10
+ return String(md == null ? '' : md)
11
+ .replace(/```[\s\S]*?```/g, '') // fenced code
12
+ .replace(/`([^`]+)`/g, '$1') // inline code
13
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, '') // images
14
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') // links -> text
15
+ .replace(/\r\n?/g, '\n');
16
+ }
17
+
18
+ // Tokenize markdown-ish text into headings, bullet lists, quotes, paragraphs.
19
+ function parseBlocks(md) {
20
+ const lines = stripNoise(md).split('\n');
21
+ const blocks = [];
22
+ let para = [];
23
+ let bullets = [];
24
+ const flushPara = () => { if (para.length) { blocks.push({ kind: 'p', text: para.join(' ').trim() }); para = []; } };
25
+ const flushBullets = () => { if (bullets.length) { blocks.push({ kind: 'ul', items: bullets.slice() }); bullets = []; } };
26
+ for (const raw of lines) {
27
+ const line = raw.replace(/\s+$/, '');
28
+ const h = line.match(/^(#{1,4})\s+(.+?)\s*#*$/);
29
+ const bul = line.match(/^\s*[-*•]\s+(.+)/) || line.match(/^\s*\d+[.)]\s+(.+)/);
30
+ const quote = line.match(/^\s*>\s*(.+)/);
31
+ if (h) { flushPara(); flushBullets(); blocks.push({ kind: 'h', level: h[1].length, text: h[2].trim() }); }
32
+ else if (bul) { flushPara(); bullets.push(bul[1].trim()); }
33
+ else if (quote) { flushPara(); flushBullets(); blocks.push({ kind: 'quote', text: quote[1].trim() }); }
34
+ else if (line.trim() === '') { flushPara(); flushBullets(); }
35
+ else { flushBullets(); para.push(line.trim()); }
36
+ }
37
+ flushPara();
38
+ flushBullets();
39
+ return blocks;
40
+ }
41
+
42
+ // "Term: detail" or bold "**Term** detail" -> { text, sub }; else a plain string.
43
+ function toItem(line) {
44
+ const bold = line.match(/^\*\*(.+?)\*\*[:\s,-]+(.+)/);
45
+ if (bold && bold[1].length <= 48) return { text: bold[1].trim(), sub: bold[2].trim() };
46
+ const colon = line.match(/^([^:]{2,48}):\s+(.+)/);
47
+ if (colon) return { text: colon[1].trim(), sub: colon[2].trim() };
48
+ return line.replace(/\*\*/g, '');
49
+ }
50
+
51
+ // A quote may carry an attribution: `"..." - Name` or `... (Name)`.
52
+ function splitQuote(text) {
53
+ const dash = text.match(/^["“]?(.+?)["”]?\s+[-–—]\s+(.+)$/);
54
+ if (dash) return { text: dash[1].trim(), author: dash[2].trim() };
55
+ const paren = text.match(/^["“]?(.+?)["”]?\s+\(([^)]+)\)\s*$/);
56
+ if (paren) return { text: paren[1].trim(), author: paren[2].trim() };
57
+ return { text: text.replace(/^["“]|["”]$/g, '').trim() };
58
+ }
59
+
60
+ function classify(heading, content) {
61
+ const ul = content.find((b) => b.kind === 'ul');
62
+ const quote = content.find((b) => b.kind === 'quote');
63
+ const paras = content.filter((b) => b.kind === 'p').map((b) => b.text);
64
+
65
+ if (ul) {
66
+ const lead = paras[0];
67
+ return {
68
+ kind: 'points',
69
+ heading: heading || undefined,
70
+ items: ul.items.map(toItem),
71
+ ...(lead && lead.length <= 120 ? { sub: lead } : {}),
72
+ };
73
+ }
74
+ if (quote) {
75
+ const q = splitQuote(quote.text);
76
+ return { kind: 'quote', quote: q };
77
+ }
78
+ const body = paras.join(' ').trim();
79
+ if (!body) return { kind: 'statement', text: heading || 'Untitled' };
80
+ if (body.length > 180 || paras.length > 1) {
81
+ return { kind: 'prose', heading: heading || undefined, paragraphs: paras.slice(0, 3) };
82
+ }
83
+ // short single paragraph: the heading is the claim, the paragraph the support
84
+ if (heading) return { kind: 'statement', text: heading, sub: body };
85
+ return { kind: 'statement', text: body };
86
+ }
87
+
88
+ // Group tokenized blocks into sections (one per heading; leading content forms
89
+ // an untitled section), then classify each into a layout section.
90
+ function blocksToSections(blocks) {
91
+ const groups = [];
92
+ let current = null;
93
+ for (const b of blocks) {
94
+ if (b.kind === 'h') { if (current) groups.push(current); current = { heading: b.text, content: [] }; }
95
+ else { if (!current) current = { heading: null, content: [] }; current.content.push(b); }
96
+ }
97
+ if (current) groups.push(current);
98
+ return groups.map((g) => ({ raw: g, section: classify(g.heading, g.content) }));
99
+ }
100
+
101
+ function composeSpec(text, opts = {}) {
102
+ const blocks = parseBlocks(text);
103
+ const grouped = blocksToSections(blocks);
104
+ let sections = grouped.map((g) => g.section);
105
+
106
+ // Cover: build a cover slide. Fold the first section into it ONLY when that
107
+ // section is a thin title/lead (a short statement or a cover) so we never
108
+ // destroy a deck that opens with bullets, a quote, or multi-paragraph prose.
109
+ const h1 = blocks.find((b) => b.kind === 'h' && b.level === 1);
110
+ const firstPara = blocks.find((b) => b.kind === 'p');
111
+ const first = sections[0];
112
+ const docTitle = opts.title || (h1 && h1.text) || (first && (first.heading || first.text)) || 'Untitled';
113
+ const coverSub = opts.subtitle
114
+ || (first && first.sub)
115
+ || (firstPara && firstPara.text !== docTitle ? firstPara.text : undefined);
116
+ const cover = { kind: 'cover', headline: docTitle, sub: coverSub };
117
+ const fold = first && (first.kind === 'statement' || first.kind === 'cover');
118
+ if (!sections.length) sections = [cover];
119
+ else if (fold) sections = [cover, ...sections.slice(1)];
120
+ else sections = [cover, ...sections];
121
+
122
+ // Close: synthesize a closing slide so every deck lands a takeaway.
123
+ sections.push({
124
+ kind: 'close',
125
+ text: opts.tagline || docTitle,
126
+ ...(opts.url || opts.footer ? { footer: [opts.footer, opts.url].filter(Boolean).join(' · ') } : {}),
127
+ });
128
+
129
+ const style = opts.style || 'narrative';
130
+ const slides = planLayout(sections, { style });
131
+ return {
132
+ theme: opts.theme || 'ink',
133
+ brand: opts.brand || { name: opts.brandName || docTitle, accent: '.' },
134
+ slides,
135
+ };
136
+ }
137
+
138
+ module.exports = {
139
+ parseBlocks,
140
+ blocksToSections,
141
+ classify,
142
+ toItem,
143
+ splitQuote,
144
+ composeSpec,
145
+ };
@@ -0,0 +1,64 @@
1
+ // Deck build history — every build appends one line to a JSONL ledger so an
2
+ // operator can answer "which presentation did this spec become, and when?".
3
+ // Rebuilds reuse the same presentation id under --update; this ledger is how
4
+ // you track iterations of a spec over time.
5
+
6
+ const fs = require('fs');
7
+ const os = require('os');
8
+ const path = require('path');
9
+ const crypto = require('crypto');
10
+
11
+ const DEFAULT_HISTORY_PATH = path.join(os.homedir(), '.atris', 'deck-history.jsonl');
12
+
13
+ // Short, stable content hash of a spec — same spec -> same hash, so you can
14
+ // tell a no-op rebuild from a real change.
15
+ function specHash(spec) {
16
+ return crypto.createHash('sha256').update(JSON.stringify(spec || {})).digest('hex').slice(0, 12);
17
+ }
18
+
19
+ function recordBuild({ spec, specPath = null, presentationId, url = null, mode = 'create', at = null }, historyPath = DEFAULT_HISTORY_PATH) {
20
+ const entry = {
21
+ at: at || new Date().toISOString(),
22
+ presentationId: presentationId || null,
23
+ url: url || (presentationId ? `https://docs.google.com/presentation/d/${presentationId}/edit` : null),
24
+ specPath: specPath || null,
25
+ specHash: specHash(spec),
26
+ theme: (spec && spec.theme) || null,
27
+ slideCount: (spec && spec.slides ? spec.slides.length : 0),
28
+ mode,
29
+ };
30
+ fs.mkdirSync(path.dirname(historyPath), { recursive: true });
31
+ fs.appendFileSync(historyPath, `${JSON.stringify(entry)}\n`);
32
+ return entry;
33
+ }
34
+
35
+ function readHistory(historyPath = DEFAULT_HISTORY_PATH) {
36
+ if (!fs.existsSync(historyPath)) return [];
37
+ return fs
38
+ .readFileSync(historyPath, 'utf8')
39
+ .split('\n')
40
+ .filter(Boolean)
41
+ .map((line) => { try { return JSON.parse(line); } catch { return null; } })
42
+ .filter(Boolean);
43
+ }
44
+
45
+ // Filter the ledger by a spec path (exact or basename suffix), a 12-char hash,
46
+ // or a presentation id. No filter returns the whole ledger.
47
+ function historyFor(query, historyPath = DEFAULT_HISTORY_PATH) {
48
+ const all = readHistory(historyPath);
49
+ if (!query) return all;
50
+ return all.filter((e) => (
51
+ e.specPath === query
52
+ || (e.specPath && e.specPath.endsWith(query))
53
+ || e.specHash === query
54
+ || e.presentationId === query
55
+ ));
56
+ }
57
+
58
+ module.exports = {
59
+ DEFAULT_HISTORY_PATH,
60
+ specHash,
61
+ recordBuild,
62
+ readHistory,
63
+ historyFor,
64
+ };