atris 3.30.8 → 3.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/AGENTS.md +16 -3
  2. package/FOR_AGENTS.md +81 -0
  3. package/README.md +4 -2
  4. package/atris/atris.md +51 -19
  5. package/atris/skills/README.md +1 -0
  6. package/atris/skills/blocks/SKILL.md +134 -0
  7. package/atris/skills/clawhub/philosophy-of-work/SKILL.md +56 -0
  8. package/atris/skills/youtube/SKILL.md +31 -11
  9. package/atris.md +7 -0
  10. package/ax +367 -93
  11. package/bin/atris.js +317 -155
  12. package/commands/autoland.js +319 -0
  13. package/commands/autopilot.js +94 -4
  14. package/commands/business.js +1 -1
  15. package/commands/clean.js +72 -9
  16. package/commands/codex-goal.js +72 -22
  17. package/commands/computer.js +48 -3
  18. package/commands/gm.js +1 -1
  19. package/commands/harvest.js +179 -0
  20. package/commands/init.js +1 -1
  21. package/commands/land.js +442 -0
  22. package/commands/loop-front.js +122 -4
  23. package/commands/member.js +519 -19
  24. package/commands/mission.js +3659 -282
  25. package/commands/play.js +1 -1
  26. package/commands/pulse.js +65 -7
  27. package/commands/run.js +3 -3
  28. package/commands/strings.js +301 -0
  29. package/commands/task.js +575 -102
  30. package/commands/truth.js +170 -0
  31. package/commands/xp.js +32 -8
  32. package/commands/youtube.js +72 -5
  33. package/decks/README.md +6 -12
  34. package/lib/auto-accept-certified.js +10 -0
  35. package/lib/autoland.js +283 -0
  36. package/lib/context-gatherer.js +0 -8
  37. package/lib/mission-artifact.js +504 -0
  38. package/lib/mission-room.js +846 -0
  39. package/lib/mission-runtime-loop.js +320 -0
  40. package/lib/next-moves.js +212 -6
  41. package/lib/pulse.js +74 -1
  42. package/lib/runner-command.js +20 -8
  43. package/lib/runs-prune.js +242 -0
  44. package/lib/task-proof.js +1 -1
  45. package/package.json +3 -3
  46. package/decks/atris-seed-pitch-v3.json +0 -118
  47. package/decks/atris-seed-pitch-v4-skeleton.json +0 -106
  48. package/decks/atris-seed-pitch-v5.json +0 -109
  49. package/decks/atris-seed-pitch-v6.json +0 -137
  50. package/decks/atris-seed-pitch-v7.json +0 -133
  51. package/decks/mark-pincus-narrative.json +0 -102
  52. package/decks/mark-pincus-sourcery.json +0 -94
  53. package/decks/yash-applied-compute-detailed.json +0 -150
  54. package/decks/yash-applied-compute-generalist.json +0 -82
  55. package/decks/yash-applied-compute-narrative.json +0 -54
  56. package/lib/ax-chat-input.js +0 -164
  57. package/lib/ax-goal.js +0 -307
  58. package/lib/ax-prefs.js +0 -63
  59. package/lib/ax-shimmer.js +0 -63
