atris 3.58.7 → 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 (67) hide show
  1. package/GETTING_STARTED.md +2 -2
  2. package/PERSONA.md +11 -11
  3. package/README.md +1 -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/skills/README.md +44 -15
  8. package/atris/skills/aeo/SKILL.md +10 -10
  9. package/atris/skills/atris/SKILL.md +1 -1
  10. package/atris/skills/engines/SKILL.md +42 -50
  11. package/atris/skills/meta/SKILL.md +4 -4
  12. package/bin/atris.js +5 -6
  13. package/commands/aeo.js +9 -12
  14. package/commands/analytics.js +8 -11
  15. package/commands/autopilot.js +4 -3
  16. package/commands/avail.js +1 -1
  17. package/commands/brain.js +3 -1
  18. package/commands/business.js +13 -8
  19. package/commands/close.js +2 -1
  20. package/commands/computer.js +15 -10
  21. package/commands/context-sync.js +3 -3
  22. package/commands/doc-health.js +50 -2
  23. package/commands/experiments.js +13 -4
  24. package/commands/improve.js +1 -1
  25. package/commands/integrations.js +2 -5
  26. package/commands/learn.js +1 -1
  27. package/commands/member.js +174 -47
  28. package/commands/mission.js +57 -11
  29. package/commands/pack.js +2 -2
  30. package/commands/probe.js +11 -5
  31. package/commands/pull.js +1 -1
  32. package/commands/push.js +1 -1
  33. package/commands/rainmaker.js +3 -16
  34. package/commands/spaceship.js +1 -1
  35. package/commands/stream.js +27 -2
  36. package/commands/study.js +15 -9
  37. package/commands/sync.js +1 -1
  38. package/commands/task.js +13 -2
  39. package/commands/workflow.js +6 -1
  40. package/lib/codex-flight.js +28 -5
  41. package/lib/fleet.js +4 -1
  42. package/lib/improve-vitals-html.js +3 -2
  43. package/lib/lesson-contradiction.js +2 -2
  44. package/lib/map-refs.js +183 -0
  45. package/lib/mission-room.js +4 -4
  46. package/lib/noninteractive.js +15 -4
  47. package/lib/policy-lessons.js +1 -1
  48. package/lib/receipt-block.js +137 -28
  49. package/lib/runner-command.js +1 -1
  50. package/lib/scorecard.js +4 -4
  51. package/lib/sleep-sync.js +11 -0
  52. package/lib/state-detection.js +28 -9
  53. package/lib/task-db.js +3 -2
  54. package/lib/todo-fallback.js +1 -1
  55. package/lib/wish-delegate.js +19 -5
  56. package/mcp/atris-mcp/index.mjs +77 -24
  57. package/package.json +2 -5
  58. package/scripts/det/det.js +2 -2
  59. package/scripts/det/pr-description.js +2 -2
  60. package/scripts/det/test.js +2 -2
  61. package/utils/api.js +1 -1
  62. package/utils/auth.js +17 -7
  63. package/utils/backend-root.js +31 -0
  64. package/utils/config.js +5 -4
  65. package/utils/owner-identity.js +61 -0
  66. package/atris/policies/LESSONS.md +0 -13
  67. package/atris/skills/autopilot/atris-autopilot.md +0 -71
@@ -98,18 +98,116 @@ function directProofText(value) {
98
98
  return ran ? `${ran[1]} passed` : text;
99
99
  }
100
100
 
