atris 3.58.6 → 3.58.8

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 (90) hide show
  1. package/GETTING_STARTED.md +2 -2
  2. package/PERSONA.md +11 -11
  3. package/README.md +9 -1
  4. package/atris/GEMINI.md +7 -6
  5. package/atris/PERSONA.md +4 -4
  6. package/atris/features/README.md +77 -6
  7. package/atris/policies/engineering-principles.md +129 -0
  8. package/atris/policies/genesis.md +112 -0
  9. package/atris/policies/product-design-principles.md +100 -0
  10. package/atris/skills/README.md +44 -15
  11. package/atris/skills/aeo/SKILL.md +10 -10
  12. package/atris/skills/atris/SKILL.md +1 -1
  13. package/atris/skills/design/SKILL.md +3 -1
  14. package/atris/skills/engines/SKILL.md +42 -50
  15. package/atris/skills/meta/SKILL.md +4 -4
  16. package/atris/skills/youtube/SKILL.md +11 -11
  17. package/bin/atris.js +60 -8
  18. package/commands/aeo.js +9 -12
  19. package/commands/analytics.js +8 -11
  20. package/commands/auth.js +8 -2
  21. package/commands/autopilot.js +4 -3
  22. package/commands/avail.js +1 -1
  23. package/commands/brain.js +4 -1
  24. package/commands/business.js +13 -8
  25. package/commands/close.js +2 -1
  26. package/commands/computer.js +15 -10
  27. package/commands/context-sync.js +3 -3
  28. package/commands/design.js +362 -0
  29. package/commands/doc-health.js +377 -0
  30. package/commands/drive.js +32 -0
  31. package/commands/experiments.js +13 -4
  32. package/commands/improve.js +68 -2
  33. package/commands/integrations.js +2 -5
  34. package/commands/land.js +144 -4
  35. package/commands/learn.js +7 -2
  36. package/commands/member.js +239 -58
  37. package/commands/mission.js +94 -18
  38. package/commands/pack.js +2 -2
  39. package/commands/probe.js +11 -5
  40. package/commands/pull.js +1 -1
  41. package/commands/pulse.js +38 -0
  42. package/commands/push.js +1 -1
  43. package/commands/rainmaker.js +3 -16
  44. package/commands/rsi.js +156 -0
  45. package/commands/spaceship.js +1 -1
  46. package/commands/stream.js +27 -2
  47. package/commands/study.js +15 -9
  48. package/commands/sync.js +1 -1
  49. package/commands/task.js +54 -3
  50. package/commands/workflow.js +17 -7
  51. package/commands/youtube.js +76 -8
  52. package/lib/apply-gate.js +22 -4
  53. package/lib/codex-flight.js +28 -5
  54. package/lib/daily-log.js +88 -0
  55. package/lib/design-api.js +130 -0
  56. package/lib/engine-ask.js +1 -1
  57. package/lib/first-minute.js +1 -6
  58. package/lib/fleet.js +4 -1
  59. package/lib/improve-vitals-html.js +3 -2
  60. package/lib/known-commands.js +3 -3
  61. package/lib/lesson-contradiction.js +2 -2
  62. package/lib/map-refs.js +183 -0
  63. package/lib/member-context.js +42 -0
  64. package/lib/mission-room.js +4 -4
  65. package/lib/noninteractive.js +15 -4
  66. package/lib/policy-lessons.js +1 -1
  67. package/lib/receipt-block.js +137 -28
  68. package/lib/rsi-record.js +335 -0
  69. package/lib/runner-command.js +1 -1
  70. package/lib/scorecard.js +4 -4
  71. package/lib/sleep-sync.js +11 -0
  72. package/lib/state-detection.js +36 -17
  73. package/lib/task-db.js +74 -53
  74. package/lib/task-list-keeper.js +192 -0
  75. package/lib/todo-fallback.js +10 -4
  76. package/lib/todo.js +22 -10
  77. package/lib/wish-delegate.js +19 -5
  78. package/mcp/atris-mcp/index.mjs +227 -0
  79. package/package.json +4 -2
  80. package/scripts/det/det.js +2 -2
  81. package/scripts/det/pr-description.js +2 -2
  82. package/scripts/det/test.js +2 -2
  83. package/scripts/det/ytnotes +43 -8
  84. package/utils/api.js +1 -1
  85. package/utils/auth.js +79 -14
  86. package/utils/backend-root.js +31 -0
  87. package/utils/config.js +5 -4
  88. package/utils/owner-identity.js +61 -0
  89. package/atris/policies/LESSONS.md +0 -13
  90. package/atris/skills/autopilot/atris-autopilot.md +0 -71
