atris 3.58.5 → 3.58.7

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 (42) hide show
  1. package/README.md +8 -0
  2. package/atris/policies/engineering-principles.md +129 -0
  3. package/atris/policies/genesis.md +112 -0
  4. package/atris/policies/product-design-principles.md +100 -0
  5. package/atris/skills/design/SKILL.md +3 -1
  6. package/atris/skills/engines/SKILL.md +3 -3
  7. package/atris/skills/x-search/SKILL.md +2 -2
  8. package/atris/skills/youtube/SKILL.md +44 -28
  9. package/bin/atris.js +56 -3
  10. package/commands/auth.js +58 -24
  11. package/commands/brain.js +1 -0
  12. package/commands/design.js +362 -0
  13. package/commands/doc-health.js +329 -0
  14. package/commands/drive.js +32 -0
  15. package/commands/improve.js +67 -1
  16. package/commands/land.js +144 -4
  17. package/commands/learn.js +211 -40
  18. package/commands/member.js +65 -11
  19. package/commands/mission.js +37 -7
  20. package/commands/pulse.js +38 -0
  21. package/commands/rsi.js +156 -0
  22. package/commands/task.js +41 -1
  23. package/commands/workflow.js +15 -14
  24. package/commands/x-search.js +9 -10
  25. package/commands/youtube.js +518 -107
  26. package/lib/apply-gate.js +22 -4
  27. package/lib/daily-log.js +88 -0
  28. package/lib/design-api.js +130 -0
  29. package/lib/engine-ask.js +1 -1
  30. package/lib/first-minute.js +1 -6
  31. package/lib/known-commands.js +3 -3
  32. package/lib/member-context.js +42 -0
  33. package/lib/rsi-record.js +335 -0
  34. package/lib/state-detection.js +8 -8
  35. package/lib/task-db.js +71 -51
  36. package/lib/task-list-keeper.js +192 -0
  37. package/lib/todo-fallback.js +9 -3
  38. package/lib/todo.js +22 -10
  39. package/mcp/atris-mcp/index.mjs +174 -0
  40. package/package.json +8 -3
  41. package/scripts/det/ytnotes +122 -10
  42. package/utils/auth.js +109 -13
