atris 3.82.12 → 3.82.15

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.
@@ -0,0 +1,151 @@
1
+ 'use strict';
2
+
3
+ const { spawn } = require('node:child_process');
4
+ const { apiRequestJson } = require('../utils/api');
5
+ const { loadCredentials } = require('../utils/auth');
6
+ const { NOT_LOGGED_IN, wantsHelp, wantsJson, tokenFrom, oneLine, printResult } = require('../lib/developer-api');
7
+
8
+ const PAGE_URL = 'https://atris.ai/dashboard/growth';
9
+ const NOT_FOUNDER = 'the growth room is only for the Atris team. sign in with a team account: atris login';
10
+
11
+ function showGrowthHelp() {
12
+ console.log('usage: atris growth [--json | --open]');
13
+ console.log(' atris growth note "what you did" [--day YYYY-MM-DD]');
14
+ console.log('signups, people, paying, revenue, cli installs and countries, the same numbers as the growth page.');
15
+ console.log(' --open open the growth page in your browser');
16
+ console.log(' --json print the full numbers once as one json object');
17
+ console.log(' note save a dated note that shows on the growth charts');
18
+ }
19
+
20
+ const count = (value) => Number(value || 0).toLocaleString('en-US');
21
+
22
+ /** "up 50%" against the week before; empty when there is nothing to compare with. */
23
+ function change(now, before) {
24
+ const a = Number(now || 0);
25
+ const b = Number(before || 0);
26
+ if (!b) return '';
27
+ const percent = Math.round(((a - b) / b) * 100);
28
+ if (percent === 0) return ', flat';
29
+ return `, ${percent > 0 ? 'up' : 'down'} ${Math.abs(percent)}%`;
30
+ }
31
+
32
+ function shortDay(day) {
33
+ const date = new Date(`${day}T12:00:00Z`);
34
+ if (Number.isNaN(date.getTime())) return String(day || '');
35
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' });
36
+ }
37
+
38
+ /** The growth numbers as a few short lines. */
39
+ function growthText(data = {}) {
40
+ const totals = data.totals || {};
41
+ const lines = [
42
+ `signups ${count(totals.signups_today)} today, ${count(totals.signups_7d)} last 7 days${change(totals.signups_7d, totals.signups_prev_7d)}, ${count(totals.signups_30d)} last 30`,
43
+ `people ${count(totals.people)} total, ${count(totals.paying)} paying`,
44
+ `revenue $${count(Math.round(Number(totals.mrr_usd || 0)))} a month`,
45
+ ];
46
+ const cli = data.cli_installs;
47
+ if (cli) lines.push(`installs ${count(cli.last_7d)} last 7 days${change(cli.last_7d, cli.prev_7d)}`);
48
+
49
+ const countries = Array.isArray(data.countries) ? data.countries.slice(0, 5) : [];
50
+ lines.push(countries.length
51
+ ? `places ${countries.map((row) => `${row.country} ${count(row.people)}`).join(', ')}`
52
+ : 'places none recorded yet');
53
+
54
+ const marks = Array.isArray(data.marks) ? data.marks.slice(0, 5) : [];
55
+ if (marks.length) {
56
+ lines.push(`marked ${marks.map((mark) => (mark.people ? `${mark.label} ${count(mark.people)}` : mark.label)).join(', ')}`);
57
+ }
58
+ const notes = Array.isArray(data.notes) ? data.notes.slice(0, 3) : [];
59
+ notes.forEach((note, index) => {
60
+ lines.push(`${index === 0 ? 'notes ' : ' '} ${shortDay(note.day)} ${oneLine(note.text)}`);
61
+ });
62
+ lines.push(`page ${PAGE_URL}`);
63
+ return lines.join('\n');
64
+ }
65
+
66
+ function openPage(url) {
67
+ const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
68
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
69
+ try {
70
+ const child = spawn(opener, args, { stdio: 'ignore', detached: true });
71
+ child.on('error', () => {});
72
+ child.unref();
73
+ } catch {
74
+ // The address is printed either way.
75
+ }
76
+ }
77
+
78
+ /** Pull `note "text" [--day YYYY-MM-DD]` apart; null text means the note is missing. */
79
+ function readNote(args) {
80
+ const rest = args.slice(1).filter((arg) => arg !== '--json');
81
+ const at = rest.indexOf('--day');
82
+ let day;
83
+ if (at !== -1) {
84
+ day = rest[at + 1];
85
+ rest.splice(at, 2);
86
+ }
87
+ return { text: rest.join(' ').trim(), day };
88
+ }
89
+
90
+ function failure(result) {
91
+ const status = Number(result && result.status);
92
+ if (status === 401 || status === 403) return NOT_FOUNDER;
93
+ return oneLine(result && result.error);
94
+ }
95
+
96
+ async function growthCommand(args = [], deps = {}) {
97
+ if (wantsHelp(args)) {
98
+ showGrowthHelp();
99
+ return 0;
100
+ }
101
+ const json = wantsJson(args);
102
+ const io = { log: deps.log || console.log, err: deps.err || console.error };
103
+ const fail = (error, extra = {}) => {
104
+ printResult({ json, ok: false, error, payload: { ok: false, error, ...extra } }, io);
105
+ return 1;
106
+ };
107
+
108
+ if (args.includes('--open')) {
109
+ (deps.openPage || openPage)(PAGE_URL);
110
+ printResult({ json, ok: true, text: PAGE_URL, payload: { ok: true, url: PAGE_URL } }, io);
111
+ return 0;
112
+ }
113
+
114
+ const token = tokenFrom((deps.loadCredentials || loadCredentials)());
115
+ if (!token) return fail(NOT_LOGGED_IN);
116
+ const request = deps.apiRequestJson || apiRequestJson;
117
+
118
+ try {
119
+ if (args[0] === 'note') {
120
+ const { text, day } = readNote(args);
121
+ if (!text) return fail('say what you did: atris growth note "posted the launch video"');
122
+ if (text.length > 280) return fail('keep a note under 280 characters');
123
+ if (day !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(String(day))) return fail('write the day as YYYY-MM-DD');
124
+ const result = await request('/admin/growth/notes', {
125
+ method: 'POST',
126
+ token,
127
+ retries: 0, // a retry could save the same note twice
128
+ body: day ? { text, day } : { text },
129
+ });
130
+ if (!result || !result.ok) return fail(failure(result), { status: result && result.status });
131
+ const saved = result.data || {};
132
+ printResult({
133
+ json,
134
+ ok: true,
135
+ text: `noted for ${shortDay(saved.day || day)}: ${oneLine(saved.text || text)}`,
136
+ payload: { ok: true, note: saved },
137
+ }, io);
138
+ return 0;
139
+ }
140
+
141
+ const result = await request('/admin/growth/overview?days=28', { method: 'GET', token });
142
+ if (!result || !result.ok) return fail(failure(result), { status: result && result.status });
143
+ const data = result.data || {};
144
+ printResult({ json, ok: true, text: growthText(data), payload: { ok: true, ...data } }, io);
145
+ return 0;
146
+ } catch (error) {
147
+ return fail(oneLine(error && error.message));
148
+ }
149
+ }
150
+
151
+ module.exports = { growthCommand, growthText, showGrowthHelp };
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- // atris memory show, search, read, ls, remember, write, rm, usage, log, lesson
3
+ // atris memory show, search, ask, read, ls, remember, write, rm, usage, log, lesson
4
4
  // and agents: the person's Atris memory from any terminal, so any agent can