@@ -0,0 +1,170 @@
1
+ // atris truth — one table of what is actually proven, blocked, or stale.
2
+ // Rolls up sources that already exist; writes nothing. SQL/state is truth, this is the render.
3
+ // 1. .atris/state/missions.jsonl — mission states (dedupe by id, latest wins)
4
+ // 2. ~/.atris/tasks.db — task counts by status
5
+ // 3. atris/features/*/ — validate.md frontmatter + newest proof/ receipt age
6
+ // 4. ~/.atris/heartbeat/{registry,state}.json — declared loops vs last_run / fails
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const os = require('os');
11
+
12
+ const STALE_DAYS = 7;
13
+
14
+ function readJson(file) {
15
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
16
+ }
17
+
18
+ function daysAgo(ms) {
19
+ if (!ms) return null;
20
+ return (Date.now() - ms) / 86400000;
21
+ }
22
+
23
+ function fmtAge(days) {
24
+ if (days == null) return 'never';
25
+ if (days < 1) return `${Math.round(days * 24)}h`;
26
+ return `${Math.round(days)}d`;
27
+ }
28
+
29
+ function loadMissions(cwd) {
30
+ const file = path.join(cwd, '.atris', 'state', 'missions.jsonl');
31
+ if (!fs.existsSync(file)) return [];
32
+ const seen = new Map();
33
+ for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
34
+ if (!line.trim()) continue;
35
+ try {
36
+ const rec = JSON.parse(line);
37
+ const id = rec.id || rec.mission_id;
38
+ if (id) seen.set(id, rec);
39
+ } catch { /* skip bad lines */ }
40
+ }
41
+ return [...seen.values()].filter((m) => m.status && m.status !== 'complete' && m.status !== 'archived');
42
+ }
43
+
44
+ function loadTaskCounts() {
45
+ try {
46
+ const { DatabaseSync } = require('node:sqlite');
47
+ const db = new DatabaseSync(path.join(os.homedir(), '.atris', 'tasks.db'), { readOnly: true });
48
+ const rows = db.prepare('SELECT status, COUNT(*) n FROM tasks GROUP BY status').all();
49
+ db.close();
50
+ const counts = {};
51
+ for (const r of rows) counts[r.status] = r.n;
52
+ return counts;
53
+ } catch { return null; }
54
+ }
55
+
56
+ function loadFeatures(cwd) {
57
+ const dir = path.join(cwd, 'atris', 'features');
58
+ if (!fs.existsSync(dir)) return [];
59
+ const out = [];
60
+ for (const name of fs.readdirSync(dir)) {
61
+ if (name.startsWith('_')) continue;
62
+ const fdir = path.join(dir, name);
63
+ const validate = path.join(fdir, 'validate.md');
64
+ if (!fs.existsSync(validate)) continue;
65
+ let status = null;
66
+ const head = fs.readFileSync(validate, 'utf8').slice(0, 500);
67
+ const m = head.match(/^status:\s*(.+)$/m);
68
+ if (m) status = m[1].trim();
69
+ // newest receipt in proof/ (if any)
70
+ let proofAgeDays = null;
71
+ const proofDir = path.join(fdir, 'proof');
72
+ if (fs.existsSync(proofDir)) {
73
+ let newest = 0;
74
+ for (const p of fs.readdirSync(proofDir)) {
75
+ try { newest = Math.max(newest, fs.statSync(path.join(proofDir, p)).mtimeMs); } catch { /* skip */ }
76
+ }
77
+ if (newest) proofAgeDays = daysAgo(newest);
78
+ }
79
+ let verdict;
80
+ if (/blocked/i.test(status || '')) verdict = 'blocked';
81
+ else if (proofAgeDays != null && proofAgeDays <= STALE_DAYS) verdict = 'proven';
82
+ else if (proofAgeDays != null) verdict = 'stale';
83
+ else verdict = 'unproven';
84
+ out.push({ lane: name, status: status || '-', verdict, proofAgeDays });
85
+ }
86
+ return out;
87
+ }
88
+
89
+ function loadHeartbeats() {
90
+ const hbDir = path.join(os.homedir(), '.atris', 'heartbeat');
91
+ const registry = readJson(path.join(hbDir, 'registry.json'));
92
+ const state = readJson(path.join(hbDir, 'state.json')) || {};
93
+ if (!registry || !Array.isArray(registry.jobs)) return [];
94
+ return registry.jobs.filter((j) => !j.disabled).map((j) => {
95
+ const s = state[j.id] || {};
96
+ const lastRun = s.last_run ? Date.parse(s.last_run) : null;
97
+ const ageDays = daysAgo(lastRun);
98
+ const expectedDays = ((j.cadence_minutes || 60) / 1440) * 3; // 3 missed cadences = stale
99
+ let verdict;
100
+ if (s.disabled) verdict = 'disabled';
101
+ else if ((s.consecutive_fails || 0) > 0) verdict = 'blocked';
102
+ else if (lastRun == null) verdict = 'never-ran';
103
+ else if (ageDays > Math.max(expectedDays, 0.05)) verdict = 'stale';
104
+ else verdict = 'proven';
105
+ return { id: j.id, verdict, lastRunAgeDays: ageDays, fails: s.consecutive_fails || 0 };
106
+ });
107
+ }
108
+
109
+ function truthCommand(args = []) {
110
+ const cwd = process.cwd();
111
+ const json = args.includes('--json');
112
+ const summary = args.includes('--summary');
113
+
114
+ const missions = loadMissions(cwd);
115
+ const tasks = loadTaskCounts();
116
+ const features = loadFeatures(cwd);
117
+ const heartbeats = loadHeartbeats();
118
+
119
+ const featureTally = {};
120
+ for (const f of features) featureTally[f.verdict] = (featureTally[f.verdict] || 0) + 1;
121
+ const loopTally = {};
122
+ for (const h of heartbeats) loopTally[h.verdict] = (loopTally[h.verdict] || 0) + 1;
123
+
124
+ const result = {
125
+ ok: true,
126
+ generated_at: new Date().toISOString(),
127
+ missions_active: missions.map((m) => ({ id: m.id || m.mission_id, status: m.status, owner: m.owner })),
128
+ tasks,
129
+ features: featureTally,
130
+ loops: loopTally,
131
+ feature_rows: features,
132
+ loop_rows: heartbeats,
133
+ };
134
+
135
+ if (json) {
136
+ console.log(JSON.stringify(result, null, 2));
137
+ return 0;
138
+ }
139
+
140
+ const line = (s) => console.log(s);
141
+ if (summary) {
142
+ line(`truth: features ${featureTally.proven || 0} proven / ${featureTally.stale || 0} stale / ${featureTally.blocked || 0} blocked / ${featureTally.unproven || 0} unproven · loops ${loopTally.proven || 0} live / ${(loopTally.stale || 0) + (loopTally['never-ran'] || 0)} stale / ${loopTally.blocked || 0} failing · missions ${missions.length} active`);
143
+ return 0;
144
+ }
145
+
146
+ line('ATRIS TRUTH — live state, not belief\n');
147
+
148
+ line(`Missions active: ${missions.length}`);
149
+ for (const m of missions) line(` ${m.status.padEnd(8)} ${m.owner || '?'} ${m.id || m.mission_id}`);
150
+
151
+ if (tasks) {
152
+ line(`\nTasks: ${Object.entries(tasks).map(([k, v]) => `${v} ${k}`).join(', ')}`);
153
+ }
154
+
155
+ line(`\nFeature lanes (${features.length}):`);
156
+ const order = { blocked: 0, stale: 1, unproven: 2, proven: 3 };
157
+ for (const f of features.sort((a, b) => (order[a.verdict] ?? 9) - (order[b.verdict] ?? 9))) {
158
+ line(` ${f.verdict.padEnd(9)} ${fmtAge(f.proofAgeDays).padStart(6)} ${f.lane}`);
159
+ }
160
+
161
+ line(`\nLoops (${heartbeats.length}):`);
162
+ for (const h of heartbeats) {
163
+ line(` ${h.verdict.padEnd(9)} ${fmtAge(h.lastRunAgeDays).padStart(6)} ${h.id}${h.fails ? ` fails=${h.fails}` : ''}`);
164
+ }
165
+
166
+ line(`\nVerdict key: proven = receipt within ${STALE_DAYS}d · stale = receipt older · unproven = no receipt ever · blocked = failing now`);
167
+ return 0;
168
+ }
169
+
170
+ module.exports = { truthCommand };
package/commands/xp.js CHANGED
@@ -18,6 +18,8 @@ const CAREER_XP_CURSOR_FILE = path.join('.atris', 'state', 'career_xp.cursor.jso
18
18
  const CAREER_XP_SESSIONS_DIR = path.join('.atris', 'state', 'career_xp_sessions');
19
19
  const TASK_PROJECTION_FILE = path.join('.atris', 'state', 'tasks.projection.json');
20
20
  const CODEX_STATE_FILE = path.join(os.homedir(), '.codex', 'state_5.sqlite');
21
+ // Codex moved native goals to goals_1.sqlite; thread metadata (cwd/title) stayed in state_5.sqlite.
22
+ const CODEX_GOALS_FILE = path.join(os.homedir(), '.codex', 'goals_1.sqlite');
21
23
  const AGENT_XP_LABEL = 'AgentXP';
22
24
  const AGENTXP_LEADERBOARD_URL = 'https://api.atris.ai/api/agentxp/leaderboard';
23
25
  const LEVEL_XP = 1000;
@@ -1549,20 +1551,42 @@ function workspacePathCandidates(workspace) {
1549
1551
  return [...new Set(candidates.filter(Boolean))];
1550
1552
  }
1551
1553
 
1554
+ function codexHasThreadsTable(dbPath) {
1555
+ if (!dbPath || !fs.existsSync(dbPath)) return false;
1556
+ return runSqliteJsonOptional(dbPath, `SELECT name FROM sqlite_master WHERE type='table' AND name='threads' LIMIT 1;`).length > 0;
1557
+ }
1558
+
1552
1559
  function readCodexGoalsForSession(args, workspace, sinceMs, untilMs) {
1553
- const dbPath = path.resolve(expandHome(
1554
- readFlag(args, '--codex-state', readFlag(args, '--state', process.env.CODEX_STATE_DB || CODEX_STATE_FILE))
1555
- ));
1560
+ const explicitDb = readFlag(args, '--codex-state', readFlag(args, '--state', process.env.CODEX_STATE_DB || ''));
1561
+ const dbPath = explicitDb
1562
+ ? path.resolve(expandHome(explicitDb))
1563
+ : path.resolve(fs.existsSync(CODEX_GOALS_FILE) ? CODEX_GOALS_FILE : CODEX_STATE_FILE);
1564
+
1565
+ // Goals live in goals_1.sqlite (no `threads` table); cwd/title metadata lives in state_5.sqlite.
1566
+ // Legacy single-file layouts (and test fixtures) keep both in one DB.
1567
+ let threadsTable = codexHasThreadsTable(dbPath) ? 'threads' : null;
1568
+ let attachPrefix = '';
1569
+ if (!threadsTable) {
1570
+ const threadsDb = CODEX_STATE_FILE !== dbPath ? CODEX_STATE_FILE : '';
1571
+ if (codexHasThreadsTable(threadsDb)) {
1572
+ threadsTable = 'tdb.threads';
1573
+ attachPrefix = `ATTACH ${sqlString(threadsDb)} AS tdb;\n`;
1574
+ }
1575
+ }
1576
+
1556
1577
  const threadId = readFlag(args, '--thread', process.env.CODEX_THREAD_ID || '');
1557
1578
  const clauses = [];
1558
1579
  if (threadId) clauses.push(`tg.thread_id = ${sqlString(threadId)}`);
1559
1580
  const workspaceCandidates = workspacePathCandidates(workspace);
1560
- if (workspaceCandidates.length) {
1581
+ if (workspaceCandidates.length && threadsTable) {
1561
1582
  clauses.push(`(t.cwd IN (${workspaceCandidates.map(sqlString).join(', ')}) AND tg.created_at_ms <= ${Number(untilMs)} AND (tg.updated_at_ms >= ${Number(sinceMs)} OR tg.status = 'active'))`);
1562
1583
  }
1563
1584
  if (!clauses.length) return [];
1564
1585
 
1565
- const rows = runSqliteJsonOptional(dbPath, `
1586
+ const join = threadsTable ? `LEFT JOIN ${threadsTable} t ON t.id = tg.thread_id` : '';
1587
+ const cwdCol = threadsTable ? 't.cwd' : 'NULL';
1588
+ const titleCol = threadsTable ? 't.title' : 'NULL';
1589
+ const rows = runSqliteJsonOptional(dbPath, `${attachPrefix}
1566
1590
  SELECT
1567
1591
  'codex' AS provider,
1568
1592
  tg.thread_id,
@@ -1574,10 +1598,10 @@ SELECT
1574
1598
  tg.time_used_seconds,
1575
1599
  tg.created_at_ms,
1576
1600
  tg.updated_at_ms,
1577
- t.cwd AS thread_cwd,
1578
- t.title AS thread_title
1601
+ ${cwdCol} AS thread_cwd,
1602
+ ${titleCol} AS thread_title
1579
1603
  FROM thread_goals tg
1580
- LEFT JOIN threads t ON t.id = tg.thread_id
1604
+ ${join}
1581
1605
  WHERE ${clauses.join(' OR ')}
1582
1606
  ORDER BY tg.updated_at_ms DESC
1583
1607
  LIMIT 25
@@ -3,7 +3,11 @@ const { ensureValidCredentials } = require('../utils/auth');
3
3
  const { spawnSync } = require('child_process');
4
4
  const https = require('https');
5
5
 
6
- const DEFAULT_QUERY = 'Extract main topics, key insights, and actionable takeaways.';
6
+ const DEFAULT_QUERY = [
7
+ 'Create a timestamped YouTube brief for Atris.',
8
+ 'Include: metadata, timestamped outline, core claims with confidence, memorable examples, actionable takeaways, Atris/product implications, and next actions.',
9
+ 'Use transcript timestamps whenever possible.',
10
+ ].join(' ');
7
11
  const DEFAULT_TIMEOUT_MS = 300000;
8
12
  const LOCAL_TRANSCRIPT_MAX_BYTES = 5 * 1024 * 1024;
9
13
  const LOCAL_TRANSCRIPT_MAX_CHARS = 250000;
@@ -18,7 +22,8 @@ function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
18
22
  output(`Usage: ${commandName} process <youtube-url> [options]`);
19
23
  output(` ${commandName} <youtube-url> [options]`);
20
24
  output('');
21
- output('Process a YouTube video through Atris using transcript-first analysis.');
25
+ output('Process a YouTube video through Atris using timestamped transcript-first analysis.');
26
+ output('Falls back to cloud video processing when local captions are unavailable.');
22
27
  output('');
23
28
  output('Options:');
24
29
  output(' --query, -q <text> Focus question for the analysis');
@@ -28,6 +33,9 @@ function showYoutubeHelp(output = console.log, commandName = 'atris youtube') {
28
33
  output(' --json Print the raw JSON response');
29
34
  output(' -h, --help This help');
30
35
  output('');
36
+ output('Default output contract:');
37
+ output(' metadata -> timestamped outline -> claims -> examples -> takeaways -> Atris implications -> next actions');
38
+ output('');
31
39
  output('Examples:');
32
40
  output(` ${commandName} https://www.youtube.com/watch?v=VIDEO_ID`);
33
41
  output(` ${commandName} process https://youtu.be/VIDEO_ID --query "Key takeaways"`);
@@ -186,6 +194,34 @@ function chooseCaptionTrack(info = {}) {
186
194
  return chooseFrom(info.subtitles) || chooseFrom(info.automatic_captions);
187
195
  }
188
196
 
197
+ function formatTimestampFromMs(ms) {
198
+ const totalSeconds = Math.max(0, Math.floor(Number(ms || 0) / 1000));
199
+ const hours = Math.floor(totalSeconds / 3600);
200
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
201
+ const seconds = totalSeconds % 60;
202
+ const two = (value) => String(value).padStart(2, '0');
203
+ return hours > 0
204
+ ? `${two(hours)}:${two(minutes)}:${two(seconds)}`
205
+ : `${two(minutes)}:${two(seconds)}`;
206
+ }
207
+
208
+ function timestampedCaptionLine(text, startMs) {
209
+ const clean = String(text || '').replace(/\s+/g, ' ').trim();
210
+ if (!clean) return '';
211
+ if (!Number.isFinite(Number(startMs))) return clean;
212
+ return `[${formatTimestampFromMs(startMs)}] ${clean}`;
213
+ }
214
+
215
+ function parseVttTimestampMs(value) {
216
+ const match = String(value || '').match(/(?:(\d{2}):)?(\d{2}):(\d{2})\.(\d{3})/);
217
+ if (!match) return null;
218
+ const hours = Number(match[1] || 0);
219
+ const minutes = Number(match[2] || 0);
220
+ const seconds = Number(match[3] || 0);
221
+ const millis = Number(match[4] || 0);
222
+ return ((hours * 3600) + (minutes * 60) + seconds) * 1000 + millis;
223
+ }
224
+
189
225
  function fetchCaptionText(urlString, redirects = 0) {
190
226
  if (!captionHostAllowed(urlString)) {
191
227
  return Promise.resolve(null);
@@ -246,15 +282,37 @@ function parseCaptionText(raw) {
246
282
  .replace(/\s+/g, ' ')
247
283
  .trim();
248
284
  if (!text) continue;
249
- if (segments[segments.length - 1] === text) continue;
250
- segments.push(text);
285
+ const line = timestampedCaptionLine(text, Number(event.tStartMs));
286
+ if (segments[segments.length - 1] === line) continue;
287
+ segments.push(line);
251
288
  }
252
- return segments.join(' ');
289
+ return segments.join('\n');
253
290
  } catch {
254
291
  return '';
255
292
  }
256
293
  }
257
294
 
295
+ if (/^WEBVTT/i.test(trimmed) || trimmed.includes('-->')) {
296
+ const segments = [];
297
+ for (const block of String(raw).split(/\r?\n\r?\n+/)) {
298
+ const lines = block.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
299
+ const timeLine = lines.find((line) => line.includes('-->'));
300
+ if (!timeLine) continue;
301
+ const startMs = parseVttTimestampMs(timeLine.split('-->')[0]);
302
+ const text = lines.slice(lines.indexOf(timeLine) + 1)
303
+ .filter((line) => !/^(NOTE|STYLE|REGION|Kind:|Language:)/.test(line))
304
+ .map((line) => line.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim())
305
+ .filter(Boolean)
306
+ .filter((line, index, all) => index === 0 || line !== all[index - 1])
307
+ .join(' ')
308
+ .trim();
309
+ const captionLine = timestampedCaptionLine(text, startMs);
310
+ if (!captionLine || segments[segments.length - 1] === captionLine) continue;
311
+ segments.push(captionLine);
312
+ }
313
+ if (segments.length) return segments.join('\n');
314
+ }
315
+
258
316
  const segments = [];
259
317
  for (const line of String(raw).split(/\r?\n/)) {
260
318
  const stripped = line.trim();
@@ -358,6 +416,14 @@ function formatYoutubeResult(data) {
358
416
  lines.push(data?.message || 'YouTube video processed successfully');
359
417
  if (metadata.title) lines.push(`Title: ${metadata.title}`);
360
418
  if (metadata.channel) lines.push(`Channel: ${metadata.channel}`);
419
+ if (metadata.duration_seconds) lines.push(`Duration: ${formatTimestampFromMs(Number(metadata.duration_seconds) * 1000)}`);
420
+ if (metadata.processing_method || metadata.transcript_source) {
421
+ const method = metadata.processing_method || metadata.transcript_source;
422
+ const source = metadata.transcript_source && metadata.transcript_source !== method
423
+ ? ` via ${metadata.transcript_source}`
424
+ : '';
425
+ lines.push(`Processing: ${method}${source}`);
426
+ }
361
427
  if (data?.credits_used !== undefined || data?.credits_remaining !== undefined) {
362
428
  const used = data.credits_used !== undefined ? data.credits_used : '?';
363
429
  const remaining = data.credits_remaining !== undefined ? data.credits_remaining : '?';
@@ -393,5 +459,6 @@ module.exports = {
393
459
  processYoutube,
394
460
  shouldRetryWithLocalTranscript,
395
461
  formatYoutubeResult,
462
+ formatTimestampFromMs,
396
463
  youtubeCommand,
397
464
  };
package/decks/README.md CHANGED
@@ -23,28 +23,28 @@ atris deck compose --md notes.md --out decks/my.json --style narrative
23
23
  atris deck compose --url https://youtu.be/VIDEO --out decks/my.json --theme ink
24
24
 
25
25
  # Pre-build lint (schema shape + taste/clip)
26
- atris deck lint decks/atris-seed-pitch-v3.json
26
+ atris deck lint decks/atris-one-loop-pitch.json
27
27
 
28
28
  # Build + open visual review packet (--review-auto also flags blank thumbnails)
29
- atris deck build decks/atris-seed-pitch-v3.json --title "Atris Seed Pitch v3" --review
29
+ atris deck build decks/atris-one-loop-pitch.json --title "Atris One Loop" --review
30
30
 
31
31
  # Rebuild an existing deck in place (URL/id stay stable)
32
- atris deck build decks/atris-seed-pitch-v3.json --update 139zTe9cPOGttzYbW04adRU26VJz0SqWRwaZmkoVibP8
32
+ atris deck build decks/atris-one-loop-pitch.json --update 139zTe9cPOGttzYbW04adRU26VJz0SqWRwaZmkoVibP8
33
33
 
34
34
  # Batch-build several specs
35
- atris deck build decks/atris-seed-pitch-v3.json decks/mark-pincus-narrative.json
35
+ atris deck build decks/atris-one-loop-pitch.json decks/atris-antislop-pitch.json
36
36
 
37
37
  # One command: video -> reviewed deck
38
38
  atris deck video https://youtu.be/VIDEO --theme noir --review
39
39
 
40
40
  # Re-fetch thumbnails for an existing deck
41
- atris deck review 139zTe9cPOGttzYbW04adRU26VJz0SqWRwaZmkoVibP8 --spec decks/atris-seed-pitch-v3.json
41
+ atris deck review 139zTe9cPOGttzYbW04adRU26VJz0SqWRwaZmkoVibP8 --spec decks/atris-one-loop-pitch.json
42
42
 
43
43
  # After thumbnails pass review (emits a receipt for the action queue)
44
44
  atris deck review 139zTe9cPOGttzYbW04adRU26VJz0SqWRwaZmkoVibP8 --confirm "slides 5+8 spacing fixed"
45
45
 
46
46
  # Builds recorded for a spec
47
- atris deck history decks/atris-seed-pitch-v3.json
47
+ atris deck history decks/atris-one-loop-pitch.json
48
48
  ```
49
49
 
50
50
  ## Themes
@@ -76,13 +76,7 @@ List all: `atris deck themes`
76
76
 
77
77
  | File | Purpose |
78
78
  |------|---------|
79
- | `atris-seed-pitch-v3.json` | Main investor pitch (10 slides, paper theme) |
80
79
  | `atris-one-loop-pitch.json` | Outcome wedge deck (7 slides, terminal theme) |
81
- | `yash-applied-compute-generalist.json` | Yash Patil / Own or Be Owned (ink, boxed template) |
82
- | `yash-applied-compute-narrative.json` | Same story, typography-first (ink, no widgets) |
83
- | `yash-applied-compute-detailed.json` | Full episode notes, 18 slides, bullets + cases |
84
- | `mark-pincus-sourcery.json` | Mark Pincus / Life at the Speed of Play (noir, boxed) |
85
- | `mark-pincus-narrative.json` | Same story, box-free narrative (12 slides, noir) |
86
80
  | `atris-single-shot-proof.json` | Review loop proof deck (6 slides, single-pass quality bar) |
87
81
  | `atris-archetypes-v2.json` | New archetypes showcase (8 slides, paper theme) |
88
82
  | `atris-antislop-pitch.json` | 3-slide antislop product pitch |
@@ -92,6 +92,15 @@ function isAllowedGitDiffCheck(argv) {
92
92
  return false;
93
93
  }
94
94
 
95
+ function isAllowedAtrisCleanDryRun(argv) {
96
+ return argv.length === 5
97
+ && argv[0] === 'node'
98
+ && argv[1] === 'bin/atris.js'
99
+ && argv[2] === 'clean'
100
+ && argv[3] === '--dry-run'
101
+ && argv[4] === '--json';
102
+ }
103
+
95
104
  function isInsidePath(candidate, root) {
96
105
  const relative = path.relative(root, candidate);
97
106
  return relative === '' || (relative && !relative.startsWith('..') && !path.isAbsolute(relative));
@@ -209,6 +218,7 @@ function parseVerifyCommand(verify) {
209
218
  || (/^scripts\/[a-zA-Z0-9_./-]+$/.test(first || '') && safeNodePathArgs(argv.slice(1)))
210
219
  ))
211
220
  || (bin === 'tsc' && argv.length === 1)
221
+ || isAllowedAtrisCleanDryRun(argv)
212
222
  || isAllowedGitDiffCheck(argv);
213
223
  if (!allowed) return { ok: false, reason: 'verify_command_not_allowed' };
214
224
  return { ok: true, argv };