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.
- package/GETTING_STARTED.md +2 -2
- package/PERSONA.md +11 -11
- package/README.md +9 -1
- package/atris/GEMINI.md +7 -6
- package/atris/PERSONA.md +4 -4
- package/atris/features/README.md +77 -6
- package/atris/policies/engineering-principles.md +129 -0
- package/atris/policies/genesis.md +112 -0
- package/atris/policies/product-design-principles.md +100 -0
- package/atris/skills/README.md +44 -15
- package/atris/skills/aeo/SKILL.md +10 -10
- package/atris/skills/atris/SKILL.md +1 -1
- package/atris/skills/design/SKILL.md +3 -1
- package/atris/skills/engines/SKILL.md +42 -50
- package/atris/skills/meta/SKILL.md +4 -4
- package/atris/skills/youtube/SKILL.md +11 -11
- package/bin/atris.js +60 -8
- package/commands/aeo.js +9 -12
- package/commands/analytics.js +8 -11
- package/commands/auth.js +8 -2
- package/commands/autopilot.js +4 -3
- package/commands/avail.js +1 -1
- package/commands/brain.js +4 -1
- package/commands/business.js +13 -8
- package/commands/close.js +2 -1
- package/commands/computer.js +15 -10
- package/commands/context-sync.js +3 -3
- package/commands/design.js +362 -0
- package/commands/doc-health.js +377 -0
- package/commands/drive.js +32 -0
- package/commands/experiments.js +13 -4
- package/commands/improve.js +68 -2
- package/commands/integrations.js +2 -5
- package/commands/land.js +144 -4
- package/commands/learn.js +7 -2
- package/commands/member.js +239 -58
- package/commands/mission.js +94 -18
- package/commands/pack.js +2 -2
- package/commands/probe.js +11 -5
- package/commands/pull.js +1 -1
- package/commands/pulse.js +38 -0
- package/commands/push.js +1 -1
- package/commands/rainmaker.js +3 -16
- package/commands/rsi.js +156 -0
- package/commands/spaceship.js +1 -1
- package/commands/stream.js +27 -2
- package/commands/study.js +15 -9
- package/commands/sync.js +1 -1
- package/commands/task.js +54 -3
- package/commands/workflow.js +17 -7
- package/commands/youtube.js +76 -8
- package/lib/apply-gate.js +22 -4
- package/lib/codex-flight.js +28 -5
- package/lib/daily-log.js +88 -0
- package/lib/design-api.js +130 -0
- package/lib/engine-ask.js +1 -1
- package/lib/first-minute.js +1 -6
- package/lib/fleet.js +4 -1
- package/lib/improve-vitals-html.js +3 -2
- package/lib/known-commands.js +3 -3
- package/lib/lesson-contradiction.js +2 -2
- package/lib/map-refs.js +183 -0
- package/lib/member-context.js +42 -0
- package/lib/mission-room.js +4 -4
- package/lib/noninteractive.js +15 -4
- package/lib/policy-lessons.js +1 -1
- package/lib/receipt-block.js +137 -28
- package/lib/rsi-record.js +335 -0
- package/lib/runner-command.js +1 -1
- package/lib/scorecard.js +4 -4
- package/lib/sleep-sync.js +11 -0
- package/lib/state-detection.js +36 -17
- package/lib/task-db.js +74 -53
- package/lib/task-list-keeper.js +192 -0
- package/lib/todo-fallback.js +10 -4
- package/lib/todo.js +22 -10
- package/lib/wish-delegate.js +19 -5
- package/mcp/atris-mcp/index.mjs +227 -0
- package/package.json +4 -2
- package/scripts/det/det.js +2 -2
- package/scripts/det/pr-description.js +2 -2
- package/scripts/det/test.js +2 -2
- package/scripts/det/ytnotes +43 -8
- package/utils/api.js +1 -1
- package/utils/auth.js +79 -14
- package/utils/backend-root.js +31 -0
- package/utils/config.js +5 -4
- package/utils/owner-identity.js +61 -0
- package/atris/policies/LESSONS.md +0 -13
- package/atris/skills/autopilot/atris-autopilot.md +0 -71
package/lib/wish-delegate.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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.
|
|
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",
|
package/scripts/det/det.js
CHANGED
|
@@ -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
|
|
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
|
|
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}
|
|
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
|
|
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) }),
|
package/scripts/det/test.js
CHANGED
|
@@ -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
|
|
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
|
|
270
|
+
console.log(`ok: ${passed} checks passed`);
|
|
271
271
|
|
|
272
272
|
// hunk-filter: keeps only matching hunks, drops non-matching files entirely
|
|
273
273
|
{
|
package/scripts/det/ytnotes
CHANGED
|
@@ -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
|
|
26
|
-
# in this run (id from the watch /
|
|
27
|
-
# or the new filename). A copied
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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/>/>/g; s/&/\&/g; s/"/"/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(`
|
|
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
|
-
// "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
430
|
-
|
|
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 };
|