101
- function proofText(receipt) {
101
+ // A receipt is a text from a good employee: one sentence a friend would send,
102
+ // plus one link a stranger can check. Ids, statuses, scores and verifier
103
+ // output stay underneath (they feed the rating); they never lead the surface.
104
+ // Standard: atris-labs atris/wiki/systems/receipt-standard.md
105
+
106
+ function sentence(value) {
107
+ const text = trimPunctuation(value);
108
+ if (!text) return '';
109
+ return `${text.charAt(0).toUpperCase()}${text.slice(1)}.`;
110
+ }
111
+
112
+ // Bracketed IPv6 hosts need their own alternative: the plain class stops at
113
+ // ] and would cut http://[::1]:3000 down to http://[::1 .
114
+ const URL_RE = /https?:\/\/\[[0-9a-f.:]+\][^\s)>\]]*|https?:\/\/[^\s)>\]]+/i;
115
+ const URL_RE_GLOBAL = new RegExp(URL_RE.source, 'gi');
116
+
117
+ // A stranger cannot open localhost, loopback or *.local addresses, so they
118
+ // never count as the check link no matter which field they came from.
119
+ // Parsing with URL normalizes tricks like 127.1 or 0x7f.1 into 127.0.0.1.
120
+ function localCheckUrl(url) {
121
+ let host;
122
+ try {
123
+ host = new URL(url).hostname.toLowerCase();
124
+ } catch {
125
+ return false;
126
+ }
127
+ if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1);
128
+ return host === 'localhost'
129
+ || host === '0.0.0.0'
130
+ || host === '::1'
131
+ || host.startsWith('127.')
132
+ || host.endsWith('.local');
133
+ }
134
+
135
+ // A checked/tested note that is nothing but a local URL carries no check a
136
+ // stranger can run; the verifier line speaks instead.
137
+ function localOnlyUrl(text) {
138
+ const value = trimPunctuation(text);
139
+ return /^https?:\/\/\S+$/i.test(value) && localCheckUrl(value);
140
+ }
141
+
142
+ function firstUrl(...values) {
143
+ for (const value of values) {
144
+ if (!value) continue;
145
+ for (const raw of String(value).match(URL_RE_GLOBAL) || []) {
146
+ const url = raw.replace(/[.,;:]+$/, '');
147
+ if (!localCheckUrl(url)) return url;
148
+ }
149
+ }
150
+ return '';
151
+ }
152
+
153
+ // The link a stranger can click. Read explicit link fields first, then any
154
+ // URL the landing text already mentions. Local-only hosts never count, and
155
+ // empty means the receipt has none.
156
+ function checkLink(receipt) {
157
+ const land = landing(receipt);
158
+ const res = result(receipt);
159
+ return firstUrl(
160
+ land.link, land.url, land.check_url, land.proof_url,
161
+ res.link, res.url, res.pr_url, res.share_url, res.tick?.pr_url, res.tick?.share_url,
162
+ land.checked, land.tested,
163
+ );
164
+ }
165
+
166
+ // What a reader can check: the link when there is one, else the plain
167
+ // check that passed. Never "verifier passed" or "receipt is present".
168
+ function checkText(receipt) {
169
+ const link = checkLink(receipt);
170
+ if (link) return link;
102
171
  const land = landing(receipt);
103
172
  const verifier = verifierResult(receipt);
104
- if (land.checked) return directProofText(land.checked);
105
- if (land.tested) return directProofText(land.tested);
173
+ if (land.checked && !localOnlyUrl(land.checked)) return directProofText(land.checked);
174
+ if (land.tested && !localOnlyUrl(land.tested)) return directProofText(land.tested);
106
175
  if (verifier) {
107
- const command = lowerLine(verifier.command || receipt?.verifier || 'configured verifier');
176
+ const command = lowerLine(verifier.command || receipt?.verifier || 'the checks');
108
177
  return `${command} ${verifier.passed ? 'passed' : 'failed'}`;
109
178
  }
110
- if (result(receipt).passed === true) return 'receipt says verifier passed';
111
- if (receipt?.at) return `receipt saved at ${lowerLine(receipt.at)}`;
112
- return 'receipt is present';
179
+ if (result(receipt).passed === true) return 'the checks passed';
180
+ return '';
181
+ }
182
+
183
+ // Dollars a mission actually brought in. Read from the landing (or result)
184
+ // so a member can write it next to what happened; the backend sums the same
185
+ // field into the member rating. Only a real number or a plain decimal string
186
+ // may count: Number() alone would read true as 1, [5] as 5, "0x10" as 16 and
187
+ // "1e3" as 1000. Junk and negatives count as zero.
188
+ const PLAIN_DECIMAL_RE = /^\d+(\.\d+)?$/;
189
+
190
+ function plainDecimalAmount(value) {
191
+ if (typeof value === 'number') return Number.isFinite(value) ? value : 0;
192
+ if (typeof value !== 'string') return 0;
193
+ const text = value.trim();
194
+ return PLAIN_DECIMAL_RE.test(text) ? Number(text) : 0;
195
+ }
196
+
197
+ function earnedUsd(receipt) {
198
+ const land = landing(receipt);
199
+ const res = result(receipt);
200
+ for (const value of [land.earned_usd, res.earned_usd, res.tick?.earned_usd]) {
201
+ if (value === undefined || value === null || value === '') continue;
202
+ const amount = plainDecimalAmount(value);
203
+ return amount > 0 ? Math.round(amount * 100) / 100 : 0;
204
+ }
205
+ return 0;
206
+ }
207
+
208
+ function usdText(amount) {
209
+ const whole = Number.isInteger(amount);
210
+ return `$${amount.toLocaleString('en-US', { minimumFractionDigits: whole ? 0 : 2, maximumFractionDigits: 2 })}`;
113
211
  }