@@ -2,6 +2,7 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const { BACKEND_ROOT_HINT, resolveBackendRoot } = require('../utils/backend-root');
5
6
  const { getLogPath, ensureLogDirectory, createLogFile, addInboxIdea } = require('./file-ops');
6
7
  const { resolveFunctionalOwner } = require('./functional-owner');
7
8
  const { buildMissionRoom, writeMissionRoomReceipt } = require('./mission-room');
@@ -55,7 +56,6 @@ const {
55
56
  } = require('./wish-audit');
56
57
 
57
58
  const METRIC_OPS = { '>=': '--gte', '<=': '--lte', '==': '--eq', '>': '--gt', '<': '--lt' };
58
- const METRIC_VERIFY_ROOT = '/Users/keshavrao/arena/atrisos-backend';
59
59
  const DAY_MS = 24 * 60 * 60 * 1000;
60
60
  const HOUR_MS = 60 * 60 * 1000;
61
61
  const IMPLICIT_ANSWER_STALE_MS = HOUR_MS;
@@ -105,8 +105,11 @@ function parseMetricExpression(text) {
105
105
 
106
106
  // Wire to backend/scripts/metric_verify.py: exits 0 when the metric meets
107
107
  // target and prints a JSON line with value + proof_uri.
108
- function metricVerifierCommand(parsed) {
109
- return `cd ${METRIC_VERIFY_ROOT} && venv/bin/python backend/scripts/metric_verify.py ${parsed.metric} ${parsed.flag} ${parsed.target}`;
108
+ function metricVerifierCommand(parsed, workspace) {
109
+ const backend = resolveBackendRoot(workspace);
110
+ if (!backend) throw new Error(BACKEND_ROOT_HINT);
111
+ const quoted = `'${backend.replace(/'/g, `'\\''`)}'`;
112
+ return `cd ${quoted} && venv/bin/python backend/scripts/metric_verify.py ${parsed.metric} ${parsed.flag} ${parsed.target}`;
110
113
  }
111
114
 
112
115
  const MISSION_TERMINAL_STATUSES = new Set(['complete', 'stopped']);
@@ -489,7 +492,9 @@ function scorecardText(value) {
489
492
  }
490
493
 
491
494
  function readWishReviewRewardRows(root = process.cwd()) {
492
- const file = path.resolve(root, '..', 'atrisos-backend', '.atris', 'state', 'scorecards.jsonl');
495
+ const backend = resolveBackendRoot(root);
496
+ if (!backend) return [];
497
+ const file = path.join(backend, '.atris', 'state', 'scorecards.jsonl');
493
498
  return readJsonLines(file).filter((row) => {
494
499
  const haystack = `${scorecardText(row.source)} ${scorecardText(row.feedback)}`;
495
500
  return /wish_review/i.test(haystack);
@@ -514,6 +519,7 @@ function reviewLine(review) {
514
519
 
515
520
  function printRewards(root = process.cwd()) {
516
521
  const reviews = readWishEvents(root).filter((event) => event && event.kind === 'review');
522
+ if (!resolveBackendRoot(root)) console.error(BACKEND_ROOT_HINT);
517
523
  const rewardRows = readWishReviewRewardRows(root);
518
524
  console.log(`reviews count: ${reviews.length}`);
519
525
  console.log(`avg score: ${averageReviewScore(reviews)}`);
@@ -749,7 +755,7 @@ function startWishDelegation(wish, audit, root, options = {}) {
749
755
  const suppliedVerifyHasCommand = !!(suppliedVerifyPlan && suppliedVerifyPlan.command);
750
756
  const userVerifyProvided = options.userVerifyProvided === true || options.verifyPlanExplicit === true;
751
757
  let verifyPlan = metricPlan
752
- ? { command: metricVerifierCommand(metricPlan), outcome: `metric ${metricPlan.expression} is met`, status: 'metric' }
758
+ ? { command: metricVerifierCommand(metricPlan, root), outcome: `metric ${metricPlan.expression} is met`, status: 'metric' }
753
759
  : (suppliedVerifyPlan || deriveVerifyPlan(taskText));
754
760
  if (!metricPlan && frontendWish && designContext.auditCommand && !userVerifyProvided && !suppliedVerifyHasCommand && !(verifyPlan && verifyPlan.command)) {
755
761
  verifyPlan = designVerifyPlan(designContext);
@@ -1529,6 +1535,10 @@ function runCapturedWish(text, root = process.cwd(), options = {}) {
1529
1535
  const asJson = !!options.asJson;
1530
1536
  const noMission = !!options.noMission;
1531
1537
  const builderMode = String(options.asMode || '').trim() === 'builder';
1538
+ if (options.metric?.ok && !resolveBackendRoot(root)) {
1539
+ console.error(BACKEND_ROOT_HINT);
1540
+ return 2;
1541
+ }
1532
1542
  let engineOverride = '';
1533
1543
  try {
1534
1544
  engineOverride = validateEngineOverride(options.engineOverride, root);
@@ -1617,6 +1627,10 @@ function runAgainWish(positionals, root = process.cwd(), options = {}) {
1617
1627
  function applyAnswer(wish, answer, root, options = {}) {
1618
1628
  const asJson = !!options.asJson;
1619
1629
  const noMission = !!options.noMission;
1630
+ if (wish.metric && !resolveBackendRoot(root)) {
1631
+ console.error(BACKEND_ROOT_HINT);
1632
+ return 2;
1633
+ }
1620
1634
  let engineOverride = '';
1621
1635
  try {
1622
1636
  engineOverride = validateEngineOverride(options.engineOverride, root);
@@ -0,0 +1,227 @@
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 { readFileSync, realpathSync } from 'node:fs';
8
+ import { createInterface } from 'node:readline';
9
+ import { fileURLToPath } from 'node:url';
10
+ import designApi from '../../lib/design-api.js';
11
+
12
+ const { resolveDesignKey, designRequest, pollDesignJob, billingOf } = designApi;
13
+
14
+ export const TOOLS = [
15
+ {
16
+ name: 'design_extract',
17
+ description: 'extract a site\'s design system (colors, typography, layout, voice) as json. costs 10 credits, 2 on a cache hit.',
18
+ inputSchema: {
19
+ type: 'object',
20
+ properties: {
21
+ url: { type: 'string', description: 'site url to extract, e.g. https://stripe.com' },
22
+ },
23
+ required: ['url'],
24
+ },
25
+ },
26
+ {
27
+ name: 'design_check',
28
+ description: 'score how closely a page follows a reference brand. costs 20 credits.',
29
+ inputSchema: {
30
+ type: 'object',
31
+ properties: {
32
+ source_url: { type: 'string', description: 'page being checked' },
33
+ reference_url: { type: 'string', description: 'brand url to check against' },
34
+ },
35
+ required: ['source_url', 'reference_url'],
36
+ },
37
+ },
38
+ {
39
+ name: 'design_search',
40
+ description: 'search brands already extracted by atris design. costs 1 credit.',
41
+ inputSchema: {
42
+ type: 'object',
43
+ properties: {
44
+ query: { type: 'string', description: 'plain words, e.g. "dark developer tools brand"' },
45
+ limit: { type: 'number', description: 'max results' },
46
+ },
47
+ required: ['query'],
48
+ },
49
+ },
50
+ ];
51
+
52
+ function terminal(job) {
53
+ const s = String((job && job.status) || '').toLowerCase();
54
+ return s === 'completed' || s === 'failed' || s === 'error' || s === 'succeeded';
55
+ }
56
+
57
+ function withBilling(data) {
58
+ const bill = billingOf(data);
59
+ return {
60
+ ...(data && typeof data === 'object' ? data : { result: data }),
61
+ credits_charged: bill.credits,
62
+ balance_remaining_usd: bill.balanceUsd,
63
+ };
64
+ }
65
+
66
+ function ok(data) {
67
+ return { content: [{ type: 'text', text: JSON.stringify(withBilling(data), null, 2) }] };
68
+ }
69
+
70
+ function fail(message) {
71
+ return { content: [{ type: 'text', text: message }], isError: true };
72
+ }
73
+
74
+ async function callApi(pathname, options, key) {
75
+ const res = await designRequest(pathname, { ...options, key });
76
+ if (!res.ok) throw new Error(`http ${res.status}: ${res.error || 'request failed'}`);
77
+ if (!res.data || typeof res.data !== 'object') {
78
+ throw new Error('the design api returned an empty response');
79
+ }
80
+ return res.data;
81
+ }
82
+
83
+ async function runJob(first, pollPath, key) {
84
+ if (!first || typeof first !== 'object') {
85
+ throw new Error('the design api returned an empty response');
86
+ }
87
+ let job = first;
88
+ if (!terminal(job) && !job.id) {
89
+ throw new Error('the design api returned no job id');
90
+ }
91
+ if (!terminal(job) && job.id) {
92
+ let failures = 0;
93
+ const polled = await pollDesignJob(async () => {
94
+ try {
95
+ const out = await callApi(pollPath, {}, key);
96
+ failures = 0;
97
+ return out;
98
+ } catch (error) {
99
+ failures += 1;
100
+ if (failures >= 3) return { status: 'failed', id: job.id, error: (error && error.message) || String(error) };
101
+ return { status: 'polling' };
102
+ }
103
+ });
104
+ if (polled.timedOut) {
105
+ throw new Error(`still running after 3 minutes. job id: ${job.id}, poll: ${pollPath}`);
106
+ }
107
+ job = polled.job || job;
108
+ }
109
+ if (job.status !== 'completed') {
110
+ throw new Error(`job ${job.id || 'unknown'} did not finish: ${job.error || job.status || 'unknown'} (poll: ${pollPath})`);
111
+ }
112
+ return job;
113
+ }
114
+
115
+ export async function handleTool(name, args = {}, key) {
116
+ if (name === 'design_extract') {
117
+ const url = String(args.url || '').trim();
118
+ if (!url) return fail('design_extract needs a url');
119
+ const first = await callApi('/design/extractions', { method: 'POST', body: { url } }, key);
120
+ return ok(await runJob(first, `/design/extractions/${first.id}`, key));
121
+ }
122
+ if (name === 'design_check') {
123
+ const source = String(args.source_url || '').trim();
124
+ const reference = String(args.reference_url || '').trim();
125
+ if (!source || !reference) return fail('design_check needs source_url and reference_url');
126
+ const first = await callApi('/design/adherence', {
127
+ method: 'POST',
128
+ body: { source_url: source, reference_url: reference },
129
+ }, key);
130
+ return ok(await runJob(first, `/design/adherence/${first.id}`, key));
131
+ }
132
+ if (name === 'design_search') {
133
+ const query = String(args.query || '').trim();
134
+ if (!query) return fail('design_search needs a query');
135
+ const body = { query };
136
+ if (Number.isInteger(args.limit) && args.limit > 0) body.limit = args.limit;
137
+ return ok(await callApi('/design/search', { method: 'POST', body }, key));
138
+ }
139
+ return fail(`unknown tool: ${name}`);
140
+ }
141
+
142
+ async function serveStdio() {
143
+ const { name, version } = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
144
+ const knownVersions = new Set(['2024-11-05', '2025-03-26', '2025-06-18', '2025-11-25']);
145
+ const send = (message, onWritten) => process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', ...message })}\n`, onWritten);
146
+ const input = createInterface({ input: process.stdin, crlfDelay: Infinity });
147
+ const inFlight = new Set();
148
+ const pendingById = new Map();
149
+
150
+ for await (const line of input) {
151
+ if (!line.trim()) continue;
152
+ let request;
153
+ try {
154
+ request = JSON.parse(line);
155
+ } catch {
156
+ send({ id: null, error: { code: -32700, message: 'parse error' } });
157
+ continue;
158
+ }
159
+ if (!request || typeof request !== 'object' || Array.isArray(request)
160
+ || request.jsonrpc !== '2.0' || typeof request.method !== 'string') {
161
+ send({ id: request?.id ?? null, error: { code: -32600, message: 'invalid request' } });
162
+ continue;
163
+ }
164
+ if (request.method === 'notifications/cancelled') {
165
+ const pending = pendingById.get(request.params?.requestId);
166
+ if (pending) pending.cancelled = true;
167
+ continue;
168
+ }
169
+ if (!Object.hasOwn(request, 'id')) continue;
170
+
171
+ const id = request.id;
172
+ let result;
173
+ if (request.method === 'initialize') {
174
+ result = {
175
+ protocolVersion: knownVersions.has(request.params?.protocolVersion)
176
+ ? request.params.protocolVersion : '2025-06-18',
177
+ capabilities: { tools: {} },
178
+ serverInfo: { name, version },
179
+ };
180
+ } else if (request.method === 'ping') {
181
+ result = {};
182
+ } else if (request.method === 'tools/list') {
183
+ result = { tools: TOOLS };
184
+ } else if (request.method === 'tools/call') {
185
+ const pending = { cancelled: false };
186
+ const work = (async () => {
187
+ try {
188
+ const key = resolveDesignKey();
189
+ result = key
190
+ ? await handleTool(request.params?.name, request.params?.arguments || {}, key)
191
+ : fail('no api key found. set ATRIS_API_KEY or run: atris login (or save a key in ~/.atris/design-api-key)');
192
+ } catch (error) {
193
+ result = fail(`design call failed: ${(error && error.message) || error}`);
194
+ }
195
+ if (!pending.cancelled) {
196
+ await new Promise((resolve, reject) => {
197
+ send({ id, result }, (error) => (error ? reject(error) : resolve()));
198
+ });
199
+ }
200
+ })();
201
+ inFlight.add(work);
202
+ pendingById.set(id, pending);
203
+ const done = () => {
204
+ inFlight.delete(work);
205
+ if (pendingById.get(id) === pending) pendingById.delete(id);
206
+ };
207
+ work.then(done, (error) => {
208
+ done();
209
+ process.stderr.write(`mcp response write failed: ${(error && error.message) || error}\n`);
210
+ process.exitCode = 1;
211
+ });
212
+ continue;
213
+ } else {
214
+ send({ id, error: { code: -32601, message: 'method not found' } });
215
+ continue;
216
+ }
217
+ send({ id, result });
218
+ }
219
+ await Promise.allSettled(inFlight);
220
+ }
221
+
222
+ // Auto-start only when run directly (`atris mcp`, `npx atris-mcp`,
223
+ // node index.mjs); realpathSync resolves the npm bin symlink.
224
+ const invokedPath = process.argv[1] ? realpathSync(process.argv[1]) : '';
225
+ if (invokedPath && invokedPath === fileURLToPath(import.meta.url)) {
226
+ await serveStdio();
227
+ }
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.58.6",
3
+ "version": "3.58.8",
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",
@@ -84,7 +84,7 @@ function catalogText() {
84
84
  );
85
85
  const gitRows = GIT_SCRIPTS.map((g) => ` ${g.name.padEnd(15)} ${g.usage}\n ${g.ask}`);
86
86
  return (
87
- 'deterministic task scripts — run: node det.js <script> <mode> < input\n\n' +
87
+ 'deterministic task scripts: run node det.js <script> <mode> < input\n\n' +
88
88
  rows.join('\n\n') +
89
89
  '\n\ngit-facing (read the repo, run the script directly; not via det.js):\n\n' +
90
90
  gitRows.join('\n\n') +
@@ -121,7 +121,7 @@ async function main() {
121
121
  }
122
122
  const git = GIT_SCRIPTS.find((g) => g.name === script);
123
123
  if (git) {
124
- process.stdout.write(`${git.name} reads the repo, not stdin — run it directly:\n ${git.usage}\n`);
124
+ process.stdout.write(`${git.name} reads the repo, not stdin; run it directly:\n ${git.usage}\n`);
125
125
  return;
126
126
  }
127
127
  const entry = CATALOG[script];
@@ -78,7 +78,7 @@ function areaBullets(files) {
78
78
  if (counts.A) parts.push(`${counts.A} added`);
79
79
  if (counts.M) parts.push(`${counts.M} changed`);
80
80
  if (counts.D) parts.push(`${counts.D} removed`);
81
- bullets.push(`- **${area}** — ${parts.join(', ')} (+${added}/-${deleted})`);
81
+ bullets.push(`- **${area}**: ${parts.join(', ')} (+${added}/-${deleted})`);
82
82
  }
83
83
  return bullets;
84
84
  }
@@ -108,7 +108,7 @@ function build(input) {
108
108
  const commits = (input && input.commits) || [];
109
109
  const files = (input && input.files) || [];
110
110
  if (!commits.length && !files.length) {
111
- return { error: 'no commits or files vs base — is the branch ahead of it?' };
111
+ return { error: 'no commits or files vs base: is the branch ahead of it?' };
112
112
  }
113
113
  const totals = files.reduce(
114
114
  (a, f) => ({ added: a.added + (f.added || 0), deleted: a.deleted + (f.deleted || 0) }),
@@ -211,7 +211,7 @@ check(
211
211
  { path: 'scripts/det/b.js', status: 'M', added: 2, deleted: 1 },
212
212
  { path: 'test/x.test.js', status: 'A', added: 5, deleted: 0 },
213
213
  ]),
214
- ['- **scripts** — 1 added, 1 changed (+12/-1)', '- **test** — 1 added (+5/-0)']
214
+ ['- **scripts**: 1 added, 1 changed (+12/-1)', '- **test**: 1 added (+5/-0)']
215
215
  );
216
216
  // test plan lists touched test files, then a check per non-test area
217
217
  check(
@@ -267,7 +267,7 @@ check('det.git.json', JSON.parse(catalogJson()).git['commit-msg'].usage.includes
267
267
  // stdin catalog stays separate from the git list (routing must not mix them)
268
268
  check('det.git.notRoutable', CATALOG['commit-msg'], undefined);
269
269
 
270
- console.log(`ok — ${passed} checks passed`);
270
+ console.log(`ok: ${passed} checks passed`);
271
271
 
272
272
  // hunk-filter: keeps only matching hunks, drops non-matching files entirely
273
273
  {
@@ -21,11 +21,14 @@ mkdir -p "$WORK"
21
21
  cd "$WORK"
22
22
 
23
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.
24
25
  # Manual English is --write-subs. Auto English is often en-orig / en-US / en-GB.
25
- # Teach reads the same list on disk. Empty --print still keeps a VTT written
26
- # in this run (id from the watch / shorts / embed / live / e / nocookie embed URL
27
- # or the new filename). A copied #t= timestamp is stripped so leftover
28
- # yt_<id> files still match.
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.
29
32
  existing=""
30
33
  for name in yt_*.en.vtt yt_*.en-orig.vtt yt_*.en-US.vtt yt_*.en-GB.vtt; do
31
34
  [ -e "$name" ] || continue
@@ -38,8 +41,33 @@ META=$(yt-dlp --no-update --quiet --no-warnings --skip-download \
38
41
  -o "yt_%(id)s" \
39
42
  --print "%(id)s|%(title)s|%(channel)s|%(duration_string)s" \
40
43
  "$URL" 2>/dev/null) || true
41
- ID="${META%%|*}"
42
- INFO="${META#*|}"
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
43
71
  if [ -z "$ID" ]; then
44
72
  case "$URL" in
45
73
  *'?v='*|*'&v='*)
@@ -108,13 +136,19 @@ if [ -z "$VTT" ]; then
108
136
  done
109
137
  fi
110
138
  if [ -z "$VTT" ]; then
111
- echo "No English captions found for $URL. Falling back is manual (atris youtube process, 5 credits)." >&2
112
- exit 2
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
113
145
  fi
114
146
 
115
147
  # Strip VTT cruft, drop only nearby rolling-caption duplicates, and keep one
116
148
  # time anchor every 30 seconds. Raw auto-captions are mostly timestamp spam:
117
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
118
152
  sed -E 's/<[^>]*>//g; s/&gt;/>/g; s/&amp;/\&/g; s/&quot;/"/g' "$VTT" | awk '
119
153
  function seconds(ts, parts) {
120
154
  split(ts, parts, ":")
@@ -146,6 +180,7 @@ function seconds(ts, parts) {
146
180
  for (i = 5; i > 0; i--) recent[i] = recent[i - 1]
147
181
  recent[0] = line
148
182
  }' > "yt_${ID}.clean.txt"
183
+ fi
149
184
 
150
185
  TRANSCRIPT=$(<"yt_${ID}.clean.txt")
151
186
 
package/utils/api.js CHANGED
@@ -97,7 +97,7 @@ function httpRequest(urlString, options) {
97
97
  // Socket idle timeout (fires if no data received for this duration)
98
98
  if (timeoutMs > 0) {
99
99
  req.setTimeout(timeoutMs, () => {
100
- req.destroy(new Error(`Request timeout after ${Math.round(timeoutMs / 1000)}s — try --timeout=300`));
100
+ req.destroy(new Error(`request timeout after ${Math.round(timeoutMs / 1000)}s, try --timeout=300`));
101
101
  });
102
102
  }
103
103
  // Hard deadline — kill request after 2x the timeout regardless of activity
package/utils/auth.js CHANGED
@@ -156,6 +156,20 @@ function mkPrivateDir(dir) {
156
156
  return dir;
157
157
  }
158
158
 
159
+ // Credential files hold login tokens. If they cannot be limited to the owner,
160
+ // say so once per file instead of leaving them readable in silence.
161
+ const permissionWarned = new Set();
162
+ function restrictToOwner(filePath) {
163
+ try {
164
+ fs.chmodSync(filePath, 0o600);
165
+ } catch (err) {
166
+ if (permissionWarned.has(filePath)) return;
167
+ permissionWarned.add(filePath);
168
+ const reason = err && err.code ? ` (${err.code})` : '';
169
+ console.error(`atris: could not limit ${filePath} to your user${reason}. other users on this machine may be able to read it.`);
170
+ }
171
+ }
172
+
159
173
  function getAtrisDir() {
160
174
  const homeDir = os.homedir();
161
175
  return mkPrivateDir(path.join(homeDir, '.atris'));
@@ -202,6 +216,51 @@ function loadPlacedAgentToken() {
202
216
  }
203
217
  }
204
218
 
219
+ function leftoverStoredAgentToken(parsed, envToken) {
220
+ if (!parsed || !envToken) return null;
221
+ const leftover = typeof parsed.agent_token === 'string' ? parsed.agent_token.trim() : '';
222
+ if (!leftover || leftover !== envToken) return null;
223
+ const expiresAt = parsed.agent_token_expires_at;
224
+ if (expiryTimeMs(expiresAt) === null) return null;
225
+ const scopes = Array.isArray(parsed.agent_token_scopes) ? parsed.agent_token_scopes : [];
226
+ return {
227
+ token: leftover,
228
+ expires_at: expiresAt,
229
+ scopes,
230
+ provider: null,
231
+ source: 'env',
232
+ agent_token: leftover,
233
+ agent_token_scopes: scopes,
234
+ agent_token_expires_at: expiresAt,
235
+ };
236
+ }
237
+
238
+ function loadLeftoverCredentialsAgentToken(envToken) {
239
+ if (!envToken) return null;
240
+ try {
241
+ return leftoverStoredAgentToken(JSON.parse(fs.readFileSync(getCredentialsPath(), 'utf8')), envToken);
242
+ } catch {
243
+ return null;
244
+ }
245
+ }
246
+
247
+ function resolveProfileOverride(profileOverride) {
248
+ if (!profileOverride) return null;
249
+ if (loadProfile(profileOverride)) return profileOverride;
250
+ const profiles = listProfiles();
251
+ const q = String(profileOverride).toLowerCase();
252
+ return profiles.find((name) => name.toLowerCase() === q)
253
+ || profiles.find((name) => name.toLowerCase().startsWith(q))
254
+ || profiles.find((name) => name.toLowerCase().includes(q))
255
+ || null;
256
+ }
257
+
258
+ function loadLeftoverProfileAgentToken(envToken) {
259
+ const name = resolveProfileOverride(process.env.ATRIS_PROFILE) || getSessionProfile();
260
+ if (!envToken || !name) return null;
261
+ return leftoverStoredAgentToken(loadProfile(name), envToken);
262
+ }
263
+
205
264
  function isUnexpiredCredential(credentials) {
206
265
  const expiresAt = expiryTimeMs(credentials?.expires_at);
207
266
  return expiresAt !== null && expiresAt > Date.now();
@@ -304,7 +363,7 @@ function getProfilesDir() {
304
363
 
305
364
  function profileNameFromEmail(email) {
306
365
  if (!email) return null;
307
- // "keshav@atrislabs.com" → "keshav"
366
+ // "alex@example.com" becomes "alex".
308
367
  const name = email.split('@')[0].toLowerCase().replace(/[^a-z0-9_-]/g, '-');
309
368
  return name || null; // Guard against emails like "@domain.com"
310
369
  }
@@ -312,7 +371,7 @@ function profileNameFromEmail(email) {
312
371
  function saveProfile(name, credentials) {
313
372
  const profilePath = path.join(getProfilesDir(), `${name}.json`);
314
373
  fs.writeFileSync(profilePath, JSON.stringify(credentials, null, 2));
315
- try { fs.chmodSync(profilePath, 0o600); } catch {}
374
+ restrictToOwner(profilePath);
316
375
  }
317
376
 
318
377
  function loadProfile(name) {
@@ -368,11 +427,7 @@ function saveCredentials(token, refreshToken, email, userId, provider, extras =
368
427
  };
369
428
 
370
429
  fs.writeFileSync(credentialsPath, JSON.stringify(credentials, null, 2));
371
- try {
372
- fs.chmodSync(credentialsPath, 0o600);
373
- } catch {
374
- // Best effort: permissions may be unsupported on this platform.
375
- }
430
+ restrictToOwner(credentialsPath);
376
431
 
377
432
  // Auto-save as named profile
378
433
  autoSaveProfile(credentials);
@@ -417,7 +472,8 @@ async function repairLoginCredentials(credentials, apiRequestJson) {
417
472
  function readCredentials() {
418
473
  // Priority: ATRIS_TOKEN env var → placed agent token → ATRIS_PROFILE env var
419
474
  // → per-terminal session file → global credentials.json. A fresh placed token
420
- // overrides a different env token because cloud images can carry a stale one.
475
+ // overrides env when the leftover is newer or when env only repeats that
476
+ // leftover, so billed auth still sees leftover scopes.
421
477
 
422
478
  // 0. Raw token injection. Headless boxes (cloud business computers) have no
423
479
  // browser for `atris login`; the runner injects a scoped token as env
@@ -425,15 +481,24 @@ function readCredentials() {
425
481
  const envToken = process.env.ATRIS_TOKEN;
426
482
  const normalizedEnvToken = envToken && envToken.trim();
427
483
  const placedAgentToken = loadPlacedAgentToken();
428
- if (
429
- normalizedEnvToken &&
430
- placedAgentToken &&
431
- placedAgentToken.token !== normalizedEnvToken &&
432
- isUnexpiredCredential(placedAgentToken)
433
- ) {
484
+ if (normalizedEnvToken && placedAgentToken && isUnexpiredCredential(placedAgentToken)) {
485
+ // Fresh placed leftover wins over env, including when env repeats the same
486
+ // token, so billed auth still sees leftover scopes and does not remint.
434
487
  return placedAgentToken;
435
488
  }
436
489
  if (normalizedEnvToken) {
490
+ const leftoverCredentials = loadLeftoverCredentialsAgentToken(normalizedEnvToken);
491
+ if (leftoverCredentials && isUnexpiredCredential(leftoverCredentials)) {
492
+ // Env repeating leftover credentials.agent_token keeps leftover scopes
493
+ // and expiry so billed auth does not remint.
494
+ return leftoverCredentials;
495
+ }
496
+ const leftoverProfile = loadLeftoverProfileAgentToken(normalizedEnvToken);
497
+ if (leftoverProfile && isUnexpiredCredential(leftoverProfile)) {
498
+ // Env repeating leftover profile or session-profile agent_token keeps
499
+ // leftover scopes and expiry so billed auth does not remint.
500
+ return leftoverProfile;
501
+ }
437
502
  return { token: normalizedEnvToken, provider: null, source: 'env' };
438
503
  }
439
504
 
@@ -0,0 +1,31 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { resolveWorkspaceRoot } = require('../lib/mission-root');
6
+ const { loadConfig } = require('./config');
7
+
8
+ const BACKEND_ROOT_HINT = 'set ATRIS_BACKEND_ROOT to the backend workspace.';
9
+
10
+ function resolveBackendRoot(cwd = process.cwd()) {
11
+ const workspace = resolveWorkspaceRoot(cwd);
12
+ // ATRIS_BACKEND_DIR is the former spelling, accepted only here.
13
+ const environment = process.env.ATRIS_BACKEND_ROOT || process.env.ATRIS_BACKEND_DIR;
14
+ if (environment) {
15
+ const candidate = path.resolve(environment);
16
+ return fs.existsSync(candidate) && fs.statSync(candidate).isDirectory() ? candidate : null;
17
+ }
18
+
19
+ const config = loadConfig(workspace);
20
+ if (typeof config?.backend_root === 'string' && config.backend_root.trim()) {
21
+ const candidate = path.resolve(workspace, config.backend_root);
22
+ return fs.existsSync(candidate) && fs.statSync(candidate).isDirectory() ? candidate : null;
23
+ }
24
+
25
+ const sibling = path.resolve(workspace, '..', 'atrisos-backend');
26
+ if (fs.existsSync(sibling) && fs.statSync(sibling).isDirectory()) return sibling;
27
+ const installedSibling = path.resolve(__dirname, '..', '..', 'atrisos-backend');
28
+ return fs.existsSync(installedSibling) && fs.statSync(installedSibling).isDirectory() ? installedSibling : null;
29
+ }
30
+
31
+ module.exports = { BACKEND_ROOT_HINT, resolveBackendRoot };