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.
- package/AGENTS.md +6 -0
- package/atris/AGENTS.md +11 -0
- package/atris/CLAUDE.md +5 -0
- package/atris/atris.md +19 -4
- package/atris/policies/atris-design.md +71 -0
- package/atris/policies/design-seed.md +187 -0
- package/atris/skills/atris/SKILL.md +26 -3
- package/atris/skills/design/SKILL.md +3 -2
- package/atris/skills/loop/SKILL.md +5 -3
- package/atris/team/_template/MEMBER.md +19 -0
- package/atris.md +63 -23
- package/ax +1434 -1698
- package/bin/atris.js +5 -1
- package/commands/agent-spawn.js +2 -2
- package/commands/brain.js +92 -7
- package/commands/brainstorm.js +62 -22
- package/commands/business-sync.js +92 -8
- package/commands/business.js +13 -7
- package/commands/chat-scan.js +102 -0
- package/commands/computer.js +758 -15
- package/commands/deck.js +505 -105
- package/commands/init.js +6 -1
- package/commands/launchpad.js +638 -0
- package/commands/log-sync.js +44 -13
- package/commands/loop-front.js +290 -0
- package/commands/member.js +124 -3
- package/commands/mission.js +653 -30
- package/commands/pull.js +37 -28
- package/commands/pulse.js +11 -8
- package/commands/run.js +79 -39
- package/commands/sync.js +36 -17
- package/commands/task.js +1072 -89
- package/commands/workflow.js +6 -6
- package/commands/xp.js +13 -1
- package/commands/youtube.js +221 -7
- package/decks/README.md +89 -0
- package/decks/archetype-catalog.json +180 -0
- package/decks/atris-antislop-pitch.json +48 -0
- package/decks/atris-archetypes-v2.json +83 -0
- package/decks/atris-one-loop-pitch.json +80 -0
- package/decks/atris-seed-pitch-v3.json +118 -0
- package/decks/atris-seed-pitch-v4-skeleton.json +106 -0
- package/decks/atris-seed-pitch-v5.json +109 -0
- package/decks/atris-seed-pitch-v6.json +137 -0
- package/decks/atris-seed-pitch-v7.json +133 -0
- package/decks/atris-single-shot-proof.json +74 -0
- package/decks/mark-pincus-narrative.json +102 -0
- package/decks/mark-pincus-sourcery.json +94 -0
- package/decks/yash-applied-compute-detailed.json +150 -0
- package/decks/yash-applied-compute-generalist.json +82 -0
- package/decks/yash-applied-compute-narrative.json +54 -0
- package/lib/ax-prefs.js +5 -12
- package/lib/chat-log-scan.js +377 -0
- package/lib/context-gatherer.js +35 -1
- package/lib/deck-compose.js +145 -0
- package/lib/deck-history.js +64 -0
- package/lib/deck-layout.js +169 -0
- package/lib/deck-review.js +431 -0
- package/lib/deck-schema.js +154 -0
- package/lib/file-ops.js +3 -3
- package/lib/functional-owner.js +189 -0
- package/lib/journal.js +7 -73
- package/lib/slides-deck.js +512 -58
- package/lib/task-db.js +128 -11
- package/package.json +2 -1
- package/templates/business-starter/team/START_HERE.md +12 -8
- package/utils/auth.js +4 -0
- package/utils/config.js +4 -0
- 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
|
+
};
|
package/lib/context-gatherer.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
};
|