@@ -0,0 +1,192 @@
1
+ 'use strict';
2
+
3
+ // Puts finished and untouched tasks off the visible list. History stays
4
+ // archived. A current list costs one indexed lookup and no writes.
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const taskDb = require('./task-db');
9
+
10
+ const DAY_MS = 24 * 60 * 60 * 1000;
11
+ const OPEN_STATUSES = new Set(['open', 'claimed', 'review']);
12
+
13
+ // Failed rows already leave the today screen after 7 days. They stay
14
+ // findable until 30 days, then they leave the list too.
15
+ const IDLE_LIMITS_MS = {
16
+ claimed: 3 * DAY_MS,
17
+ open: 14 * DAY_MS,
18
+ review: 14 * DAY_MS,
19
+ failed: 30 * DAY_MS,
20
+ };
21
+
22
+ const REASONS = {
23
+ done: 'finished work left the list',
24
+ claimed: 'claimed work sat untouched for 3 days',
25
+ open: 'waiting work sat untouched for 14 days',
26
+ review: 'review sat untouched for 14 days',
27
+ failed: 'failed work sat untouched for 30 days',
28
+ };
29
+
30
+ const CANDIDATE_SQL = `
31
+ SELECT id, title, status, tag, updated_at, created_at, done_at, metadata
32
+ FROM tasks
33
+ WHERE workspace_root = ? AND status = 'done'
34
+ UNION ALL
35
+ SELECT id, title, status, tag, updated_at, created_at, done_at, metadata
36
+ FROM tasks
37
+ WHERE workspace_root = ? AND status = 'claimed' AND updated_at < ?
38
+ UNION ALL
39
+ SELECT id, title, status, tag, updated_at, created_at, done_at, metadata
40
+ FROM tasks
41
+ WHERE workspace_root = ? AND status = 'open' AND updated_at < ?
42
+ UNION ALL
43
+ SELECT id, title, status, tag, updated_at, created_at, done_at, metadata
44
+ FROM tasks
45
+ WHERE workspace_root = ? AND status = 'review' AND updated_at < ?
46
+ UNION ALL
47
+ SELECT id, title, status, tag, updated_at, created_at, done_at, metadata
48
+ FROM tasks
49
+ WHERE workspace_root = ? AND status = 'failed' AND updated_at < ?
50
+ UNION ALL
51
+ SELECT id, title, status, tag, updated_at, created_at, done_at, metadata
52
+ FROM tasks
53
+ WHERE workspace_root = ? AND tag = 'mission-blocker' AND status IN ('open', 'claimed', 'review')
54
+ `;
55
+
56
+ function actorName(actor) {
57
+ return actor || process.env.ATRIS_AGENT_ID || process.env.USER || 'task-list';
58
+ }
59
+
60
+ function decisionFor(row, now) {
61
+ if (!row || row.status === 'archived') return null;
62
+ if (row.status === 'done') {
63
+ return { reason: REASONS.done, fromDone: true, fromFailed: false };
64
+ }
65
+ if (!Object.prototype.hasOwnProperty.call(IDLE_LIMITS_MS, row.status)) return null;
66
+ const at = Number(row.updated_at || row.created_at || 0);
67
+ if (!((now - at) > IDLE_LIMITS_MS[row.status])) return null;
68
+ return { reason: REASONS[row.status], fromDone: false, fromFailed: row.status === 'failed' };
69
+ }
70
+
71
+ function selectCandidates(db, workspaceRoot, now) {
72
+ return db.prepare(CANDIDATE_SQL).all(
73
+ workspaceRoot,
74
+ workspaceRoot, now - IDLE_LIMITS_MS.claimed,
75
+ workspaceRoot, now - IDLE_LIMITS_MS.open,
76
+ workspaceRoot, now - IDLE_LIMITS_MS.review,
77
+ workspaceRoot, now - IDLE_LIMITS_MS.failed,
78
+ workspaceRoot,
79
+ );
80
+ }
81
+
82
+ function blockerFrom(row) {
83
+ if (!row || row.tag !== 'mission-blocker' || !OPEN_STATUSES.has(row.status)) return null;
84
+ let metadata = row.metadata;
85
+ if (typeof metadata === 'string') {
86
+ try { metadata = JSON.parse(metadata); } catch { metadata = null; }
87
+ }
88
+ if (!metadata || !metadata.mission_id || !metadata.mission_blocker_class) return null;
89
+ return { ...row, metadata };
90
+ }
91
+
92
+ function loadMissions(workspaceRoot) {
93
+ try {
94
+ return require('../commands/mission').listMissions(workspaceRoot) || [];
95
+ } catch {
96
+ return [];
97
+ }
98
+ }
99
+
100
+ function noteKeep(workspaceRoot, actor, count) {
101
+ if (!count || !workspaceRoot || !fs.existsSync(path.join(workspaceRoot, 'atris'))) return;
102
+ try {
103
+ const now = new Date();
104
+ const year = String(now.getFullYear());
105
+ const dir = path.join(workspaceRoot, 'atris', 'logs', year);
106
+ fs.mkdirSync(dir, { recursive: true });
107
+ const name = `${year}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}.md`;
108
+ const stamp = now.toTimeString().slice(0, 5);
109
+ fs.appendFileSync(path.join(dir, name), `## ${stamp} · Task list kept\n- count: ${count}\n- actor: ${actor}\n\n`);
110
+ } catch {
111
+ // The rows are already off the list. A missed note should not put them back.
112
+ }
113
+ }
114
+
115
+ function keepTaskList(db, { workspaceRoot, actor, now = Date.now(), missions } = {}) {
116
+ const who = actorName(actor);
117
+ const seen = new Set();
118
+ const toArchive = [];
119
+ const blockers = [];
120
+ for (const row of selectCandidates(db, workspaceRoot, now)) {
121
+ if (!row || seen.has(row.id)) continue;
122
+ seen.add(row.id);
123
+ const decision = decisionFor(row, now);
124
+ if (decision) {
125
+ toArchive.push({ id: row.id, previous_status: row.status, ...decision });
126
+ continue;
127
+ }
128
+ const blocker = blockerFrom(row);
129
+ if (blocker) blockers.push(blocker);
130
+ }
131
+
132
+ const missionList = Array.isArray(missions)
133
+ ? missions
134
+ : (blockers.length ? loadMissions(workspaceRoot) : []);
135
+ if (!toArchive.length && !(blockers.length && missionList.length)) {
136
+ return { put_away: [], reaped: [] };
137
+ }
138
+
139
+ const putAway = [];
140
+ let reaped = [];
141
+ db.exec('BEGIN IMMEDIATE');
142
+ try {
143
+ for (const item of toArchive) {
144
+ const result = taskDb.archiveTask(db, {
145
+ id: item.id,
146
+ actor: who,
147
+ reason: item.reason,
148
+ fromDone: item.fromDone,
149
+ fromFailed: item.fromFailed,
150
+ skipLogs: true,
151
+ });
152
+ if (result.archived) {
153
+ putAway.push({ id: item.id, previous_status: item.previous_status, reason: item.reason });
154
+ }
155
+ }
156
+ if (blockers.length && missionList.length) {
157
+ reaped = taskDb.reapMissionBlockerTasks(db, {
158
+ workspaceRoot,
159
+ missions: missionList,
160
+ actor: who,
161
+ rows: blockers,
162
+ skipLogs: true,
163
+ }).closed || [];
164
+ }
165
+ db.exec('COMMIT');
166
+ } catch (error) {
167
+ try { db.exec('ROLLBACK'); } catch { /* the original error is the one to surface */ }
168
+ throw error;
169
+ }
170
+ noteKeep(workspaceRoot, who, putAway.length + reaped.length);
171
+ return { put_away: putAway, reaped };
172
+ }
173
+
174
+ function keepWorkspaceTaskList(cwd = process.cwd(), { actor } = {}) {
175
+ const dbPath = taskDb.getDbPath();
176
+ if (!dbPath || !fs.existsSync(dbPath)) {
177
+ return { put_away: [], reaped: [], skipped: 'no_db' };
178
+ }
179
+ const db = taskDb.open();
180
+ return keepTaskList(db, {
181
+ workspaceRoot: taskDb.workspaceRoot(cwd),
182
+ actor,
183
+ now: Date.now(),
184
+ });
185
+ }
186
+
187
+ module.exports = {
188
+ DAY_MS,
189
+ IDLE_LIMITS_MS,
190
+ keepTaskList,
191
+ keepWorkspaceTaskList,
192
+ };
@@ -80,15 +80,21 @@ function parseSection(content, sectionName) {
80
80
  const bracketTaskMatch = line.match(/^- \*\*\[([^\]\n]+)\]\*\*\s+(.+)$/);