114
212
 
115
213
  function nextText(receipt) {
@@ -117,43 +215,53 @@ function nextText(receipt) {
117
215
  return lowerLine(text);
118
216
  }
119
217
 
218
+ function whatText(receipt, max = 180) {
219
+ return historicalLandingText(trimPunctuation(changedText(receipt)), max);
220
+ }
221
+
222
+ // One line: "What happened. Check it: <link or check>."
223
+ function checkLine(receipt, max = 160) {
224
+ const check = trimPunctuation(checkText(receipt));
225
+ if (!check) return '';
226
+ // A link stays bare so it copies clean; a plain check reads as a sentence.
227
+ return firstUrl(check) ? `Check it: ${check}` : `Check it: ${truncateLine(check, max)}.`;
228
+ }
229
+
120
230
  function renderEmailLine(receipt) {
121
- const what = historicalLandingText(trimPunctuation(changedText(receipt)), 180);
122
- const scale = scaleText(receipt);
123
- const proof = historicalLandingText(trimPunctuation(proofText(receipt)), 90);
124
- return `${what}; ${scale}; we know because ${proof}.`;
231
+ const what = sentence(whatText(receipt));
232
+ const check = checkLine(receipt);
233
+ return check ? `${what} ${check}` : what;
125
234
  }
126
235
 
127
236
  function renderMorningCardRow(receipt) {
128
- return `- mission: ${renderEmailLine(receipt).replace(/\.$/, '')}`;
237
+ return `- ${renderEmailLine(receipt).replace(/\.$/, '')}`;
129
238
  }
130
239
 
240
+ // Three lines lead. The machinery (size, status, mission id) sits below as
241
+ // details, where a builder can find it and a customer never has to read it.
131
242
  function renderPageSection(receipt) {
132
243
  const next = nextText(receipt);
133
- const lines = [
134
- '## mission receipt',
135
- '',
136
- `- what: ${truncateLine(trimPunctuation(changedText(receipt)), 160)}`,
137
- `- how big: ${scaleText(receipt)}`,
138
- `- how we know: ${truncateLine(trimPunctuation(proofText(receipt)), 160)}`,
139
- `- status: ${statusText(receipt)}`,
140
- ];
141
- if (receipt?.mission_id) lines.push(`- mission: ${lowerLine(receipt.mission_id)}`);
142
- if (next) lines.push(`- next: ${truncateLine(trimPunctuation(next), 160)}`);
244
+ const check = checkLine(receipt);
245
+ const lines = ['## mission receipt', '', sentence(whatText(receipt, 160))];
246
+ if (check) lines.push(check);
247
+ if (next) lines.push(`Next: ${sentence(truncateLine(next, 160))}`);
248
+ const details = [scaleText(receipt), `status ${statusText(receipt)}`];
249
+ const earned = earnedUsd(receipt);
250
+ if (earned > 0) details.push(`earned ${usdText(earned)}`);
251
+ if (receipt?.mission_id) details.push(`mission ${lowerLine(receipt.mission_id)}`);
252
+ lines.push('', `details: ${details.join('; ')}`);
143
253
  return lines.join('\n');
144
254
  }
145
255
 
146
256
  function renderCard(receipt) {
147
- const status = statusText(receipt);
148
- const what = truncateLine(trimPunctuation(changedText(receipt)), 64);
149
- const scale = scaleText(receipt);
150
- const proof = truncateLine(trimPunctuation(proofText(receipt)), 88);
257
+ const what = sentence(whatText(receipt, 64));
258
+ const check = trimPunctuation(checkText(receipt));
151
259
  return {
152
260
  kind: 'statement',
153
261
  headline: what,
154
262
  text: what,
155
- kicker: `${status} mission receipt`,
156
- sub: `${scale}; ${proof}`,
263
+ kicker: 'mission receipt',
264
+ sub: check ? `Check it: ${truncateLine(check, 88)}` : '',
157
265
  brand: 'atris',
158
266
  size: 'og',
159
267
  theme: 'atris',
@@ -161,6 +269,7 @@ function renderCard(receipt) {
161
269
  }
162
270
 
163
271
  module.exports = {
272
+ earnedUsd,
164
273
  renderCard,
165
274
  renderPageSection,
166
275
  renderEmailLine,
@@ -8,7 +8,7 @@
8
8
  // so autonomous runs are reproducible, while keeping explicit per-run/env knobs
9
9
  // first in precedence. Precedence: explicit model -> ATRIS_RUNNER_MODEL env ->
10
10
  // ATRIS_RUNNER_PROFILE -> legacy ATRIS_CLAUDE_MODEL env -> pinned default.
11
- const DEFAULT_CLAUDE_RUNNER_MODEL = 'claude-opus-4-8';
11
+ const DEFAULT_CLAUDE_RUNNER_MODEL = 'claude-opus-5-5';
12
12
  const DEFAULT_FABLE_RUNNER_MODEL = 'claude-fable-5';
13
13
  const DEFAULT_CLAUDE_RUNNER_BIN = 'claude';
14
14
 
package/lib/scorecard.js CHANGED
@@ -245,14 +245,14 @@ function writeScorecard(atrisDir, data) {
245
245
 
246
246
  // Ensure scorecards.md exists
247
247
  if (!fs.existsSync(scorecardsPath)) {
248
- const template = `# scorecards.md \u2014 Endgame Results\n\n> Append-only. One line per closed endgame. Records outcome metrics from the horizon.\n\n---\n\n`;
248
+ const template = `# scorecards.md: Endgame Results\n\n> Append-only. One line per closed endgame. Records outcome metrics from the horizon.\n\n---\n\n`;
249
249
  fs.writeFileSync(scorecardsPath, template, 'utf8');
250
250
  }
251
251
 
252
- // Format: - **[date] slug**, shipped: X/Y, wall-clock: Nh, halt: Z%, reward: total, lessons: N
252
+ // Format: - **[date] slug** - shipped: X/Y - wall-clock: Nh - halt: Z% - reward: total - lessons: N
253
253
  const haltPercent = Math.round(haltRatio * 100);
254
254
  const wallClockStr = wallClockHours < 1 ? `${Math.round(wallClockHours * 60)}m` : `${wallClockHours.toFixed(1)}h`;
255
- const line = `- **[${endDate}] ${slug}** \u2014 shipped: ${tasksShipped}/${tasksAttempted} \u2014 wall-clock: ${wallClockStr} \u2014 halt: ${haltPercent}% \u2014 reward: ${totalReward} \u2014 lessons: ${lessonsGenerated}\n`;
255
+ const line = `- **[${endDate}] ${slug}** - shipped: ${tasksShipped}/${tasksAttempted} - wall-clock: ${wallClockStr} - halt: ${haltPercent}% - reward: ${totalReward} - lessons: ${lessonsGenerated}\n`;
256
256
 
257
257
  // Append to file
258
258
  fs.appendFileSync(scorecardsPath, line, 'utf8');
@@ -302,7 +302,7 @@ function readScorecards(atrisDir) {
302
302
  const scorecards = [];
303
303
 
304
304
  for (const line of content.split('\n')) {
305
- const match = line.match(/^- \*\*\[(.+?)\]\s+(.+?)\*\*\s*\u2014\s*shipped:\s*(\d+)\/(\d+)\s*\u2014\s*wall-clock:\s*(.+?)\s*\u2014\s*halt:\s*(\d+)%\s*\u2014\s*reward:\s*(-?\d+)\s*\u2014\s*lessons:\s*(\d+)$/);
305
+ const match = line.match(/^- \*\*\[(.+?)\]\s+(.+?)\*\*\s*[-\u2014]\s*shipped:\s*(\d+)\/(\d+)\s*[-\u2014]\s*wall-clock:\s*(.+?)\s*[-\u2014]\s*halt:\s*(\d+)%\s*[-\u2014]\s*reward:\s*(-?\d+)\s*[-\u2014]\s*lessons:\s*(\d+)$/);
306
306
  if (!match) continue;
307
307
 
308
308
  const [, endDate, slug, shipped, attempted, wallClockStr, haltPercent, reward, lessons] = match;
@@ -0,0 +1,11 @@
1
+ 'use strict';
2
+
3
+ // Block the current thread for ms milliseconds without burning a core.
4
+ // Atomics.wait parks the thread; a Date.now() loop keeps it at full CPU.
5
+ function sleepSync(ms) {
6
+ const duration = Math.max(0, Number(ms) || 0);
7
+ if (!duration) return;
8
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, duration);
9
+ }
10
+
11
+ module.exports = { sleepSync };
@@ -271,6 +271,32 @@ function getTodayInboxItems(workspaceDir) {
271
271
  /**
272
272
  * Detect workspace state: fresh install, has inbox items, in-progress work, or blocked
273
273
  */
274
+ // Blocked means a task whose status is failed: the DB has no separate
275
+ // blocked status, doneTask logs a failed task as a "blocked" event, and the
276
+ // board renders failed tasks under "## Blocked". Once this workspace has rows
277
+ // in the task DB, the DB is the only source. The DB file is shared by every
278
+ // workspace on the machine, so a workspace with no rows there falls back to
279
+ // the Blocked section of its own TODO.md, never a word elsewhere in a title.
280
+ function hasBlockedTasks(atrisDir, todoFile, legacyTaskContextFile) {
281
+ try {
282
+ const taskDb = require('./task-db');
283
+ if (fs.existsSync(taskDb.getDbPath())) {
284
+ const db = taskDb.open();
285
+ const workspaceRoot = taskDb.workspaceRoot(path.dirname(path.resolve(atrisDir)));
286
+ if (taskDb.listTasks(db, { workspaceRoot, limit: 1 }).length) {
287
+ return taskDb.listTasks(db, { workspaceRoot, status: 'failed', limit: 1 }).length > 0;
288
+ }
289
+ }
290
+ } catch (e) {
291
+ if (process.env.ATRIS_DEBUG) console.error('[state-detection] db blocked read failed:', e.message);
292
+ }
293
+ const taskFilePath = fs.existsSync(todoFile)
294
+ ? todoFile
295
+ : (fs.existsSync(legacyTaskContextFile) ? legacyTaskContextFile : null);
296
+ if (!taskFilePath) return false;
297
+ return getTasksFromTodoSection(fs.readFileSync(taskFilePath, 'utf8'), 'Blocked').length > 0;
298
+ }
299
+
274
300
  function detectWorkspaceState(workspaceDir) {
275
301
  const atrisDir = path.join(workspaceDir, 'atris');
276
302
  const featuresDir = path.join(atrisDir, 'features');
@@ -317,15 +343,8 @@ function detectWorkspaceState(workspaceDir) {
317
343
  }
318
344
  }
319
345
 
320
- // Check for blocked tasks (prefer TODO.md, fallback to legacy TASK_CONTEXTS.md)
321
- const taskFilePath = fs.existsSync(todoFile)
322
- ? todoFile
323
- : (fs.existsSync(legacyTaskContextFile) ? legacyTaskContextFile : null);
324
- if (taskFilePath) {
325
- const content = fs.readFileSync(taskFilePath, 'utf8');
326
- if (content.includes('Risk Level: High') || /\bblocked\b/i.test(content)) {
327
- return { state: 'blocked', reason: 'Tasks marked as blocked or high risk' };
328
- }
346
+ if (hasBlockedTasks(atrisDir, todoFile, legacyTaskContextFile)) {
347
+ return { state: 'blocked', reason: 'Tasks marked as blocked' };
329
348
  }
330
349
 
331
350
  // Default: setup complete but no active work
package/lib/task-db.js CHANGED
@@ -34,6 +34,7 @@ const { isDecisionTask } = require('./task-decision');
34
34
  const { parseVerifyCommand } = require('./auto-accept-certified');
35
35
  const { taskExplanation } = require('./task-explanation');
36
36
  const { treeHashFor } = require('./tree-hash');
37
+ const { sleepSync } = require('./sleep-sync');
37
38
 
38
39
  const DEFAULT_DB_PATH = path.join(os.homedir(), '.atris', 'tasks.db');
39
40
  const TASK_EPISODES_FILE = path.join('.atris', 'state', 'task_episodes.jsonl');
@@ -2224,8 +2225,7 @@ function withBusyRetry(fn, attempts = 8) {
2224
2225
  const busy = /SQLITE_BUSY|database is locked/i.test(msg) || code === 'SQLITE_BUSY' || code === 5;
2225
2226
  if (!busy) throw e;
2226
2227
  // Sleep synchronously, node:sqlite is sync; matches the rest of the API
2227
- const end = Date.now() + delay + Math.floor(Math.random() * delay);
2228
- while (Date.now() < end) {} // tight loop is fine, delay is small
2228
+ sleepSync(delay + Math.floor(Math.random() * delay));
2229
2229
  delay = Math.min(delay * 2, 500);
2230
2230
  }
2231
2231
  }
@@ -2234,6 +2234,7 @@ function withBusyRetry(fn, attempts = 8) {
2234
2234
 
2235
2235
  module.exports = {
2236
2236
  open,
2237
+ withBusyRetry,
2237
2238
  close,
2238
2239
  getDbPath,
2239
2240
  workspaceRoot,
@@ -34,7 +34,7 @@ function cleanTaskTitle(text) {
34
34
  const withoutTags = raw.replace(/\s*\[[\w-]+\]/g, '').trim();
35
35
  const bold = withoutTags.match(/^\*\*(.+?)\*\*\s*(?:[\u2014-]\s*)?(.*)$/);
36
36
  if (!bold) return withoutTags;
37
- return [bold[1], bold[2]].filter(Boolean).join(' \u2014 ').trim();
37
+ return [bold[1], bold[2]].filter(Boolean).join(' - ').replace(/\s*\u2014\s*/g, ' - ').trim();
38
38
  }
39
39
 
40
40
  function parseSection(content, sectionName) {
@@ -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);
@@ -4,11 +4,9 @@
4
4
  // Auth resolves the same way as the CLI: ATRIS_API_KEY, then
5
5
  // the logged-in atris token, then ~/.atris/design-api-key.
6
6
 
7
- import { realpathSync } from 'node:fs';
7
+ import { readFileSync, realpathSync } from 'node:fs';
8
+ import { createInterface } from 'node:readline';
8
9
  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
10
  import designApi from '../../lib/design-api.js';
13
11
 
14
12
  const { resolveDesignKey, designRequest, pollDesignJob, billingOf } = designApi;
@@ -141,34 +139,89 @@ export async function handleTool(name, args = {}, key) {
141
139
  return fail(`unknown tool: ${name}`);
142
140
  }
143
141
 
144
- export function createServer() {
145
- const server = new Server(
146
- { name: 'atris', version: '1.0.0' },
147
- { capabilities: { tools: {} } },
148
- );
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
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
- }
150
+ for await (const line of input) {
151
+ if (!line.trim()) continue;
152
+ let request;
157
153
  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}`);
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;
161
163
  }
162
- });
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;
163
170
 
164
- return server;
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);
165
220
  }
166
221
 
167
222
  // Auto-start only when run directly (`atris mcp`, `npx atris-mcp`,
168
223
  // node index.mjs); realpathSync resolves the npm bin symlink.
169
224
  const invokedPath = process.argv[1] ? realpathSync(process.argv[1]) : '';
170
225
  if (invokedPath && invokedPath === fileURLToPath(import.meta.url)) {
171
- const server = createServer();
172
- const transport = new StdioServerTransport();
173
- await server.connect(transport);
226
+ await serveStdio();
174
227
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.58.7",
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": {
@@ -101,8 +101,5 @@
101
101
  "bugs": {
102
102
  "url": "https://github.com/atrislabs/atris/issues"
103
103
  },
104
- "homepage": "https://github.com/atrislabs/atris#readme",
105
- "dependencies": {
106
- "@modelcontextprotocol/sdk": "^1.30.0"
107
- }
104
+ "homepage": "https://github.com/atrislabs/atris#readme"
108
105
  }
@@ -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
  {
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