5
5
  // read it, search it and save to it. One request per command. The login and
6
6
  // network modules load only when a command needs the server.
@@ -13,6 +13,7 @@ const { NOT_LOGGED_IN, oneLine, tokenFrom } = require('../lib/developer-api');
13
13
  const MAX_WRITE_BYTES = 1000000;
14
14
  const STDIN_WAIT_MS = 5000;
15
15
  const REMEMBER_MAX_CHARS = 1000;
16
+ const ASK_MAX_CHARS = 500;
16
17
  const REMEMBER_FILES = ['journal', 'now', 'learning'];
17
18
  const LEARNING_SECTIONS = ['want', 'understand', 'shaky'];
18
19
 
@@ -138,6 +139,20 @@ const COMMANDS = {
138
139
  },
139
140
  },
140
141
 
142
+ ask: {
143
+ check(ctx) {
144
+ const question = ctx.words.join(' ').replace(/\s+/g, ' ').trim();
145
+ if (!question) throw new UsageError('ask a question, like atris memory ask "where does Sam work?"');
146
+ if (question.length > ASK_MAX_CHARS) throw new UsageError('keep the question under 500 characters');
147
+ return { question, folder: ctx.flags['--in'] || '' };
148
+ },
149
+ async run(ctx, { question, folder }) {
150
+ // The server reads for up to 75 seconds; asking twice would count twice.
151
+ const data = await ctx.call(`/memory/ask?q=${enc(question)}${folder ? `&folder=${enc(folder)}` : ''}`, { timeoutMs: 90000, retries: 0 });
152
+ return { data, lines: live.askLines(data) };
153
+ },
154
+ },
155
+
141
156
  read: {
142
157
  check(ctx) {
143
158
  const rel = onePath(ctx, 'file to read');
package/commands/push.js CHANGED
@@ -9,6 +9,17 @@ const { normalizeWikiOnlyPrefix } = require('../lib/wiki');
9
9
  const { emitSyncEvent, startTimer } = require('../lib/sync-telemetry');
10
10
  const { assertSafeWorkspaceRoot } = require('../lib/workspace-safety');
11
11
 
12
+ // The server's own words for a refused request. It used to be dropped, so a
13
+ // refusal for any reason read as "you can only push to your team/ folder".
14
+ function refusalText(result) {
15
+ if (!result) return '';
16
+ const raw = result.errorMessage || result.error || (result.data && result.data.detail) || '';
17
+ if (!raw) return '';
18
+ if (typeof raw === 'string') return raw === 'Request failed' ? '' : raw;
19
+ if (typeof raw === 'object' && typeof raw.message === 'string') return raw.message;
20
+ try { return JSON.stringify(raw); } catch { return String(raw); }
21
+ }
22
+
12
23
  function resolvePushSourceDir({ slug, argv = process.argv, cwd = process.cwd() }) {
13
24
  const fromIdx = argv.indexOf('--from');
14
25
  if (fromIdx !== -1 && argv[fromIdx + 1]) {
@@ -858,13 +869,15 @@ async function pushAtris() {
858
869
  pushed = landedPaths.size;
859
870
  break;
860
871
  } else {
861
- console.error(`\n Push failed: ${retry.errorMessage || retry.error || retry.status}`);
872
+ console.error(`\n Push failed: ${refusalText(retry) || retry.status}`);
873
+ if (refusalText(result)) console.error(` The first refusal said: ${refusalText(result)}`);
862
874
  await emit('access_denied', { error_detail: `403 retry failed: ${retry.status}` });
863
875
  process.exit(1);
864
876
  }
865
877
  } else {
866
- console.error('\n Access denied: you can only push to your team/ folder.');
867
- await emit('access_denied');
878
+ const reason = refusalText(result);
879
+ console.error(`\n Access denied: ${reason || 'the server refused this push and gave no reason.'}`);
880
+ await emit('access_denied', reason ? { error_detail: reason } : undefined);
868
881
  process.exit(1);
869
882
  }
870
883
  } else if (result.status === 409) {
@@ -1052,6 +1065,7 @@ async function pushAtris() {
1052
1065
  }
1053
1066
 
1054
1067
  module.exports = {
1068
+ refusalText,
1055
1069
  pushAtris,
1056
1070
  buildPushChangePlan,
1057
1071
  buildPushUploadBatches,
@@ -715,12 +715,12 @@ function runVerifyCommandCached(verify, workspaceRoot, cache = null) {
715
715
  // root: one scan covers every task in a sweep.
716
716
  const repoHygieneCache = new Map();
717
717
 
718
- function repoHygieneGate(workspaceRoot) {
718
+ function repoHygieneGate(workspaceRoot, skillScope = null) {
719
719
  const root = path.resolve(workspaceRoot || process.cwd());
720
720
  // Skill receipts are mutable workspace state, so recompute this gate before
721
721
  // consulting the repo-wide dead-code cache. A new receipt can unblock a
722
722
  // landing, while a later SKILL.md edit must make an older receipt stale.
723
- const skillEval = skillEvalGate(root);
723
+ const skillEval = skillEvalGate(root, skillScope || {});
724
724
  if (!skillEval.ok) return skillEval;
725
725
  if (repoHygieneCache.has(root)) return repoHygieneCache.get(root);
726
726
  let result = { ok: true, skipped: true };
@@ -755,8 +755,17 @@ function repoHygieneGate(workspaceRoot) {
755
755
 
756
756
  // Shared by both landing lanes so the check cannot ship in one dispatch branch
757
757
  // and silently skip the sibling (lesson: parallel-paths-drift).
758
- function hygieneBlockResult(task, ref) {
759
- const hygiene = repoHygieneGate(task.workspace_root || process.cwd());
758
+ // A job answers for the skills it changed. When its exact file list is on
759
+ // record (the same list the taste check just read), only those files count,
760
+ // read where the job's branch is checked out. With no list on record the
761
+ // workspace-wide check stays, so an unevaluated skill is never waved through.
762
+ function jobSkillScope(task, candidateGate) {
763
+ if (!candidateGate || candidateGate.advisory_only || !Array.isArray(candidateGate.paths)) return null;
764
+ return { changeRoot: candidateWorkspaceRoot(task), paths: candidateGate.paths };
765
+ }
766
+
767
+ function hygieneBlockResult(task, ref, candidateGate = null) {
768
+ const hygiene = repoHygieneGate(task.workspace_root || process.cwd(), jobSkillScope(task, candidateGate));
760
769
  if (hygiene.ok) return null;
761
770
  return {
762
771
  eligible: false,
@@ -1360,7 +1369,7 @@ function evaluateAutoAccept(task, options = {}) {
1360
1369
  if (executeVerify) {
1361
1370
  candidateGate = candidatePolicyGate(task, { verifyCache, executeDetectors: true });
1362
1371
  if (!candidateGate.ok) return { ...candidateGate, ref };
1363
- const hygieneBlock = hygieneBlockResult(task, ref);
1372
+ const hygieneBlock = hygieneBlockResult(task, ref, candidateGate.gate);
1364
1373
  if (hygieneBlock) return hygieneBlock;
1365
1374
  }
1366
1375
  return {
@@ -1444,7 +1453,7 @@ function evaluateAutoAccept(task, options = {}) {
1444
1453
  if (executeVerify) {
1445
1454
  candidateGate = candidatePolicyGate(task, { verifyCache, executeDetectors: true });
1446
1455
  if (!candidateGate.ok) return { ...candidateGate, ref };
1447
- const hygieneBlock = hygieneBlockResult(task, ref);
1456
+ const hygieneBlock = hygieneBlockResult(task, ref, candidateGate.gate);
1448
1457
  if (hygieneBlock) return hygieneBlock;
1449
1458
  }
1450
1459
 
@@ -3,7 +3,7 @@
3
3
  const knownCommands = ['init', 'guide', 'doctor', 'doc-health', 'install', 'workspace', 'log', 'logs', 'later', 'idea', 'done', 'wish', 'ask', 'approve', 'stop', 'ready', 'check', 'drill', 'dream', 'now', '99', 'goal', 'wtf', 'founder', 'orb', 'radar', 'who', 'stream', 'ctop', 'launchpad', 'status', 'analytics', 'revisions', 'visualize', 'brain', 'brainstorm', 'autopilot', 'run', 'plan', 'do', 'review', 'release',
4
4
  'activate', '_activate', 'agent', 'team', 'chat', 'fast', 'ax', 'console', 'serve', 'login', 'logout', 'whoami', 'switch', 'use', 'accounts', '_resolve', '_profile-email', '_switch-session', 'shell-init', 'update', 'upgrade', 'version', 'help', 'next', 'atris',
5
5
  'clean', 'close', 'harvest', 'verify', 'recover', 'search', 'scout', 'skill', 'member', 'codex-goal', 'app', 'apps', 'learn', 'lesson', 'taste', 'teach', 'plugin', 'experiments', 'bench', 'router', 'tree', 'receipt', 'proof', 'openclaw', 'pull', 'push', 'watch', 'cloud', 'live', 'align', 'terminal', 'computer', 'diff', 'business', 'sync', 'youtube', 'x-search',
6
- 'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'decide', 'campaign', 'agents', 'probe', 'worktree', 'land', 'caretaker', 'autoland', 'drive', 'aeo', 'slop', 'voice', 'strings', 'write', 'security-review', 'secure', 'sentinel', 'deck', 'site', 'theme', 'card', 'reel', 'improve', 'study', 'rainmaker', 'xp', 'play', 'gm', 'game', 'x', 'recap', 'report', 'signup', 'clarity', 'interview', 'meet', 'moves', 'unknowns', 'avail', 'scoreboard', 'scorecard', 'rating', 'sync-checkout', 'zen',
6
+ 'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'decide', 'campaign', 'agents', 'probe', 'worktree', 'land', 'caretaker', 'autoland', 'drive', 'aeo', 'slop', 'voice', 'strings', 'write', 'security-review', 'secure', 'sentinel', 'deck', 'site', 'theme', 'card', 'reel', 'improve', 'study', 'rainmaker', 'xp', 'play', 'gm', 'game', 'x', 'recap', 'report', 'signup', 'clarity', 'interview', 'meet', 'moves', 'unknowns', 'avail', 'scoreboard', 'growth', 'scorecard', 'rating', 'sync-checkout', 'zen',
7
7
  'ci', 'github', 'vercel', 'supabase', 'linear', 'stripe', 'memory', 'balance', 'usage', 'spend', 'api-key', 'agent-client', 'topup', 'buy', 'transactions', 'hire', 'hires', 'mail', 'design', 'mcp', 'gmail', 'google', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
8
8
  'fork', 'browse', 'publish', 'pack', 'sleep', 'wake', 'feedback', 'errors', 'wiki', 'code-review', 'cr', 'soul', 'fleet', 'fleet-report', 'loops', 'self-improve', 'compile', 'spaceship', 'truth', 'sign', 'engine', 'engines', 'link', 'playbook', 'feed', 'brief', 'social', 'host', 'people', 'follow', 'unfollow', 'friends', 'msg', 'inbox', 'invite', 'join', 'rsi', 'pay', 'trust'];
9
9
 
@@ -9,7 +9,7 @@ const fs = require('fs');
9
9
  const path = require('path');
10
10
 
11
11
  const LIVE_COMMANDS = new Set([
12
- 'show', 'search', 'read', 'ls', 'remember', 'write', 'rm', 'usage', 'log', 'lesson', 'agents', 'setup',
12
+ 'show', 'search', 'ask', 'read', 'ls', 'remember', 'write', 'rm', 'usage', 'log', 'lesson', 'agents', 'setup',
13
13
  ]);
14
14
 
15
15
  function isLiveCommand(word) {
@@ -19,10 +19,11 @@ function isLiveCommand(word) {
19
19
  // One line per command, shown together by `atris memory` and one at a time by
20
20
  // `atris memory <command> --help`.
21
21
  const COLUMN = 38;
22
- const row = (form, ...what) => [` ${form}`.padEnd(COLUMN) + what[0], ...what.slice(1).map((more) => ' '.repeat(COLUMN) + more)].join('\n');
22
+ const row = (form, ...what) => [` ${form} `.padEnd(COLUMN) + what[0], ...what.slice(1).map((more) => ' '.repeat(COLUMN) + more)].join('\n');
23
23
  const USAGE_LINES = {
24
24
  show: row('show', 'who they are and what matters now; read it before helping'),
25
25
  search: row('search "rent|lease" [--in folder]', 'lines with any of the words, split by |'),
26
+ ask: row('ask "a question" [--in folder]', 'answered from their memory, with the lines it rests on'),
26
27
  read: row('read <path> [--from N] [--lines N]', 'numbered lines of one file; --raw prints it as stored'),
27
28
  ls: row('ls [folder]', 'what is in a folder; MAP.md says what is where'),
28
29
  remember: row('remember "one short line"', 'a lasting fact or preference about the person, when they',
@@ -43,6 +44,7 @@ const LIVE_HELP = [
43
44
  'read',
44
45
  USAGE_LINES.show,
45
46
  USAGE_LINES.search,
47
+ USAGE_LINES.ask,
46
48
  USAGE_LINES.read,
47
49
  USAGE_LINES.ls,
48
50
  '',
@@ -62,6 +64,7 @@ const LIVE_HELP = [
62
64
  // Flags that take a value, per command. --json, --raw and --help take none.
63
65
  const VALUE_FLAGS = {
64
66
  search: ['--in'],
67
+ ask: ['--in'],
65
68
  read: ['--from', '--lines'],
66
69
  ls: [],
67
70
  show: [],
@@ -205,6 +208,26 @@ function searchLines(query, data) {
205
208
  return lines;
206
209
  }
207
210
 
211
+ // The answer first, then each line it rests on, then how sure it is. With no
212
+ // answer (the model was busy) the search hits it started from are shown.
213
+ function askLines(data) {
214
+ const sources = (data && data.sources) || [];
215
+ const cite = sources.map((s) => ` ${s.path}:${s.n}: ${s.text}`);
216
+ if (data && data.answer && data.found) {
217
+ return [String(data.answer), ...(cite.length ? ['', 'From:', ...cite] : [])];
218
+ }
219
+ if (data && data.answer) {
220
+ const lines = [String(data.answer)];
221
+ if (cite.length) lines.push('', 'Closest lines:', ...cite);
222
+ lines.push('', 'Not found for certain. Search it yourself: atris memory search "word|other word"');
223
+ return lines;
224
+ }
225
+ const hits = (data && data.search && data.search.hits) || [];
226
+ const lines = [String((data && data.note) || 'No answer right now.')];
227
+ if (hits.length) lines.push('', 'Lines that may hold it:', ...hits.slice(0, 8).map((h) => ` ${h.path}:${h.n}: ${h.text}`));
228
+ return lines;
229
+ }
230
+
208
231
  function readLines(data) {
209
232
  const page = (data && data.lines) || [];
210
233
  const total = Number(data && data.total) || 0;
@@ -244,7 +267,7 @@ function rulesBlock(agent) {
244
267
  '',
245
268
  'This person keeps one memory in Atris that every app and agent shares. Use it.',
246
269
  '- Start each task with `atris memory show`: who they are and what matters now.',
247
- '- Before you ask them anything they may have said before, search: `atris memory search "word|other word"`.',
270
+ '- Before you ask them anything they may have said before, search: `atris memory search "word|other word"`, or ask in plain words: `atris memory ask "..."`.',
248
271
  '- Open what you find with `atris memory read <path>`. `atris memory ls` shows the folder.',
249
272
  '- When they say remember, note, save or don\'t forget, save one short line: `atris memory remember "..."`.',
250
273
  '- Save only lasting facts about them, never logs or your own work. Never rewrite their about.md.',
@@ -338,6 +361,7 @@ module.exports = {
338
361
  END,
339
362
  LIVE_HELP,
340
363
  USAGE_LINES,
364
+ askLines,
341
365
  isLiveCommand,
342
366
  lsLines,
343
367
  mcpLines,
@@ -187,12 +187,47 @@ function validateSkillEvalReceipt(receipt, skill) {
187
187
  return { ok: true, receipt_at_ms: receiptAtMs };
188
188
  }
189
189
 
190
- function skillEvalGate(workspaceRoot = process.cwd()) {
190
+ // The learner writes these notes itself after verified runs (see
191
+ // writeAutoLearnedSkill in lib/mission-core.js). No job authors them, so they
192
+ // never count against a job whose own file list is unknown.
193
+ const LEARNER_NOTE_PATTERN = /(^|\/)skills\/auto-learned\/SKILL\.md$/;
194
+
195
+ function normalizedScopePaths(paths) {
196
+ if (!Array.isArray(paths)) return null;
197
+ return paths
198
+ .map((entry) => String(entry || '').trim().replace(/\\/g, '/').replace(/\/+$/, ''))
199
+ .filter(Boolean);
200
+ }
201
+
202
+ // A job answers for the skills it changed, not for every skill file that
203
+ // happens to sit changed in the workspace. Eight learner notes nobody had
204
+ // evaluated held seven finished jobs for five days (2026-11-04 to 11-09)
205
+ // while none of those jobs touched a skill.
206
+ // scope.paths the job's exact file list, when it is on record. Only
207
+ // skills inside it count, learner notes included.
208
+ // scope.changeRoot where the job's branch is checked out, when that is not
209
+ // the workspace itself.
210
+ // With no file list every changed skill still counts, except learner notes.
211
+ function skillsInScope(root, scope = {}) {
212
+ const changeRoot = path.resolve(scope.changeRoot || root);
213
+ const jobPaths = normalizedScopePaths(scope.paths);
214
+ return changedSkillFiles(changeRoot).filter((skill) => {
215
+ if (!jobPaths) return !LEARNER_NOTE_PATTERN.test(skill.path);
216
+ return jobPaths.some((entry) => skill.path === entry || skill.path.startsWith(`${entry}/`));
217
+ });
218
+ }
219
+
220
+ function skillEvalGate(workspaceRoot = process.cwd(), scope = {}) {
191
221
  const root = path.resolve(workspaceRoot);
192
- const skills = changedSkillFiles(root);
222
+ const changeRoot = path.resolve((scope && scope.changeRoot) || root);
223
+ const skills = skillsInScope(root, scope || {});
193
224
  if (!skills.length) return { ok: true, skipped: true, skills: [] };
194
225
 
195
- const receipts = readSkillEvalReceipts(root);
226
+ // Evaluations are saved in the workspace; one run from the branch's own
227
+ // checkout is saved there instead. Either proves the skill was judged.
228
+ const receipts = changeRoot === root
229
+ ? readSkillEvalReceipts(root)
230
+ : [...readSkillEvalReceipts(root), ...readSkillEvalReceipts(changeRoot)];
196
231
  const missing = [];
197
232
  const accepted = [];
198
233
  for (const skill of skills) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.82.12",
3
+ "version": "3.82.15",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof. project memory your coding agents share across sessions.",
5
5
  "mcpName": "io.github.atrislabs/atris",
6
6
  "main": "bin/atris.js",