81
81
  if (bracketTaskMatch) {
82
82
  if (current) tasks.push(current);
83
- const { allTags, tag } = tagsFromText(bracketTaskMatch[2]);
83
+ // Compact rows carry display-only verification previews. Never execute
84
+ // a clipped command; the full command lives in task show or the DB.
85
+ const titleOnly = content.includes('<!-- ATRIS_TODO_COMPACT:2 -->') && /^(Backlog|Completed)$/i.test(sectionName);
86
+ const compact = !titleOnly && bracketTaskMatch[2].match(/^(.*?) · ([^·]+?)(?: · verify: (.*))?$/);
87
+ const title = compact ? compact[1] : bracketTaskMatch[2];
88
+ const { allTags, tag } = tagsFromText(title);
84
89
  current = {
85
90
  id: bracketTaskMatch[1],
86
- title: cleanTaskTitle(bracketTaskMatch[2]),
91
+ title: cleanTaskTitle(title),
87
92
  tag,
88
93
  tags: allTags,
89
- claimed: null,
94
+ claimed: compact && compact[2] !== 'unassigned' ? compact[2].trim() : null,
90
95
  stage: null,
91
96
  verify: null,
97
+ ...(compact || titleOnly ? { verify_preview: compact ? compact[3] || null : null } : {}),
92
98
  };
93
99
  continue;
94
100
  }
package/lib/todo.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // SHIM. Same export surface as the legacy markdown parser, but reads from the
2
2
  // SQLite task store first when ATRIS_TASK_DB=1 is set, falling back to the
3
- // pure markdown parser at lib/todo-fallback.js.
3
+ // pure markdown parser at lib/todo-fallback.js. Compact generated boards also
4
+ // consult an existing store so verification previews never replace commands.
4
5
  //
5
6
  // All 3 callers (commands/autopilot.js, commands/status.js, commands/run.js)
6
7
  // inherit the strangler without changing their import path.
@@ -22,12 +23,14 @@ function dbToShimRow(row) {
22
23
  ? metadata.verify.trim()
23
24
  : null;
24
25
  const claimed = row.claimed_by || metadata.claimed || null;
26
+ const tags = [...new Set([row.tag, ...(Array.isArray(metadata.todo_tags) ? metadata.todo_tags : []),
27
+ ...(Array.isArray(metadata.tags) ? metadata.tags : [])].filter(Boolean))];
25
28
  // Map DB row → the shape the existing consumers expect from parseTodo().
26
29
  return {
27
30
  id: row.id,
28
31
  title: row.title,
29
- tag: row.tag || null,
30
- tags: row.tag ? [row.tag] : [],
32
+ tag: tags.includes('endgame') ? 'endgame' : (row.tag || tags[0] || null),
33
+ tags,
31
34
  claimed,
32
35
  stage: row.status === 'claimed' ? 'in_progress' : (metadata.stage || null),
33
36
  verify,
@@ -38,25 +41,29 @@ function dbToShimRow(row) {
38
41
  function dbBuckets(workspaceRoot) {
39
42
  const taskDb = require('./task-db');
40
43
  const db = taskDb.open();
41
- const rows = taskDb.listTasks(db, { workspaceRoot, limit: 500 });
44
+ const rows = taskDb.withTaskDisplayRefs(taskDb.listTasks(db, { workspaceRoot, limit: 500 }));
42
45
  // Need raw source_key for merge dedup, plus the shim shape for callers.
43
46
  const backlog = [];
44
47
  const inProgress = [];
45
48
  const review = [];
46
49
  const completed = [];
47
50
  const sourceKeys = new Set();
51
+ const sourceRefs = new Set();
48
52
  // Query directly so the shim can dedup against markdown by the strong key
49
53
  // even if future list filters hide rows.
50
54
  const stmt = db.prepare('SELECT id, source_key FROM tasks WHERE workspace_root = ? AND source_key IS NOT NULL');
51
55
  for (const r of stmt.all(workspaceRoot)) sourceKeys.add(r.source_key);
52
56
  for (const r of rows) {
57
+ for (const ref of [r.id, r.display_id, r.legacy_ref, r.metadata?.todo_id]) {
58
+ if (ref) sourceRefs.add(String(ref).toLowerCase());
59
+ }
53
60
  const shaped = dbToShimRow(r);
54
61
  if (r.status === 'open') backlog.push(shaped);
55
62
  else if (r.status === 'claimed') inProgress.push(shaped);
56
63
  else if (r.status === 'review') review.push(shaped);
57
64
  else if (r.status === 'done' || r.status === 'failed') completed.push(shaped);
58
65
  }
59
- return { backlog, inProgress, review, completed, sourceKeys };
66
+ return { backlog, inProgress, review, completed, sourceKeys, sourceRefs };
60
67
  }
61
68
 
62
69
  function mergeBuckets(dbBuck, mdBuck, todoPath) {
@@ -71,6 +78,7 @@ function mergeBuckets(dbBuck, mdBuck, todoPath) {
71
78
  for (const k of ['backlog', 'inProgress', 'review', 'completed']) {
72
79
  for (const r of dbBuck[k]) { out[k].push(r); seenTitles.add(norm(r.title)); }
73
80
  for (const r of mdBuck[k]) {
81
+ if (r.id && dbBuck.sourceRefs?.has(String(r.id).toLowerCase())) continue;
74
82
  const sk = todoPath ? taskDb.sourceKey(todoPath, r.title) : null;
75
83
  if (sk && dbBuck.sourceKeys && dbBuck.sourceKeys.has(sk)) continue;
76
84
  if (seenTitles.has(norm(r.title))) continue;
@@ -81,14 +89,19 @@ function mergeBuckets(dbBuck, mdBuck, todoPath) {
81
89
  }
82
90
 
83
91
  function parseTodo(todoPath) {
84
- // Legacy pure-markdown path.
85
- if (!TASK_DB_ENABLED) return fallback.parseTodoFile(todoPath);
92
+ const mdBuck = fallback.parseTodoFile(todoPath);
93
+ const compact = Object.values(mdBuck).some(rows => rows.some(row => Object.hasOwn(row, 'verify_preview')));
94
+ // Compact generated boards omit full commands. Resolve them from existing
95
+ // durable state even without the legacy opt-in, without minting a database.
96
+ if (!TASK_DB_ENABLED && !compact) return mdBuck;
86
97
 
87
98
  // DB-first merged view. Workspace scope = directory containing todoPath
88
99
  // (or its parent), so the DB stays per-repo.
89
100
  const path = require('path');
90
101
  const fs = require('fs');
91
- const taskDb = require('./task-db');
102
+ let taskDb;
103
+ try { taskDb = require('./task-db'); } catch { return mdBuck; }
104
+ if (!TASK_DB_ENABLED && !fs.existsSync(taskDb.getDbPath())) return mdBuck;
92
105
  let workspaceRoot;
93
106
  try {
94
107
  const todoAbs = path.resolve(todoPath);
@@ -106,9 +119,8 @@ function parseTodo(todoPath) {
106
119
  } catch (e) {
107
120
  // If sqlite blew up (missing in node, perms), don't break the legacy path.
108
121
  if (process.env.ATRIS_DEBUG) console.error('[todo shim] db read failed:', e.message);
109
- return fallback.parseTodoFile(todoPath);
122
+ return mdBuck;
110
123
  }
111
- const mdBuck = fallback.parseTodoFile(todoPath);
112
124
  return mergeBuckets(dbBuck, mdBuck, todoPath);
113
125
  }
114
126
 
@@ -0,0 +1,174 @@
1
+ #!/usr/bin/env node
2
+ // atris mcp, a stdio Model Context Protocol server exposing the design API.
3
+ // Tools: design_extract, design_check, design_search.
4
+ // Auth resolves the same way as the CLI: ATRIS_API_KEY, then
5
+ // the logged-in atris token, then ~/.atris/design-api-key.
6
+
7
+ import { realpathSync } from 'node:fs';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
10
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
11
+ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
12
+ import designApi from '../../lib/design-api.js';
13
+
14
+ const { resolveDesignKey, designRequest, pollDesignJob, billingOf } = designApi;
15
+
16
+ export const TOOLS = [
17
+ {
18
+ name: 'design_extract',
19
+ description: 'extract a site\'s design system (colors, typography, layout, voice) as json. costs 10 credits, 2 on a cache hit.',
20
+ inputSchema: {
21
+ type: 'object',
22
+ properties: {
23
+ url: { type: 'string', description: 'site url to extract, e.g. https://stripe.com' },
24
+ },
25
+ required: ['url'],
26
+ },
27
+ },
28
+ {
29
+ name: 'design_check',
30
+ description: 'score how closely a page follows a reference brand. costs 20 credits.',
31
+ inputSchema: {
32
+ type: 'object',
33
+ properties: {
34
+ source_url: { type: 'string', description: 'page being checked' },
35
+ reference_url: { type: 'string', description: 'brand url to check against' },
36
+ },
37
+ required: ['source_url', 'reference_url'],
38
+ },
39
+ },
40
+ {
41
+ name: 'design_search',
42
+ description: 'search brands already extracted by atris design. costs 1 credit.',
43
+ inputSchema: {
44
+ type: 'object',
45
+ properties: {
46
+ query: { type: 'string', description: 'plain words, e.g. "dark developer tools brand"' },
47
+ limit: { type: 'number', description: 'max results' },
48
+ },
49
+ required: ['query'],
50
+ },
51
+ },
52
+ ];
53
+
54
+ function terminal(job) {
55
+ const s = String((job && job.status) || '').toLowerCase();
56
+ return s === 'completed' || s === 'failed' || s === 'error' || s === 'succeeded';
57
+ }
58
+
59
+ function withBilling(data) {
60
+ const bill = billingOf(data);
61
+ return {
62
+ ...(data && typeof data === 'object' ? data : { result: data }),
63
+ credits_charged: bill.credits,
64
+ balance_remaining_usd: bill.balanceUsd,
65
+ };
66
+ }
67
+
68
+ function ok(data) {
69
+ return { content: [{ type: 'text', text: JSON.stringify(withBilling(data), null, 2) }] };
70
+ }
71
+
72
+ function fail(message) {
73
+ return { content: [{ type: 'text', text: message }], isError: true };
74
+ }
75
+
76
+ async function callApi(pathname, options, key) {
77
+ const res = await designRequest(pathname, { ...options, key });
78
+ if (!res.ok) throw new Error(`http ${res.status}: ${res.error || 'request failed'}`);
79
+ if (!res.data || typeof res.data !== 'object') {
80
+ throw new Error('the design api returned an empty response');
81
+ }
82
+ return res.data;
83
+ }
84
+
85
+ async function runJob(first, pollPath, key) {
86
+ if (!first || typeof first !== 'object') {
87
+ throw new Error('the design api returned an empty response');
88
+ }
89
+ let job = first;
90
+ if (!terminal(job) && !job.id) {
91
+ throw new Error('the design api returned no job id');
92
+ }
93
+ if (!terminal(job) && job.id) {
94
+ let failures = 0;
95
+ const polled = await pollDesignJob(async () => {
96
+ try {
97
+ const out = await callApi(pollPath, {}, key);
98
+ failures = 0;
99
+ return out;
100
+ } catch (error) {
101
+ failures += 1;
102
+ if (failures >= 3) return { status: 'failed', id: job.id, error: (error && error.message) || String(error) };
103
+ return { status: 'polling' };
104
+ }
105
+ });
106
+ if (polled.timedOut) {
107
+ throw new Error(`still running after 3 minutes. job id: ${job.id}, poll: ${pollPath}`);
108
+ }
109
+ job = polled.job || job;
110
+ }
111
+ if (job.status !== 'completed') {
112
+ throw new Error(`job ${job.id || 'unknown'} did not finish: ${job.error || job.status || 'unknown'} (poll: ${pollPath})`);
113
+ }
114
+ return job;
115
+ }
116
+
117
+ export async function handleTool(name, args = {}, key) {
118
+ if (name === 'design_extract') {
119
+ const url = String(args.url || '').trim();
120
+ if (!url) return fail('design_extract needs a url');
121
+ const first = await callApi('/design/extractions', { method: 'POST', body: { url } }, key);
122
+ return ok(await runJob(first, `/design/extractions/${first.id}`, key));
123
+ }
124
+ if (name === 'design_check') {
125
+ const source = String(args.source_url || '').trim();
126
+ const reference = String(args.reference_url || '').trim();
127
+ if (!source || !reference) return fail('design_check needs source_url and reference_url');
128
+ const first = await callApi('/design/adherence', {
129
+ method: 'POST',
130
+ body: { source_url: source, reference_url: reference },
131
+ }, key);
132
+ return ok(await runJob(first, `/design/adherence/${first.id}`, key));
133
+ }
134
+ if (name === 'design_search') {
135
+ const query = String(args.query || '').trim();
136
+ if (!query) return fail('design_search needs a query');
137
+ const body = { query };
138
+ if (Number.isInteger(args.limit) && args.limit > 0) body.limit = args.limit;
139
+ return ok(await callApi('/design/search', { method: 'POST', body }, key));
140
+ }
141
+ return fail(`unknown tool: ${name}`);
142
+ }
143
+
144
+ export function createServer() {
145
+ const server = new Server(
146
+ { name: 'atris', version: '1.0.0' },
147
+ { capabilities: { tools: {} } },
148
+ );
149
+
150
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
151
+
152
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
153
+ const key = resolveDesignKey();
154
+ if (!key) {
155
+ return fail('no api key found. set ATRIS_API_KEY or run: atris login (or save a key in ~/.atris/design-api-key)');
156
+ }
157
+ try {
158
+ return await handleTool(request.params.name, request.params.arguments || {}, key);
159
+ } catch (error) {
160
+ return fail(`design call failed: ${(error && error.message) || error}`);
161
+ }
162
+ });
163
+
164
+ return server;
165
+ }
166
+
167
+ // Auto-start only when run directly (`atris mcp`, `npx atris-mcp`,
168
+ // node index.mjs); realpathSync resolves the npm bin symlink.
169
+ const invokedPath = process.argv[1] ? realpathSync(process.argv[1]) : '';
170
+ if (invokedPath && invokedPath === fileURLToPath(import.meta.url)) {
171
+ const server = createServer();
172
+ const transport = new StdioServerTransport();
173
+ await server.connect(transport);
174
+ }
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.58.5",
3
+ "version": "3.58.7",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof.",
5
5
  "main": "bin/atris.js",
6
6
  "bin": {
7
7
  "atris": "bin/atris",
8
- "ax": "ax"
8
+ "ax": "ax",
9
+ "atris-mcp": "mcp/atris-mcp/index.mjs"
9
10
  },
10
11
  "files": [
11
12
  "ax",
@@ -19,6 +20,7 @@
19
20
  "scripts/det/",
20
21
  "utils/",
21
22
  "lib/",
23
+ "mcp/",
22
24
  "templates/",
23
25
  "README.md",
24
26
  "AGENTS.md",
@@ -99,5 +101,8 @@
99
101
  "bugs": {
100
102
  "url": "https://github.com/atrislabs/atris/issues"
101
103
  },
102
- "homepage": "https://github.com/atrislabs/atris#readme"
104
+ "homepage": "https://github.com/atrislabs/atris#readme",
105
+ "dependencies": {
106
+ "@modelcontextprotocol/sdk": "^1.30.0"
107
+ }
103
108
  }
@@ -8,7 +8,7 @@ set -euo pipefail
8
8
  USAGE="usage: ytnotes <youtube-url> [haiku|atris-fast|gemini|grok|codex|cursor]"
9
9
 
10
10
  case "${1:-}" in
11
- *youtube.com*|*youtu.be*) ;;
11
+ *youtube.com*|*youtu.be*|*youtube-nocookie.com*) ;;
12
12
  *) echo "$USAGE" >&2; exit 2 ;;
13
13
  esac
14
14
 
@@ -20,24 +20,135 @@ WORK="${TMPDIR:-/tmp}/ytnotes"
20
20
  mkdir -p "$WORK"
21
21
  cd "$WORK"
22
22
 
23
+ # Printed metadata and a written English VTT are a hit even when yt-dlp exits 429.
24
+ # A leaked WARNING|ERROR print line is not the video id.
25
+ # Manual English is --write-subs. Auto English is often en-orig / en-US / en-GB.
26
+ # Teach parses leftover yt_<id>.clean.txt stamps as cues.
27
+ # Empty --print still keeps a VTT written in this run (id from the watch /
28
+ # shorts / embed / live / e / nocookie embed URL or the new filename). A copied
29
+ # #t= timestamp is stripped so leftover yt_<id> files still match. When VTT is
30
+ # gone, leftover yt_<id>.clean.txt from a prior notes run is still a keep.
31
+ # Another video's leftover clean.txt is not invent-keep.
32
+ existing=""
33
+ for name in yt_*.en.vtt yt_*.en-orig.vtt yt_*.en-US.vtt yt_*.en-GB.vtt; do
34
+ [ -e "$name" ] || continue
35
+ existing="$existing $name"
36
+ done
37
+
23
38
  META=$(yt-dlp --no-update --quiet --no-warnings --skip-download \
24
- --write-auto-subs --sub-format vtt --sub-langs en \
39
+ --write-subs --write-auto-subs --sub-format vtt --sub-langs "en,en-orig,en-US,en-GB" \
25
40
  --no-simulate \
26
41
  -o "yt_%(id)s" \
27
42
  --print "%(id)s|%(title)s|%(channel)s|%(duration_string)s" \
28
- "$URL" 2>/dev/null)
29
- ID="${META%%|*}"
30
- INFO="${META#*|}"
31
-
32
- VTT="yt_${ID}.en.vtt"
33
- if [ ! -s "$VTT" ]; then
34
- echo "No English captions found for $URL. Falling back is manual (atris youtube process, 5 credits)." >&2
35
- exit 2
43
+ "$URL" 2>/dev/null) || true
44
+ # Skip WARNING/ERROR/INFO print leaks and NA/None sentinels. Keep the last
45
+ # id|title line so notes stay on yt_<id>.md and keep can find them after a
46
+ # later error. Same sentinel bar as watch/search looksLikeFlatVideoId.
47
+ ID=""
48
+ INFO=""
49
+ while IFS= read -r line || [ -n "$line" ]; do
50
+ trimmed="${line#"${line%%[![:space:]]*}"}"
51
+ case "$trimmed" in
52
+ WARNING*|ERROR*|INFO*|warning*|error*|info*) continue ;;
53
+ esac
54
+ case "$trimmed" in
55
+ *'|'*)
56
+ cand="${trimmed%%|*}"
57
+ folded=$(printf '%s' "$cand" | tr '[:upper:]' '[:lower:]')
58
+ case "$folded" in
59
+ ''|na|none) continue ;;
60
+ esac
61
+ case "$cand" in
62
+ *[!A-Za-z0-9_-]*) continue ;;
63
+ esac
64
+ ID="$cand"
65
+ INFO="${trimmed#*|}"
66
+ ;;
67
+ esac
68
+ done <<EOF
69
+ $META
70
+ EOF
71
+ if [ -z "$ID" ]; then
72
+ case "$URL" in
73
+ *'?v='*|*'&v='*)
74
+ ID="${URL#*'?v='}"
75
+ if [ "$ID" = "$URL" ]; then
76
+ ID="${URL#*'&v='}"
77
+ fi
78
+ ID="${ID%%&*}"
79
+ ID="${ID%%\?*}"
80
+ ;;
81
+ *'youtu.be/'*)
82
+ ID="${URL#*'youtu.be/'}"
83
+ ID="${ID%%\?*}"
84
+ ID="${ID%%&*}"
85
+ ID="${ID%%/*}"
86
+ ;;
87
+ *'/shorts/'*)
88
+ ID="${URL#*'/shorts/'}"
89
+ ID="${ID%%\?*}"
90
+ ID="${ID%%&*}"
91
+ ID="${ID%%/*}"
92
+ ;;
93
+ *'/embed/'*)
94
+ ID="${URL#*'/embed/'}"
95
+ ID="${ID%%\?*}"
96
+ ID="${ID%%&*}"
97
+ ID="${ID%%/*}"
98
+ ;;
99
+ *'/e/'*)
100
+ ID="${URL#*'/e/'}"
101
+ ID="${ID%%\?*}"
102
+ ID="${ID%%&*}"
103
+ ID="${ID%%/*}"
104
+ ;;
105
+ *'/live/'*)
106
+ ID="${URL#*'/live/'}"
107
+ ID="${ID%%\?*}"
108
+ ID="${ID%%&*}"
109
+ ID="${ID%%/*}"
110
+ ;;
111
+ esac
112
+ ID="${ID%%#*}"
113
+ fi
114
+
115
+ VTT=""
116
+ if [ -n "$ID" ]; then
117
+ for name in "yt_${ID}.en.vtt" "yt_${ID}.en-orig.vtt" "yt_${ID}.en-US.vtt" "yt_${ID}.en-GB.vtt"; do
118
+ if [ -s "$name" ]; then
119
+ VTT="$name"
120
+ break
121
+ fi
122
+ done
123
+ fi
124
+ if [ -z "$VTT" ]; then
125
+ for name in yt_*.en.vtt yt_*.en-orig.vtt yt_*.en-US.vtt yt_*.en-GB.vtt; do
126
+ [ -s "$name" ] || continue
127
+ case " $existing " in
128
+ *" $name "*) continue ;;
129
+ esac
130
+ VTT="$name"
131
+ if [ -z "$ID" ]; then
132
+ ID="${name#yt_}"
133
+ ID="${ID%%.*}"
134
+ fi
135
+ break
136
+ done
137
+ fi
138
+ if [ -z "$VTT" ]; then
139
+ if [ -n "$ID" ] && [ -s "yt_${ID}.clean.txt" ]; then
140
+ : # leftover cleaned transcript from a prior notes run
141
+ else
142
+ echo "No English captions found for $URL. Falling back is manual (atris youtube process, 5 credits)." >&2
143
+ exit 2
144
+ fi
36
145
  fi
37
146
 
38
147
  # Strip VTT cruft, drop only nearby rolling-caption duplicates, and keep one
39
148
  # time anchor every 30 seconds. Raw auto-captions are mostly timestamp spam:
40
149
  # the 21-minute canary is 210 KB raw, 95 KB with the old cleaner, and 22 KB here.
150
+ # Leftover clean.txt is already cleaned; do not overwrite it with an empty VTT pass.
151
+ if [ -n "$VTT" ]; then
41
152
  sed -E 's/<[^>]*>//g; s/&gt;/>/g; s/&amp;/\&/g; s/&quot;/"/g' "$VTT" | awk '
42
153
  function seconds(ts, parts) {
43
154
  split(ts, parts, ":")
@@ -69,6 +180,7 @@ function seconds(ts, parts) {
69
180
  for (i = 5; i > 0; i--) recent[i] = recent[i - 1]
70
181
  recent[0] = line
71
182
  }' > "yt_${ID}.clean.txt"
183
+ fi
72
184
 
73
185
  TRANSCRIPT=$(<"yt_${ID}.clean.txt")
74
186