atris 3.15.13 → 3.15.22

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 (93) hide show
  1. package/AGENTS.md +84 -8
  2. package/README.md +5 -1
  3. package/atris/AGENTS.md +46 -1
  4. package/atris/CLAUDE.md +36 -1
  5. package/atris/GEMINI.md +14 -1
  6. package/atris/atris.md +12 -1
  7. package/atris/atrisDev.md +3 -2
  8. package/atris/context/README.md +11 -0
  9. package/atris/features/company-brain-sync/validate.md +5 -5
  10. package/atris/learnings.jsonl +1 -0
  11. package/atris/policies/atris-design.md +2 -0
  12. package/atris/skills/aeo/SKILL.md +2 -2
  13. package/atris/skills/atris/SKILL.md +15 -62
  14. package/atris/skills/design/SKILL.md +2 -0
  15. package/atris/skills/imessage/SKILL.md +19 -2
  16. package/atris/skills/loop/SKILL.md +6 -5
  17. package/atris/skills/magic-inbox/SKILL.md +1 -1
  18. package/atris/team/_template/MEMBER.md +23 -1
  19. package/atris/team/brainstormer/START_HERE.md +6 -0
  20. package/atris/team/executor/MEMBER.md +13 -0
  21. package/atris/team/executor/START_HERE.md +6 -0
  22. package/atris/team/launcher/START_HERE.md +6 -0
  23. package/atris/team/mission-lead/MEMBER.md +39 -0
  24. package/atris/team/mission-lead/MISSION.md +33 -0
  25. package/atris/team/mission-lead/START_HERE.md +6 -0
  26. package/atris/team/navigator/MEMBER.md +11 -0
  27. package/atris/team/navigator/START_HERE.md +6 -0
  28. package/atris/team/opus-overnight/MEMBER.md +39 -0
  29. package/atris/team/opus-overnight/MISSION.md +61 -0
  30. package/atris/team/opus-overnight/START_HERE.md +6 -0
  31. package/atris/team/opus-overnight/STEERING.md +35 -0
  32. package/atris/team/researcher/START_HERE.md +6 -0
  33. package/atris/team/validator/MEMBER.md +26 -6
  34. package/atris/team/validator/START_HERE.md +6 -0
  35. package/atris/wiki/concepts/agent-activation-contract.md +79 -0
  36. package/atris/wiki/concepts/workspace-initialization-contract.md +73 -0
  37. package/atris/wiki/index.md +27 -0
  38. package/atris/wiki/sources/atris-labs-2026-05-10.txt +17 -0
  39. package/atris/wiki/sources/atris-labs-goals-2026-05-10.txt +15 -0
  40. package/atris/wiki/sources/atrisos-generative-ui-product-surface-2026-05-10.txt +10 -0
  41. package/atris/wiki/sources/jack-dorsey-2026-05-10.txt +12 -0
  42. package/atris.md +49 -13
  43. package/bin/atris.js +660 -22
  44. package/commands/activate.js +12 -3
  45. package/commands/aeo.js +1 -1
  46. package/commands/align.js +10 -10
  47. package/commands/analytics.js +9 -4
  48. package/commands/app.js +2 -0
  49. package/commands/apps.js +276 -0
  50. package/commands/auth.js +1 -1
  51. package/commands/autopilot.js +74 -5
  52. package/commands/brain.js +536 -61
  53. package/commands/brainstorm.js +12 -12
  54. package/commands/business-sync.js +142 -24
  55. package/commands/clean.js +9 -6
  56. package/commands/codex-goal.js +311 -0
  57. package/commands/errors.js +11 -1
  58. package/commands/feedback.js +55 -17
  59. package/commands/fork.js +2 -2
  60. package/commands/gm.js +376 -0
  61. package/commands/init.js +80 -3
  62. package/commands/integrations.js +524 -0
  63. package/commands/learn.js +25 -16
  64. package/commands/lesson.js +41 -0
  65. package/commands/lifecycle.js +2 -2
  66. package/commands/member.js +2416 -9
  67. package/commands/mission.js +1776 -0
  68. package/commands/now.js +48 -7
  69. package/commands/play.js +425 -0
  70. package/commands/publish.js +2 -1
  71. package/commands/pull.js +72 -29
  72. package/commands/push.js +199 -17
  73. package/commands/review.js +51 -13
  74. package/commands/skill.js +2 -2
  75. package/commands/soul.js +19 -13
  76. package/commands/status.js +6 -1
  77. package/commands/sync.js +5 -4
  78. package/commands/task.js +1041 -147
  79. package/commands/terminal.js +5 -5
  80. package/commands/verify.js +7 -5
  81. package/commands/visualize.js +7 -0
  82. package/commands/wiki.js +53 -16
  83. package/commands/workflow.js +298 -54
  84. package/commands/workspace-clean.js +1 -1
  85. package/commands/worktree.js +468 -0
  86. package/commands/xp.js +1608 -0
  87. package/lib/manifest.js +34 -4
  88. package/lib/scorecard.js +3 -2
  89. package/lib/task-db.js +408 -27
  90. package/lib/todo-fallback.js +28 -2
  91. package/lib/todo.js +5 -3
  92. package/package.json +23 -2
  93. package/utils/update-check.js +51 -1
package/commands/xp.js ADDED
@@ -0,0 +1,1608 @@
1
+ const { ensureValidCredentials } = require('../utils/auth');
2
+ const { apiRequestJson } = require('../utils/api');
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const crypto = require('crypto');
7
+ const { spawnSync } = require('child_process');
8
+
9
+ const DEFAULT_GRAPH_DAYS = 365;
10
+ const INTENSITY_CHARS = [' ', '.', ':', '*', '#'];
11
+ const ROW_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
12
+ const TASK_EPISODES_FILE = path.join('.atris', 'state', 'task_episodes.jsonl');
13
+ const CAREER_XP_RECEIPTS_FILE = path.join('.atris', 'state', 'career_xp_receipts.jsonl');
14
+ const CAREER_XP_PROJECTION_FILE = path.join('.atris', 'state', 'career_xp.projection.json');
15
+ const CAREER_XP_CURSOR_FILE = path.join('.atris', 'state', 'career_xp.cursor.json');
16
+ const CAREER_XP_SESSIONS_DIR = path.join('.atris', 'state', 'career_xp_sessions');
17
+ const TASK_PROJECTION_FILE = path.join('.atris', 'state', 'tasks.projection.json');
18
+ const CODEX_STATE_FILE = path.join(os.homedir(), '.codex', 'state_5.sqlite');
19
+ const AGENT_XP_LABEL = 'AgentXP';
20
+ const LEVEL_XP = 1000;
21
+ const RECEIPT_CHAIN_VERSION = 'atris.career_xp_receipt_chain.v1';
22
+ const XP_STATE_FILES = new Set([
23
+ path.basename(TASK_EPISODES_FILE),
24
+ path.basename(CAREER_XP_RECEIPTS_FILE),
25
+ path.basename(CAREER_XP_PROJECTION_FILE),
26
+ ]);
27
+ const SEARCH_EXCLUDED_DIRS = new Set([
28
+ '.git',
29
+ 'node_modules',
30
+ 'dist',
31
+ 'build',
32
+ '.next',
33
+ 'coverage',
34
+ 'tmp',
35
+ 'temp',
36
+ ]);
37
+ const DEFAULT_SEARCH_DEPTH = 6;
38
+
39
+ function showHelp() {
40
+ console.log('Usage: atris xp [card|status|collect|session] [--json] [--workspace <path>] [--all] [--root <path>]');
41
+ console.log(' atris xp session [--since today|tonight|YYYY-MM-DD] [--until <time>] [--mission <text>] [--thread <id>] [--no-write]');
42
+ console.log(' atris xp [--json] [--local] [--workspace <path>] [--operator <name>]');
43
+ console.log('');
44
+ console.log('Show your AgentXP graph for the active Atris account.');
45
+ console.log('Use status to show account-level AgentXP across verified local ledgers.');
46
+ console.log('Use collect or status --local to project accepted task proof in the current workspace.');
47
+ console.log('Use session to encapsulate the current work window into a local XP capsule.');
48
+ console.log('Use status --all to explicitly aggregate verified local XP ledgers across workspaces.');
49
+ console.log('Use --local to render from proof receipts in the current workspace.');
50
+ }
51
+
52
+ function asNumber(value, fallback = 0) {
53
+ const parsed = Number(value);
54
+ return Number.isFinite(parsed) ? parsed : fallback;
55
+ }
56
+
57
+ function formatNumber(value) {
58
+ return asNumber(value).toLocaleString('en-US');
59
+ }
60
+
61
+ function buildContributionRows(days) {
62
+ const normalized = Array.isArray(days) ? days : [];
63
+ if (normalized.length === 0) {
64
+ return ROW_LABELS.map(label => `${label} `);
65
+ }
66
+
67
+ const first = new Date(`${normalized[0].date}T00:00:00Z`);
68
+ const pad = Number.isFinite(first.getTime()) ? first.getUTCDay() : 0;
69
+ const padded = [
70
+ ...Array.from({ length: pad }, () => null),
71
+ ...normalized,
72
+ ];
73
+ while (padded.length % 7 !== 0) padded.push(null);
74
+
75
+ return ROW_LABELS.map((label, rowIndex) => {
76
+ let line = `${label} `;
77
+ for (let index = rowIndex; index < padded.length; index += 7) {
78
+ const day = padded[index];
79
+ const intensity = Math.max(0, Math.min(4, asNumber(day?.intensity)));
80
+ line += INTENSITY_CHARS[intensity];
81
+ }
82
+ return line;
83
+ });
84
+ }
85
+
86
+ function dateWindow(windowDays = DEFAULT_GRAPH_DAYS) {
87
+ const count = Math.max(1, asNumber(windowDays, DEFAULT_GRAPH_DAYS));
88
+ const end = new Date();
89
+ const dates = [];
90
+ for (let offset = count - 1; offset >= 0; offset -= 1) {
91
+ const date = new Date(end);
92
+ date.setDate(end.getDate() - offset);
93
+ const key = localDateKey(date);
94
+ if (key && dates[dates.length - 1] !== key) dates.push(key);
95
+ }
96
+ return dates;
97
+ }
98
+
99
+ function graphIntensity(xp, maxDailyXp) {
100
+ const total = asNumber(xp);
101
+ if (total <= 0) return 0;
102
+ const max = Math.max(1, asNumber(maxDailyXp, 1));
103
+ if (max <= 1) return 1;
104
+ const ratio = total / max;
105
+ if (ratio >= 0.75) return 4;
106
+ if (ratio >= 0.5) return 3;
107
+ if (ratio >= 0.25) return 2;
108
+ return 1;
109
+ }
110
+
111
+ function graphFromDailyTotals(dailyTotals, windowDays = DEFAULT_GRAPH_DAYS) {
112
+ const totals = dailyTotals instanceof Map
113
+ ? dailyTotals
114
+ : new Map(Object.entries(dailyTotals || {}));
115
+ const dates = dateWindow(windowDays);
116
+ const maxDailyXp = dates.reduce((max, date) => Math.max(max, asNumber(totals.get(date))), 0);
117
+ const days = dates.map((date) => {
118
+ const xp = asNumber(totals.get(date));
119
+ return {
120
+ date,
121
+ xp,
122
+ total_xp: xp,
123
+ intensity: graphIntensity(xp, maxDailyXp),
124
+ };
125
+ });
126
+ return {
127
+ schema: 'atris.agent_xp_contribution_graph.v1',
128
+ metric_label: AGENT_XP_LABEL,
129
+ window_days: days.length,
130
+ total_xp: days.reduce((sum, day) => sum + day.xp, 0),
131
+ active_days: days.filter(day => day.xp > 0).length,
132
+ first_date: days[0]?.date || null,
133
+ last_date: days[days.length - 1]?.date || null,
134
+ days,
135
+ };
136
+ }
137
+
138
+ function buildAgentXpContributionGraph(receipts, windowDays = DEFAULT_GRAPH_DAYS) {
139
+ const totals = new Map();
140
+ for (const receipt of receipts || []) {
141
+ if (!receipt || receipt.outcome !== 'accepted') continue;
142
+ const xp = asNumber(receipt.xp);
143
+ if (xp <= 0) continue;
144
+ const date = localDateKey(receipt.accepted_at);
145
+ if (!date) continue;
146
+ totals.set(date, asNumber(totals.get(date)) + xp);
147
+ }
148
+ return graphFromDailyTotals(totals, windowDays);
149
+ }
150
+
151
+ function combineAgentXpContributionGraphs(graphs, windowDays = DEFAULT_GRAPH_DAYS) {
152
+ const totals = new Map();
153
+ for (const graph of graphs || []) {
154
+ for (const day of graph?.days || []) {
155
+ const date = day?.date;
156
+ if (!date) continue;
157
+ const xp = asNumber(day.xp ?? day.total_xp);
158
+ if (xp <= 0) continue;
159
+ totals.set(date, asNumber(totals.get(date)) + xp);
160
+ }
161
+ }
162
+ return graphFromDailyTotals(totals, windowDays);
163
+ }
164
+
165
+ function currentForm(payload) {
166
+ const arenas = payload.current_form_by_arena || {};
167
+ const local = arenas.local_workspace || {};
168
+ const ovr = asNumber(local.ovr || local.current_form);
169
+ if (!ovr) return null;
170
+ return {
171
+ ovr,
172
+ visibleStats: Array.isArray(local.visible_stats) ? local.visible_stats : [],
173
+ leaderboardEligible: Boolean(local.leaderboard_eligible),
174
+ integrityStatus: local.integrity_status || 'unknown',
175
+ };
176
+ }
177
+
178
+ function readFlag(args, name, fallback = null) {
179
+ const inline = args.find(arg => arg.startsWith(`${name}=`));
180
+ if (inline) {
181
+ return inline.slice(name.length + 1);
182
+ }
183
+ const index = args.indexOf(name);
184
+ if (index >= 0 && args[index + 1] && !args[index + 1].startsWith('--')) {
185
+ return args[index + 1];
186
+ }
187
+ return fallback;
188
+ }
189
+
190
+ function readFlagValues(args, names) {
191
+ const wanted = Array.isArray(names) ? names : [names];
192
+ const values = [];
193
+ for (let index = 0; index < args.length; index += 1) {
194
+ const arg = args[index];
195
+ for (const name of wanted) {
196
+ if (arg === name && args[index + 1] && !args[index + 1].startsWith('--')) {
197
+ values.push(args[index + 1]);
198
+ } else if (arg.startsWith(`${name}=`)) {
199
+ values.push(arg.slice(name.length + 1));
200
+ }
201
+ }
202
+ }
203
+ return values.filter(Boolean);
204
+ }
205
+
206
+ function hasFlag(args, name) {
207
+ return args.includes(name) || args.some(arg => arg.startsWith(`${name}=`));
208
+ }
209
+
210
+ function levelFromXp(careerXp) {
211
+ return Math.max(1, Math.floor(asNumber(careerXp) / LEVEL_XP) + 1);
212
+ }
213
+
214
+ function localDateKey(value, timeZone = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC') {
215
+ const date = value instanceof Date ? value : new Date(value);
216
+ if (!Number.isFinite(date.getTime())) return null;
217
+ try {
218
+ return new Intl.DateTimeFormat('en-CA', {
219
+ timeZone,
220
+ year: 'numeric',
221
+ month: '2-digit',
222
+ day: '2-digit',
223
+ }).format(date);
224
+ } catch (_) {
225
+ return date.toISOString().slice(0, 10);
226
+ }
227
+ }
228
+
229
+ function parseJsonlContent(content, options = {}) {
230
+ const allowPartialTail = Boolean(options.allowPartialTail);
231
+ const rows = [];
232
+ const hasTerminalNewline = /\r?\n$/.test(content);
233
+ const lines = content.split('\n');
234
+ let parsedBytes = 0;
235
+ let skippedPartialTail = false;
236
+ let skippedPartialTailBytes = 0;
237
+
238
+ for (let index = 0; index < lines.length; index += 1) {
239
+ const rawLine = lines[index];
240
+ const isLast = index === lines.length - 1;
241
+ const lineHadNewline = !isLast || hasTerminalNewline;
242
+ const lineBytes = isLast && hasTerminalNewline && rawLine === ''
243
+ ? 0
244
+ : Buffer.byteLength(rawLine, 'utf8') + (lineHadNewline ? 1 : 0);
245
+ const line = rawLine.trim();
246
+
247
+ if (!line) {
248
+ parsedBytes += lineBytes;
249
+ continue;
250
+ }
251
+
252
+ try {
253
+ rows.push(JSON.parse(line));
254
+ parsedBytes += lineBytes;
255
+ } catch (error) {
256
+ if (allowPartialTail && isLast && !hasTerminalNewline) {
257
+ skippedPartialTail = true;
258
+ skippedPartialTailBytes = lineBytes;
259
+ break;
260
+ }
261
+ throw error;
262
+ }
263
+ }
264
+
265
+ return {
266
+ rows,
267
+ parsedBytes,
268
+ skippedPartialTail,
269
+ skippedPartialTailBytes,
270
+ };
271
+ }
272
+
273
+ function readJsonl(filePath, options = {}) {
274
+ if (!fs.existsSync(filePath)) return [];
275
+ const content = fs.readFileSync(filePath, 'utf8');
276
+ return parseJsonlContent(content, options).rows;
277
+ }
278
+
279
+ function writeJson(filePath, payload) {
280
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
281
+ fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
282
+ }
283
+
284
+ function appendJsonl(filePath, rows) {
285
+ if (!rows.length) return;
286
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
287
+ fs.appendFileSync(filePath, rows.map(row => JSON.stringify(row)).join('\n') + '\n', 'utf8');
288
+ }
289
+
290
+ function writeJsonl(filePath, rows) {
291
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
292
+ fs.writeFileSync(filePath, rows.length ? rows.map(row => JSON.stringify(row)).join('\n') + '\n' : '', 'utf8');
293
+ }
294
+
295
+ function canonicalJson(value) {
296
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
297
+ if (value && typeof value === 'object') {
298
+ return `{${Object.keys(value).sort().map((key) => {
299
+ const item = value[key];
300
+ if (item === undefined) return null;
301
+ return `${JSON.stringify(key)}:${canonicalJson(item)}`;
302
+ }).filter(Boolean).join(',')}}`;
303
+ }
304
+ return JSON.stringify(value);
305
+ }
306
+
307
+ function sha256(value) {
308
+ return crypto.createHash('sha256').update(String(value)).digest('hex');
309
+ }
310
+
311
+ function hashPayload(value) {
312
+ return sha256(canonicalJson(value));
313
+ }
314
+
315
+ function readJsonFile(filePath, fallback = null) {
316
+ if (!fs.existsSync(filePath)) return fallback;
317
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
318
+ }
319
+
320
+ function fileSize(filePath) {
321
+ try {
322
+ return fs.statSync(filePath).size;
323
+ } catch (_) {
324
+ return 0;
325
+ }
326
+ }
327
+
328
+ function timestampMs(value) {
329
+ if (value === null || value === undefined || value === '') return null;
330
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
331
+ const numeric = Number(value);
332
+ if (Number.isFinite(numeric) && String(value).trim() !== '') return numeric;
333
+ const parsed = new Date(value).getTime();
334
+ return Number.isFinite(parsed) ? parsed : null;
335
+ }
336
+
337
+ function isoFromMs(value) {
338
+ const ms = timestampMs(value);
339
+ return ms === null ? null : new Date(ms).toISOString();
340
+ }
341
+
342
+ function startOfToday() {
343
+ const date = new Date();
344
+ date.setHours(0, 0, 0, 0);
345
+ return date;
346
+ }
347
+
348
+ function startOfTonight() {
349
+ const date = new Date();
350
+ if (date.getHours() < 6) {
351
+ date.setDate(date.getDate() - 1);
352
+ }
353
+ date.setHours(18, 0, 0, 0);
354
+ return date;
355
+ }
356
+
357
+ function parseSessionBoundary(value, fallback) {
358
+ if (!value) return fallback;
359
+ const normalized = String(value).trim().toLowerCase();
360
+ if (!normalized) return fallback;
361
+ if (normalized === 'now') return new Date();
362
+ if (normalized === 'today') return startOfToday();
363
+ if (normalized === 'tonight') return startOfTonight();
364
+ if (normalized === 'yesterday') {
365
+ const date = startOfToday();
366
+ date.setDate(date.getDate() - 1);
367
+ return date;
368
+ }
369
+
370
+ const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(normalized);
371
+ if (dateOnly) {
372
+ return new Date(
373
+ Number(dateOnly[1]),
374
+ Number(dateOnly[2]) - 1,
375
+ Number(dateOnly[3]),
376
+ 0,
377
+ 0,
378
+ 0,
379
+ 0
380
+ );
381
+ }
382
+
383
+ const parsed = new Date(value);
384
+ if (Number.isFinite(parsed.getTime())) return parsed;
385
+ throw new Error(`Invalid session time: ${value}`);
386
+ }
387
+
388
+ function inWindow(value, sinceMs, untilMs) {
389
+ const ms = timestampMs(value);
390
+ return ms !== null && ms >= sinceMs && ms <= untilMs;
391
+ }
392
+
393
+ function expandHome(filePath) {
394
+ if (!filePath) return filePath;
395
+ if (filePath === '~') return os.homedir();
396
+ if (filePath.startsWith('~/')) return path.join(os.homedir(), filePath.slice(2));
397
+ return filePath;
398
+ }
399
+
400
+ function sqlString(value) {
401
+ return `'${String(value).replace(/'/g, "''")}'`;
402
+ }
403
+
404
+ function runSqliteJsonOptional(dbPath, sql) {
405
+ if (!dbPath || !fs.existsSync(dbPath)) return [];
406
+ const result = spawnSync('sqlite3', ['-readonly', '-json', dbPath, sql], { encoding: 'utf8' });
407
+ if (result.error || result.status !== 0) return [];
408
+ const out = String(result.stdout || '').trim();
409
+ if (!out) return [];
410
+ try {
411
+ return JSON.parse(out);
412
+ } catch (_) {
413
+ return [];
414
+ }
415
+ }
416
+
417
+ function receiptHashBase(receipt, previousHash) {
418
+ const base = {
419
+ ...receipt,
420
+ chain_version: RECEIPT_CHAIN_VERSION,
421
+ previous_receipt_hash: previousHash || null,
422
+ };
423
+ delete base.receipt_hash;
424
+ return base;
425
+ }
426
+
427
+ function receiptDuplicateComparable(receipt) {
428
+ const comparable = { ...(receipt || {}) };
429
+ delete comparable.chain_version;
430
+ delete comparable.previous_receipt_hash;
431
+ delete comparable.receipt_hash;
432
+ return comparable;
433
+ }
434
+
435
+ function receiptLabel(receipt) {
436
+ const title = String(receipt?.title || '').trim();
437
+ if (title) return title;
438
+ const task = String(receipt?.source_task_id || '').trim();
439
+ if (task) return `task ${task.slice(0, 8)}`;
440
+ const source = String(receipt?.source_type || receipt?.source || '').trim();
441
+ return source || 'accepted proof';
442
+ }
443
+
444
+ function withReceiptIntegrity(receipt, previousHash) {
445
+ const base = receiptHashBase(receipt, previousHash);
446
+ return {
447
+ ...base,
448
+ receipt_hash: hashPayload(base),
449
+ };
450
+ }
451
+
452
+ function normalizeReceiptChain(receipts, { allowLegacyUpgrade = true } = {}) {
453
+ const seen = new Map();
454
+ const errors = [];
455
+ const dedupedReceipts = [];
456
+ const normalized = [];
457
+ let previousHash = null;
458
+ let upgraded = false;
459
+
460
+ for (const receipt of receipts) {
461
+ if (!receipt || typeof receipt !== 'object') {
462
+ errors.push('invalid_receipt');
463
+ continue;
464
+ }
465
+ if (!receipt.receipt_id) errors.push('missing_receipt_id');
466
+ if (receipt.receipt_id && seen.has(receipt.receipt_id)) {
467
+ const first = seen.get(receipt.receipt_id);
468
+ if (canonicalJson(receiptDuplicateComparable(first)) === canonicalJson(receiptDuplicateComparable(receipt))) {
469
+ dedupedReceipts.push(receiptLabel(receipt));
470
+ continue;
471
+ }
472
+ errors.push(`conflicting_duplicate_receipt:${receiptLabel(receipt)}`);
473
+ }
474
+
475
+ const expected = withReceiptIntegrity(receipt, previousHash);
476
+ const hadIntegrity = Boolean(receipt.receipt_hash && receipt.chain_version && Object.prototype.hasOwnProperty.call(receipt, 'previous_receipt_hash'));
477
+ if (hadIntegrity) {
478
+ if (receipt.chain_version !== RECEIPT_CHAIN_VERSION) {
479
+ errors.push(`receipt_chain_version_mismatch:${receiptLabel(receipt)}`);
480
+ }
481
+ if ((receipt.previous_receipt_hash || null) !== (previousHash || null)) {
482
+ errors.push(`receipt_previous_hash_mismatch:${receiptLabel(receipt)}`);
483
+ }
484
+ if (receipt.receipt_hash !== expected.receipt_hash) {
485
+ errors.push(`receipt_hash_mismatch:${receiptLabel(receipt)}`);
486
+ }
487
+ }
488
+ if (!hadIntegrity && !allowLegacyUpgrade) {
489
+ errors.push(`missing_receipt_integrity:${receiptLabel(receipt)}`);
490
+ }
491
+ if (!hadIntegrity && allowLegacyUpgrade) upgraded = true;
492
+
493
+ normalized.push(expected);
494
+ previousHash = expected.receipt_hash;
495
+ if (receipt.receipt_id && !seen.has(receipt.receipt_id)) seen.set(receipt.receipt_id, receipt);
496
+ }
497
+
498
+ return {
499
+ receipts: normalized,
500
+ upgraded,
501
+ deduped: dedupedReceipts.length > 0,
502
+ integrity: {
503
+ status: errors.length ? 'tampered' : 'verified',
504
+ chain_version: RECEIPT_CHAIN_VERSION,
505
+ receipts_count: normalized.length,
506
+ head_hash: previousHash,
507
+ errors,
508
+ deduped_receipts: dedupedReceipts,
509
+ local_trust: 'tamper_evident_not_attested',
510
+ },
511
+ };
512
+ }
513
+
514
+ function alignJsonlReadStart(filePath, start) {
515
+ if (!start || start <= 0) return 0;
516
+ const fd = fs.openSync(filePath, 'r');
517
+ try {
518
+ const previous = Buffer.alloc(1);
519
+ fs.readSync(fd, previous, 0, 1, start - 1);
520
+ if (previous[0] === 0x0a) return start;
521
+
522
+ const current = Buffer.alloc(1);
523
+ const currentBytes = fs.readSync(fd, current, 0, 1, start);
524
+ if (previous[0] === 0x0d && currentBytes === 1 && current[0] === 0x0a) {
525
+ return start + 1;
526
+ }
527
+
528
+ const chunkSize = 64 * 1024;
529
+ let scanEnd = start;
530
+ while (scanEnd > 0) {
531
+ const scanStart = Math.max(0, scanEnd - chunkSize);
532
+ const buffer = Buffer.alloc(scanEnd - scanStart);
533
+ fs.readSync(fd, buffer, 0, buffer.length, scanStart);
534
+ for (let index = buffer.length - 1; index >= 0; index -= 1) {
535
+ if (buffer[index] === 0x0a) return scanStart + index + 1;
536
+ }
537
+ scanEnd = scanStart;
538
+ }
539
+ return 0;
540
+ } finally {
541
+ fs.closeSync(fd);
542
+ }
543
+ }
544
+
545
+ function readTaskEpisodeTail(episodePath, cursorPath, options = {}) {
546
+ const stat = fs.existsSync(episodePath) ? fs.statSync(episodePath) : null;
547
+ const forceReset = Boolean(options.forceReset);
548
+ const cursor = forceReset ? null : readJsonFile(cursorPath, null);
549
+ const previousBytes = Number(cursor?.bytes_read || 0);
550
+ const size = stat ? stat.size : 0;
551
+ const reset = forceReset || !cursor || cursor.source_path !== episodePath || size < previousBytes;
552
+ const start = reset || !stat ? 0 : alignJsonlReadStart(episodePath, previousBytes);
553
+ if (!stat || size === start) {
554
+ return {
555
+ episodes: [],
556
+ cursor: {
557
+ schema: 'atris.career_xp_cursor.v1',
558
+ source_path: episodePath,
559
+ bytes_read: size,
560
+ source_size: size,
561
+ last_episode_id: cursor?.last_episode_id || null,
562
+ updated_at: new Date().toISOString(),
563
+ reset,
564
+ },
565
+ };
566
+ }
567
+
568
+ const fd = fs.openSync(episodePath, 'r');
569
+ try {
570
+ const buffer = Buffer.alloc(size - start);
571
+ fs.readSync(fd, buffer, 0, buffer.length, start);
572
+ const parsed = parseJsonlContent(buffer.toString('utf8'), { allowPartialTail: true });
573
+ const episodes = parsed.rows;
574
+ const bytesRead = start + parsed.parsedBytes;
575
+ return {
576
+ episodes,
577
+ cursor: {
578
+ schema: 'atris.career_xp_cursor.v1',
579
+ source_path: episodePath,
580
+ bytes_read: bytesRead,
581
+ source_size: size,
582
+ last_episode_id: episodes.length ? episodes[episodes.length - 1].episode_id || null : cursor?.last_episode_id || null,
583
+ updated_at: new Date().toISOString(),
584
+ reset,
585
+ skipped_partial_tail: parsed.skippedPartialTail,
586
+ skipped_partial_tail_bytes: parsed.skippedPartialTailBytes,
587
+ },
588
+ };
589
+ } finally {
590
+ fs.closeSync(fd);
591
+ }
592
+ }
593
+
594
+ function receiptFromTaskEpisode(episode) {
595
+ const doneForXp = String(episode?.state?.status || '').toLowerCase() === 'done';
596
+ const hasExplicitCareerXp = episode?.career_xp && typeof episode.career_xp === 'object';
597
+ const eligible = doneForXp && (
598
+ episode?.career_xp?.eligible === true
599
+ || (!hasExplicitCareerXp && episode?.rl?.label === 'accepted')
600
+ );
601
+ const proof = String(episode?.proof || '').trim();
602
+ const reward = asNumber(episode?.career_xp?.reward ?? episode?.reward?.value, 0);
603
+ if (!eligible || !proof || reward <= 0 || !episode?.episode_id) return null;
604
+
605
+ return {
606
+ schema: 'atris.career_xp_receipt.v1',
607
+ receipt_id: `task_review:${episode.episode_id}`,
608
+ source: 'atris-cli',
609
+ source_type: 'task_review',
610
+ source_task_id: episode.task_id || null,
611
+ source_episode_id: episode.episode_id,
612
+ workspace_root: episode.workspace_root || null,
613
+ actor: episode.action?.actor || null,
614
+ outcome: 'accepted',
615
+ xp: reward,
616
+ reward,
617
+ proof,
618
+ proof_ref: proof,
619
+ source_episode_hash: hashPayload(episode),
620
+ title: episode.state?.title || null,
621
+ goal: episode.goal || null,
622
+ accepted_at: episode.created_at || new Date().toISOString(),
623
+ tokens_used: Number.isFinite(Number(episode.tokens_used)) ? Number(episode.tokens_used) : null,
624
+ duration_seconds: Number.isFinite(Number(episode.duration_seconds)) ? Number(episode.duration_seconds) : null,
625
+ };
626
+ }
627
+
628
+ function latestReceipt(receipts) {
629
+ return receipts
630
+ .slice()
631
+ .sort((a, b) => new Date(b.accepted_at).getTime() - new Date(a.accepted_at).getTime())[0] || null;
632
+ }
633
+
634
+ function countBySource(receipts) {
635
+ return receipts.reduce((acc, receipt) => {
636
+ const source = receipt.source || 'unknown';
637
+ acc[source] = (acc[source] || 0) + 1;
638
+ return acc;
639
+ }, {});
640
+ }
641
+
642
+ function buildCareerXpProjection(receipts, workspace, integrity = {}) {
643
+ const trusted = integrity.status === 'tampered' ? [] : receipts;
644
+ const accepted = trusted
645
+ .filter(receipt => receipt && receipt.outcome === 'accepted' && asNumber(receipt.xp) > 0)
646
+ .sort((a, b) => new Date(a.accepted_at).getTime() - new Date(b.accepted_at).getTime());
647
+ const totalXp = accepted.reduce((sum, receipt) => sum + asNumber(receipt.xp), 0);
648
+ const today = localDateKey(new Date());
649
+ const todayXp = accepted
650
+ .filter(receipt => localDateKey(receipt.accepted_at) === today)
651
+ .reduce((sum, receipt) => sum + asNumber(receipt.xp), 0);
652
+ const level = levelFromXp(totalXp);
653
+ const levelBase = (level - 1) * LEVEL_XP;
654
+ const currentLevelXp = totalXp - levelBase;
655
+ const remainingXp = Math.max(0, (level * LEVEL_XP) - totalXp);
656
+ const nextLevelProgress = {
657
+ level,
658
+ next_level: level + 1,
659
+ current_xp: currentLevelXp,
660
+ required_xp: LEVEL_XP,
661
+ remaining_xp: remainingXp,
662
+ percent: Math.round((currentLevelXp / LEVEL_XP) * 1000) / 10,
663
+ };
664
+ const latest = latestReceipt(accepted);
665
+
666
+ return {
667
+ schema: 'atris.career_xp_projection.v1',
668
+ generated_at: new Date().toISOString(),
669
+ workspace_root: workspace,
670
+ metric_label: AGENT_XP_LABEL,
671
+ agent_xp: totalXp,
672
+ total_agent_xp: totalXp,
673
+ today_agent_xp: todayXp,
674
+ career_xp: totalXp,
675
+ total_xp: totalXp,
676
+ today_xp: todayXp,
677
+ level,
678
+ leaderboard_eligible: false,
679
+ integrity_status: integrity.status || 'unknown',
680
+ next_level_progress: nextLevelProgress,
681
+ career: {
682
+ level,
683
+ card_label: 'Career Card',
684
+ progress_label: 'Career Progress',
685
+ next_level_progress: nextLevelProgress,
686
+ },
687
+ contribution_graph: buildAgentXpContributionGraph(accepted),
688
+ receipts_count: accepted.length,
689
+ sources: countBySource(accepted),
690
+ latest_accepted_proof: latest ? {
691
+ label: receiptLabel(latest),
692
+ receipt_id: latest.receipt_id,
693
+ source: latest.source,
694
+ source_task_id: latest.source_task_id,
695
+ source_episode_id: latest.source_episode_id,
696
+ title: latest.title,
697
+ proof: latest.proof,
698
+ xp: latest.xp,
699
+ reward: latest.reward,
700
+ actor: latest.actor,
701
+ accepted_at: latest.accepted_at,
702
+ goal: latest.goal || null,
703
+ } : null,
704
+ ledger: {
705
+ receipts_path: path.join(workspace, CAREER_XP_RECEIPTS_FILE),
706
+ projection_path: path.join(workspace, CAREER_XP_PROJECTION_FILE),
707
+ cursor_path: path.join(workspace, CAREER_XP_CURSOR_FILE),
708
+ },
709
+ integrity: {
710
+ status: integrity.status || 'unknown',
711
+ chain_version: integrity.chain_version || RECEIPT_CHAIN_VERSION,
712
+ head_hash: integrity.head_hash || null,
713
+ receipts_count: integrity.receipts_count ?? accepted.length,
714
+ errors: integrity.errors || [],
715
+ deduped_receipts: integrity.deduped_receipts || [],
716
+ local_trust: integrity.local_trust || 'tamper_evident_not_attested',
717
+ cursor: integrity.cursor || null,
718
+ note: 'Local XP is tamper-evident. Public trust still requires cloud/notary attestation.',
719
+ },
720
+ };
721
+ }
722
+
723
+ function collectLocalXpProjectionState(args = [], { write = true } = {}) {
724
+ const workspace = path.resolve(readFlag(args, '--workspace', process.cwd()));
725
+ const episodePath = path.join(workspace, TASK_EPISODES_FILE);
726
+ const receiptsPath = path.join(workspace, CAREER_XP_RECEIPTS_FILE);
727
+ const projectionPath = path.join(workspace, CAREER_XP_PROJECTION_FILE);
728
+ const cursorPath = path.join(workspace, CAREER_XP_CURSOR_FILE);
729
+ let existingChain = normalizeReceiptChain(readJsonl(receiptsPath));
730
+ if ((existingChain.upgraded || existingChain.deduped) && existingChain.integrity.status === 'verified') {
731
+ if (write) writeJsonl(receiptsPath, existingChain.receipts);
732
+ existingChain = normalizeReceiptChain(existingChain.receipts);
733
+ }
734
+ if (existingChain.integrity.status === 'tampered') {
735
+ const projection = buildCareerXpProjection([], workspace, existingChain.integrity);
736
+ if (write) writeJson(projectionPath, projection);
737
+ return {
738
+ projection: {
739
+ ...projection,
740
+ collected_receipts: 0,
741
+ },
742
+ receipts: [],
743
+ newReceipts: [],
744
+ };
745
+ }
746
+
747
+ const replayFromStart = existingChain.receipts.length === 0
748
+ && fileSize(episodePath) > 0
749
+ && fileSize(receiptsPath) === 0;
750
+ const tail = readTaskEpisodeTail(episodePath, cursorPath, { forceReset: replayFromStart });
751
+ const seen = new Set(existingChain.receipts.map(receipt => receipt.receipt_id).filter(Boolean));
752
+ let previousHash = existingChain.integrity.head_hash || null;
753
+ const newReceipts = tail.episodes
754
+ .map(receiptFromTaskEpisode)
755
+ .filter(Boolean)
756
+ .filter((receipt) => {
757
+ if (seen.has(receipt.receipt_id)) return false;
758
+ seen.add(receipt.receipt_id);
759
+ return true;
760
+ })
761
+ .map((receipt) => {
762
+ const signed = withReceiptIntegrity(receipt, previousHash);
763
+ previousHash = signed.receipt_hash;
764
+ return signed;
765
+ });
766
+ const receipts = [...existingChain.receipts, ...newReceipts];
767
+ const finalChain = normalizeReceiptChain(receipts);
768
+ const finalIntegrity = {
769
+ ...finalChain.integrity,
770
+ cursor: tail.cursor,
771
+ };
772
+ const projection = buildCareerXpProjection(
773
+ finalIntegrity.status === 'tampered' ? [] : finalChain.receipts,
774
+ workspace,
775
+ finalIntegrity,
776
+ );
777
+
778
+ if (write) {
779
+ appendJsonl(receiptsPath, newReceipts);
780
+ writeJson(cursorPath, tail.cursor);
781
+ writeJson(projectionPath, projection);
782
+ }
783
+
784
+ return {
785
+ projection: {
786
+ ...projection,
787
+ collected_receipts: newReceipts.length,
788
+ },
789
+ receipts: finalChain.receipts,
790
+ newReceipts,
791
+ cursor: tail.cursor,
792
+ };
793
+ }
794
+
795
+ function collectLocalXpProjection(args = []) {
796
+ return collectLocalXpProjectionState(args, { write: true }).projection;
797
+ }
798
+
799
+ function uniquePaths(paths) {
800
+ const seen = new Set();
801
+ return paths
802
+ .map(item => path.resolve(item))
803
+ .filter((item) => {
804
+ if (seen.has(item)) return false;
805
+ seen.add(item);
806
+ return true;
807
+ });
808
+ }
809
+
810
+ function stateWorkspaceForFile(filePath) {
811
+ const stateDir = path.dirname(filePath);
812
+ if (path.basename(stateDir) !== 'state') return null;
813
+ const atrisDir = path.dirname(stateDir);
814
+ if (path.basename(atrisDir) !== '.atris') return null;
815
+ return path.dirname(atrisDir);
816
+ }
817
+
818
+ function discoverCareerXpWorkspaces(root, maxDepth = DEFAULT_SEARCH_DEPTH) {
819
+ const start = path.resolve(root);
820
+ if (!fs.existsSync(start)) return [];
821
+ const workspaces = new Set();
822
+ const stack = [{ dir: start, depth: 0 }];
823
+
824
+ while (stack.length) {
825
+ const { dir, depth } = stack.pop();
826
+ let entries;
827
+ try {
828
+ entries = fs.readdirSync(dir, { withFileTypes: true });
829
+ } catch (_) {
830
+ continue;
831
+ }
832
+
833
+ for (const entry of entries) {
834
+ const entryPath = path.join(dir, entry.name);
835
+ if (entry.isDirectory()) {
836
+ if (depth >= maxDepth || SEARCH_EXCLUDED_DIRS.has(entry.name)) continue;
837
+ stack.push({ dir: entryPath, depth: depth + 1 });
838
+ continue;
839
+ }
840
+ if (!entry.isFile() || !XP_STATE_FILES.has(entry.name)) continue;
841
+ const workspace = stateWorkspaceForFile(entryPath);
842
+ if (workspace) workspaces.add(workspace);
843
+ }
844
+ }
845
+
846
+ return Array.from(workspaces).sort();
847
+ }
848
+
849
+ function defaultAllSearchRoots(args = []) {
850
+ const explicitRoots = readFlagValues(args, ['--root', '--search-root']);
851
+ if (explicitRoots.length) return uniquePaths(explicitRoots);
852
+
853
+ const roots = [];
854
+ const workspace = readFlag(args, '--workspace', null);
855
+ if (workspace) roots.push(workspace);
856
+ roots.push(process.cwd());
857
+ roots.push(path.join(os.homedir(), 'arena'));
858
+ return uniquePaths(roots);
859
+ }
860
+
861
+ function workspaceName(workspace) {
862
+ return path.basename(workspace) || workspace;
863
+ }
864
+
865
+ function isVerifiedProjection(projection) {
866
+ return projection?.schema === 'atris.career_xp_projection.v1'
867
+ && projection.integrity_status === 'verified'
868
+ && projection.integrity?.status === 'verified';
869
+ }
870
+
871
+ function buildAllCareerXpProjection(projections, searchRoots = []) {
872
+ const warnings = [];
873
+ const verified = [];
874
+ const workspaces = projections.map((item) => {
875
+ if (item.error) {
876
+ warnings.push({
877
+ workspace_root: item.workspace_root,
878
+ reason: item.error,
879
+ });
880
+ return {
881
+ workspace_root: item.workspace_root,
882
+ name: workspaceName(item.workspace_root),
883
+ included: false,
884
+ integrity_status: 'error',
885
+ error: item.error,
886
+ };
887
+ }
888
+
889
+ const projection = item.projection;
890
+ const included = isVerifiedProjection(projection);
891
+ if (included) {
892
+ verified.push(projection);
893
+ } else {
894
+ warnings.push({
895
+ workspace_root: item.workspace_root,
896
+ reason: `integrity:${projection?.integrity_status || projection?.integrity?.status || 'unknown'}`,
897
+ errors: projection?.integrity?.errors || [],
898
+ });
899
+ }
900
+
901
+ return {
902
+ workspace_root: item.workspace_root,
903
+ name: workspaceName(item.workspace_root),
904
+ included,
905
+ total_xp: asNumber(projection?.total_xp),
906
+ today_xp: asNumber(projection?.today_xp),
907
+ level: asNumber(projection?.level, 1),
908
+ receipts_count: asNumber(projection?.receipts_count),
909
+ integrity_status: projection?.integrity_status || projection?.integrity?.status || 'unknown',
910
+ leaderboard_eligible: Boolean(projection?.leaderboard_eligible),
911
+ latest_accepted_proof: projection?.latest_accepted_proof || null,
912
+ ledger: projection?.ledger || null,
913
+ };
914
+ }).sort((a, b) => {
915
+ if (b.included !== a.included) return Number(b.included) - Number(a.included);
916
+ return b.total_xp - a.total_xp || a.name.localeCompare(b.name);
917
+ });
918
+
919
+ const totalXp = verified.reduce((sum, projection) => sum + asNumber(projection.total_xp), 0);
920
+ const todayXp = verified.reduce((sum, projection) => sum + asNumber(projection.today_xp), 0);
921
+ const level = levelFromXp(totalXp);
922
+ const levelBase = (level - 1) * LEVEL_XP;
923
+ const currentLevelXp = totalXp - levelBase;
924
+ const nextLevelProgress = {
925
+ level,
926
+ next_level: level + 1,
927
+ current_xp: currentLevelXp,
928
+ required_xp: LEVEL_XP,
929
+ remaining_xp: Math.max(0, (level * LEVEL_XP) - totalXp),
930
+ percent: Math.round((currentLevelXp / LEVEL_XP) * 1000) / 10,
931
+ };
932
+ const latest = verified
933
+ .map(projection => ({
934
+ ...(projection.latest_accepted_proof || {}),
935
+ workspace_root: projection.workspace_root,
936
+ workspace_name: workspaceName(projection.workspace_root),
937
+ }))
938
+ .filter(proof => proof && proof.accepted_at)
939
+ .sort((a, b) => new Date(b.accepted_at).getTime() - new Date(a.accepted_at).getTime())[0] || null;
940
+ const contributionGraph = combineAgentXpContributionGraphs(
941
+ verified.map(projection => projection.contribution_graph),
942
+ );
943
+
944
+ return {
945
+ schema: 'atris.career_xp_profile.v1',
946
+ generated_at: new Date().toISOString(),
947
+ search_roots: searchRoots,
948
+ workspace_count: workspaces.length,
949
+ verified_workspace_count: verified.length,
950
+ metric_label: AGENT_XP_LABEL,
951
+ agent_xp: totalXp,
952
+ total_agent_xp: totalXp,
953
+ today_agent_xp: todayXp,
954
+ career_xp: totalXp,
955
+ total_xp: totalXp,
956
+ today_xp: todayXp,
957
+ level,
958
+ leaderboard_eligible: false,
959
+ next_level_progress: nextLevelProgress,
960
+ career: {
961
+ level,
962
+ card_label: 'Career Card',
963
+ progress_label: 'Career Progress',
964
+ next_level_progress: nextLevelProgress,
965
+ },
966
+ contribution_graph: contributionGraph,
967
+ receipts_count: verified.reduce((sum, projection) => sum + asNumber(projection.receipts_count), 0),
968
+ latest_accepted_proof: latest,
969
+ workspaces,
970
+ integrity: {
971
+ status: warnings.length ? 'warnings' : 'verified',
972
+ warnings,
973
+ local_trust: 'workspace_local_tamper_evident_not_attested',
974
+ note: 'Only verified local ledgers are counted. Public trust still requires cloud/notary attestation.',
975
+ },
976
+ };
977
+ }
978
+
979
+ function collectAllLocalXpProjection(args = []) {
980
+ const searchRoots = defaultAllSearchRoots(args).filter(root => fs.existsSync(root));
981
+ const explicitWorkspaces = readFlagValues(args, '--workspace');
982
+ const discovered = searchRoots.flatMap(root => discoverCareerXpWorkspaces(root));
983
+ const workspaces = uniquePaths([...explicitWorkspaces, ...discovered]);
984
+ const projections = workspaces.map((workspace) => {
985
+ try {
986
+ return {
987
+ workspace_root: workspace,
988
+ projection: collectLocalXpProjection(['--workspace', workspace]),
989
+ };
990
+ } catch (error) {
991
+ return {
992
+ workspace_root: workspace,
993
+ error: error.message,
994
+ };
995
+ }
996
+ });
997
+
998
+ return buildAllCareerXpProjection(projections, searchRoots);
999
+ }
1000
+
1001
+ function readTaskProjectionState(workspace) {
1002
+ const projectionPath = path.join(workspace, TASK_PROJECTION_FILE);
1003
+ try {
1004
+ const projection = readJsonFile(projectionPath, { tasks: [] });
1005
+ return {
1006
+ tasks: Array.isArray(projection?.tasks) ? projection.tasks : [],
1007
+ warning: null,
1008
+ };
1009
+ } catch (error) {
1010
+ return {
1011
+ tasks: [],
1012
+ warning: `task_projection_unreadable:${error.message}`,
1013
+ };
1014
+ }
1015
+ }
1016
+
1017
+ function readTaskProjection(workspace) {
1018
+ return readTaskProjectionState(workspace).tasks;
1019
+ }
1020
+
1021
+ function taskRef(task) {
1022
+ return task?.display_id || task?.legacy_ref || task?.id || 'task';
1023
+ }
1024
+
1025
+ function taskTimes(task) {
1026
+ const times = [
1027
+ task?.created_at,
1028
+ task?.updated_at,
1029
+ task?.done_at,
1030
+ task?.review?.reviewed_at,
1031
+ task?.review?.accepted_at,
1032
+ task?.review?.revised_at,
1033
+ task?.metadata?.agent_reviewed_at,
1034
+ task?.metadata?.accepted_at,
1035
+ task?.metadata?.human_revision_at,
1036
+ ];
1037
+ for (const event of task?.events || []) times.push(event.created_at);
1038
+ for (const message of task?.messages || []) times.push(message.created_at);
1039
+ return times
1040
+ .map(timestampMs)
1041
+ .filter(ms => ms !== null);
1042
+ }
1043
+
1044
+ function taskTouchedInWindow(task, sinceMs, untilMs) {
1045
+ return taskTimes(task).some(ms => ms >= sinceMs && ms <= untilMs);
1046
+ }
1047
+
1048
+ function latestTaskMs(task) {
1049
+ const times = taskTimes(task);
1050
+ return times.length ? Math.max(...times) : null;
1051
+ }
1052
+
1053
+ function taskApprovalStatus(task) {
1054
+ return task?.review?.approval_status || task?.metadata?.approval_status || null;
1055
+ }
1056
+
1057
+ function textExcerpt(value, limit = 220) {
1058
+ const text = String(value || '').replace(/\s+/g, ' ').trim();
1059
+ if (text.length <= limit) return text || null;
1060
+ return `${text.slice(0, limit - 3)}...`;
1061
+ }
1062
+
1063
+ function compactTask(task) {
1064
+ const proof = task?.review?.proof || task?.metadata?.latest_agent_proof || null;
1065
+ return {
1066
+ task_id: taskRef(task),
1067
+ id: task?.id || null,
1068
+ title: task?.title || null,
1069
+ status: task?.status || null,
1070
+ tag: task?.tag || null,
1071
+ claimed_by: task?.claimed_by || null,
1072
+ approval_status: taskApprovalStatus(task),
1073
+ summary: task?.review?.summary || null,
1074
+ proof_excerpt: textExcerpt(proof),
1075
+ has_proof: Boolean(proof),
1076
+ updated_at: isoFromMs(latestTaskMs(task)),
1077
+ };
1078
+ }
1079
+
1080
+ function receiptMatchesTask(receipt, task) {
1081
+ const sourceTaskId = String(receipt?.source_task_id || '');
1082
+ if (!sourceTaskId) return false;
1083
+ return [task?.id, task?.display_id, task?.legacy_ref]
1084
+ .filter(Boolean)
1085
+ .map(String)
1086
+ .includes(sourceTaskId);
1087
+ }
1088
+
1089
+ function compactReceipt(receipt, task = null, options = {}) {
1090
+ const proof = receipt?.proof || null;
1091
+ const compact = {
1092
+ receipt_id: receipt?.receipt_id || null,
1093
+ task_id: task ? taskRef(task) : receipt?.source_task_id || null,
1094
+ source_task_id: receipt?.source_task_id || null,
1095
+ title: receipt?.title || task?.title || null,
1096
+ proof_excerpt: textExcerpt(proof),
1097
+ has_proof: Boolean(proof),
1098
+ xp: asNumber(receipt?.xp),
1099
+ actor: receipt?.actor || null,
1100
+ accepted_at: receipt?.accepted_at || null,
1101
+ goal: receipt?.goal || null,
1102
+ };
1103
+ if (options.fullProof) {
1104
+ compact.proof = proof;
1105
+ }
1106
+ return compact;
1107
+ }
1108
+
1109
+ function compactGoal(goal) {
1110
+ if (!goal) return null;
1111
+ if (typeof goal === 'string') {
1112
+ const label = goal.trim();
1113
+ return label ? { label } : null;
1114
+ }
1115
+ const label = goal.objective || goal.title || goal.condition || goal.id || goal.goal_id || null;
1116
+ if (!label) return null;
1117
+ return {
1118
+ provider: goal.provider || null,
1119
+ thread_id: goal.thread_id || null,
1120
+ goal_id: goal.id || goal.goal_id || null,
1121
+ label,
1122
+ status: goal.status || null,
1123
+ met: typeof goal.met === 'boolean' ? goal.met : null,
1124
+ tokens_used: Number.isFinite(Number(goal.tokens_used)) ? Number(goal.tokens_used) : null,
1125
+ time_used_seconds: Number.isFinite(Number(goal.time_used_seconds)) ? Number(goal.time_used_seconds) : null,
1126
+ created_at: goal.created_at || isoFromMs(goal.created_at_ms),
1127
+ updated_at: goal.updated_at || isoFromMs(goal.updated_at_ms),
1128
+ workspace_path: goal.thread_cwd || goal.workspace_path || null,
1129
+ };
1130
+ }
1131
+
1132
+ function uniqueGoals(goals) {
1133
+ const seen = new Set();
1134
+ return goals
1135
+ .map(compactGoal)
1136
+ .filter(Boolean)
1137
+ .filter((goal) => {
1138
+ const key = `${goal.goal_id || ''}:${goal.label}`;
1139
+ if (seen.has(key)) return false;
1140
+ seen.add(key);
1141
+ return true;
1142
+ });
1143
+ }
1144
+
1145
+ function buildNextQuest(reviewTasks, touchedTasks, acceptedReceipts) {
1146
+ const pendingReview = reviewTasks
1147
+ .slice()
1148
+ .sort((a, b) => (latestTaskMs(b) || 0) - (latestTaskMs(a) || 0))[0];
1149
+ if (pendingReview) {
1150
+ return `Review ${taskRef(pendingReview)}: ${pendingReview.title}`;
1151
+ }
1152
+
1153
+ const activeTask = touchedTasks
1154
+ .filter(task => ['claimed', 'open', 'todo', 'do'].includes(String(task.status || '').toLowerCase()))
1155
+ .sort((a, b) => (latestTaskMs(b) || 0) - (latestTaskMs(a) || 0))[0];
1156
+ if (activeTask) {
1157
+ return `Move ${taskRef(activeTask)} to Review with proof.`;
1158
+ }
1159
+
1160
+ if (!acceptedReceipts.length) {
1161
+ return 'Put one task into Review with proof, then accept it if the proof is real.';
1162
+ }
1163
+ return 'Pick the next highest-leverage task and move it to Review with proof.';
1164
+ }
1165
+
1166
+ function workspacePathCandidates(workspace) {
1167
+ const candidates = [workspace];
1168
+ try {
1169
+ candidates.push(fs.realpathSync(workspace));
1170
+ } catch (_) {
1171
+ // best-effort only
1172
+ }
1173
+ const pwd = process.env.PWD || '';
1174
+ if (pwd) {
1175
+ try {
1176
+ if (fs.existsSync(pwd) && fs.realpathSync(pwd) === fs.realpathSync(workspace)) candidates.push(pwd);
1177
+ } catch (_) {
1178
+ // best-effort only
1179
+ }
1180
+ }
1181
+ return [...new Set(candidates.filter(Boolean))];
1182
+ }
1183
+
1184
+ function readCodexGoalsForSession(args, workspace, sinceMs, untilMs) {
1185
+ const dbPath = path.resolve(expandHome(
1186
+ readFlag(args, '--codex-state', readFlag(args, '--state', process.env.CODEX_STATE_DB || CODEX_STATE_FILE))
1187
+ ));
1188
+ const threadId = readFlag(args, '--thread', process.env.CODEX_THREAD_ID || '');
1189
+ const clauses = [];
1190
+ if (threadId) clauses.push(`tg.thread_id = ${sqlString(threadId)}`);
1191
+ const workspaceCandidates = workspacePathCandidates(workspace);
1192
+ if (workspaceCandidates.length) {
1193
+ clauses.push(`(t.cwd IN (${workspaceCandidates.map(sqlString).join(', ')}) AND tg.created_at_ms <= ${Number(untilMs)} AND (tg.updated_at_ms >= ${Number(sinceMs)} OR tg.status = 'active'))`);
1194
+ }
1195
+ if (!clauses.length) return [];
1196
+
1197
+ const rows = runSqliteJsonOptional(dbPath, `
1198
+ SELECT
1199
+ 'codex' AS provider,
1200
+ tg.thread_id,
1201
+ tg.goal_id,
1202
+ tg.objective,
1203
+ tg.status,
1204
+ tg.token_budget,
1205
+ tg.tokens_used,
1206
+ tg.time_used_seconds,
1207
+ tg.created_at_ms,
1208
+ tg.updated_at_ms,
1209
+ t.cwd AS thread_cwd,
1210
+ t.title AS thread_title
1211
+ FROM thread_goals tg
1212
+ LEFT JOIN threads t ON t.id = tg.thread_id
1213
+ WHERE ${clauses.join(' OR ')}
1214
+ ORDER BY tg.updated_at_ms DESC
1215
+ LIMIT 25
1216
+ `);
1217
+
1218
+ return rows.map(row => ({
1219
+ ...row,
1220
+ provider: 'codex',
1221
+ }));
1222
+ }
1223
+
1224
+ function buildCareerXpSessionCapsule(args = []) {
1225
+ const workspace = path.resolve(readFlag(args, '--workspace', process.cwd()));
1226
+ const sinceInput = readFlag(args, '--since', 'today');
1227
+ const untilInput = readFlag(args, '--until', null);
1228
+ const since = parseSessionBoundary(sinceInput, startOfToday());
1229
+ const until = parseSessionBoundary(untilInput, new Date());
1230
+ const sinceMs = since.getTime();
1231
+ const untilMs = until.getTime();
1232
+ if (sinceMs > untilMs) {
1233
+ throw new Error('--since must be before --until');
1234
+ }
1235
+
1236
+ const writeEnabled = !hasFlag(args, '--no-write') && !hasFlag(args, '--dry-run');
1237
+ const projectionState = collectLocalXpProjectionState(['--workspace', workspace], { write: writeEnabled });
1238
+ const projection = projectionState.projection;
1239
+ const receiptsPath = path.join(workspace, CAREER_XP_RECEIPTS_FILE);
1240
+ const taskProjectionPath = path.join(workspace, TASK_PROJECTION_FILE);
1241
+ const sessionDate = localDateKey(since) || since.toISOString().slice(0, 10);
1242
+ const sessionPath = path.join(workspace, CAREER_XP_SESSIONS_DIR, `${sessionDate}.json`);
1243
+ const warnings = [];
1244
+ if (projection.integrity?.cursor?.skipped_partial_tail) {
1245
+ warnings.push('task_episodes_partial_tail_waiting');
1246
+ }
1247
+ const taskProjection = readTaskProjectionState(workspace);
1248
+ if (taskProjection.warning) warnings.push(taskProjection.warning);
1249
+ const tasks = taskProjection.tasks;
1250
+ const touchedTasks = tasks
1251
+ .filter(task => taskTouchedInWindow(task, sinceMs, untilMs))
1252
+ .sort((a, b) => (latestTaskMs(b) || 0) - (latestTaskMs(a) || 0));
1253
+ const reviewTasks = touchedTasks.filter((task) => {
1254
+ const status = String(task.status || '').toLowerCase();
1255
+ return status === 'review' || taskApprovalStatus(task) === 'pending';
1256
+ });
1257
+ const doneTasks = touchedTasks.filter(task => String(task.status || '').toLowerCase() === 'done');
1258
+ const receipts = projection.integrity_status === 'verified' ? projectionState.receipts : [];
1259
+ const acceptedReceipts = receipts
1260
+ .filter(receipt => receipt?.outcome === 'accepted' && asNumber(receipt.xp) > 0 && inWindow(receipt.accepted_at, sinceMs, untilMs))
1261
+ .sort((a, b) => (timestampMs(b.accepted_at) || 0) - (timestampMs(a.accepted_at) || 0));
1262
+ const acceptedTasks = acceptedReceipts.map((receipt) => {
1263
+ const task = touchedTasks.find(item => receiptMatchesTask(receipt, item));
1264
+ return compactReceipt(receipt, task);
1265
+ });
1266
+ const windowXp = acceptedReceipts.reduce((sum, receipt) => sum + asNumber(receipt.xp), 0);
1267
+ const afterTotalXp = asNumber(projection.total_xp);
1268
+ const missionFlag = readFlag(args, '--mission', null);
1269
+ const episodeGoals = readJsonl(path.join(workspace, TASK_EPISODES_FILE), { allowPartialTail: true })
1270
+ .filter(episode => inWindow(episode?.created_at, sinceMs, untilMs))
1271
+ .map(episode => episode.goal);
1272
+ const codexGoals = readCodexGoalsForSession(args, workspace, sinceMs, untilMs);
1273
+ const goals = uniqueGoals([
1274
+ ...codexGoals,
1275
+ ...acceptedReceipts.map(receipt => receipt.goal),
1276
+ ...episodeGoals,
1277
+ ]);
1278
+ const latestAccepted = acceptedReceipts[0] || null;
1279
+
1280
+ const capsule = {
1281
+ schema: 'atris.career_xp_session_capsule.v1',
1282
+ generated_at: new Date().toISOString(),
1283
+ workspace_root: workspace,
1284
+ window: {
1285
+ label: String(sinceInput || 'today'),
1286
+ since: since.toISOString(),
1287
+ until: until.toISOString(),
1288
+ since_ms: sinceMs,
1289
+ until_ms: untilMs,
1290
+ },
1291
+ mission: {
1292
+ label: missionFlag || goals[0]?.label || touchedTasks.find(task => task.tag === 'career-xp')?.title || 'Capture proof-backed work',
1293
+ source: missionFlag ? 'flag' : goals[0] ? 'goal' : 'tasks',
1294
+ },
1295
+ xp: {
1296
+ before_agent_xp: Math.max(0, afterTotalXp - windowXp),
1297
+ after_agent_xp: afterTotalXp,
1298
+ delta_agent_xp: windowXp,
1299
+ today_agent_xp: asNumber(projection.today_xp),
1300
+ before_total_xp: Math.max(0, afterTotalXp - windowXp),
1301
+ after_total_xp: afterTotalXp,
1302
+ delta_xp: windowXp,
1303
+ today_xp: asNumber(projection.today_xp),
1304
+ level: asNumber(projection.level, 1),
1305
+ next_level_progress: projection.next_level_progress || null,
1306
+ integrity_status: projection.integrity_status || 'unknown',
1307
+ },
1308
+ tasks: {
1309
+ touched_count: touchedTasks.length,
1310
+ review_count: reviewTasks.length,
1311
+ accepted_count: acceptedTasks.length,
1312
+ done_count: doneTasks.length,
1313
+ touched: touchedTasks.map(compactTask),
1314
+ review: reviewTasks.map(compactTask),
1315
+ accepted: acceptedTasks,
1316
+ },
1317
+ goals: {
1318
+ count: goals.length,
1319
+ touched: goals,
1320
+ },
1321
+ proof: {
1322
+ receipts_count: acceptedReceipts.length,
1323
+ latest_accepted: latestAccepted ? compactReceipt(
1324
+ latestAccepted,
1325
+ touchedTasks.find(task => receiptMatchesTask(latestAccepted, task)),
1326
+ { fullProof: true }
1327
+ ) : null,
1328
+ accepted: acceptedTasks,
1329
+ },
1330
+ next_quest: buildNextQuest(reviewTasks, touchedTasks, acceptedReceipts),
1331
+ files: {
1332
+ projection_path: path.join(workspace, CAREER_XP_PROJECTION_FILE),
1333
+ receipts_path: receiptsPath,
1334
+ task_projection_path: fs.existsSync(taskProjectionPath) ? taskProjectionPath : null,
1335
+ session_path: writeEnabled ? sessionPath : null,
1336
+ },
1337
+ written: writeEnabled,
1338
+ };
1339
+
1340
+ if (warnings.length) {
1341
+ capsule.warnings = warnings;
1342
+ }
1343
+
1344
+ if (writeEnabled) {
1345
+ writeJson(sessionPath, capsule);
1346
+ }
1347
+
1348
+ return capsule;
1349
+ }
1350
+
1351
+ function normalizeLocalScore(score, workspace) {
1352
+ const card = score.profile_card || score.player_card || {};
1353
+ const integrity = score.integrity || {};
1354
+ const careerXp = asNumber(card.agent_xp ?? card.career_xp);
1355
+ const leaderboardEligible = Boolean(
1356
+ card.leaderboard_eligible ?? integrity.leaderboard_eligible
1357
+ );
1358
+
1359
+ return {
1360
+ metric_label: AGENT_XP_LABEL,
1361
+ agent_xp: careerXp,
1362
+ career_xp: careerXp,
1363
+ level: levelFromXp(careerXp),
1364
+ operator: score.operator || null,
1365
+ leaderboard_eligible: leaderboardEligible,
1366
+ source: 'local_contribution_score',
1367
+ workspace_root: score.workspace_root || workspace,
1368
+ current_form_by_arena: {
1369
+ local_workspace: {
1370
+ ovr: asNumber(card.ovr || card.current_form),
1371
+ current_form: asNumber(card.current_form || card.ovr),
1372
+ visible_stats: Array.isArray(card.visible_stats) ? card.visible_stats : [],
1373
+ leaderboard_eligible: leaderboardEligible,
1374
+ integrity_status: score.label || integrity.status || 'unknown',
1375
+ },
1376
+ },
1377
+ contribution_graph: score.contribution_graph || {},
1378
+ };
1379
+ }
1380
+
1381
+ function renderContributionGraph(graph) {
1382
+ if (!graph || !Array.isArray(graph.days)) return;
1383
+ console.log('');
1384
+ console.log(`Last ${formatNumber(graph.window_days || DEFAULT_GRAPH_DAYS)} days: ${formatNumber(graph.total_xp)} ${AGENT_XP_LABEL} across ${formatNumber(graph.active_days)} active days`);
1385
+ for (const row of buildContributionRows(graph.days)) {
1386
+ console.log(row);
1387
+ }
1388
+ console.log('Legend: blank none | . started | : solid | * heavy | # breakout');
1389
+ }
1390
+
1391
+ function loadLocalPayload(args) {
1392
+ const workspace = path.resolve(readFlag(args, '--workspace', process.cwd()));
1393
+ const operator = readFlag(
1394
+ args,
1395
+ '--operator',
1396
+ process.env.ATRIS_OPERATOR || process.env.USER || os.userInfo().username
1397
+ );
1398
+ const script = path.join(workspace, 'scripts', 'contribution_score.py');
1399
+
1400
+ if (!fs.existsSync(script)) {
1401
+ throw new Error(`No local contribution scorer found at ${path.relative(process.cwd(), script)}`);
1402
+ }
1403
+
1404
+ const result = spawnSync(
1405
+ process.env.PYTHON || 'python3',
1406
+ [script, '--workspace', workspace, '--operator', operator, '--json'],
1407
+ { cwd: workspace, encoding: 'utf8', maxBuffer: 20 * 1024 * 1024 }
1408
+ );
1409
+
1410
+ if (result.error) {
1411
+ throw result.error;
1412
+ }
1413
+ if (result.status !== 0) {
1414
+ const detail = (result.stderr || result.stdout || '').trim();
1415
+ throw new Error(detail || `Local scorer exited ${result.status}`);
1416
+ }
1417
+
1418
+ return normalizeLocalScore(JSON.parse(result.stdout), workspace);
1419
+ }
1420
+
1421
+ function render(payload) {
1422
+ if (payload.schema === 'atris.career_xp_session_capsule.v1') {
1423
+ const xp = payload.xp || {};
1424
+ const tasks = payload.tasks || {};
1425
+ console.log(`${AGENT_XP_LABEL} Session ${payload.window?.label || 'window'}`);
1426
+ console.log(`${AGENT_XP_LABEL} earned ${formatNumber(xp.delta_agent_xp ?? xp.delta_xp)} | Total ${formatNumber(xp.after_agent_xp ?? xp.after_total_xp)} | Today ${formatNumber(xp.today_agent_xp ?? xp.today_xp)} | Career Level ${formatNumber(xp.level || 1)}`);
1427
+ console.log(`Tasks ${formatNumber(tasks.touched_count)} touched | ${formatNumber(tasks.review_count)} in Review | ${formatNumber(tasks.accepted_count)} accepted`);
1428
+ if (payload.proof?.latest_accepted) {
1429
+ const proof = payload.proof.latest_accepted;
1430
+ console.log(`Latest proof ${proof.task_id || proof.title || 'accepted proof'}: ${textExcerpt(proof.proof, 260)}`);
1431
+ } else {
1432
+ console.log('Latest proof: none accepted in this session');
1433
+ }
1434
+ console.log(`Mission: ${payload.mission?.label || 'Capture proof-backed work'}`);
1435
+ console.log(`Next quest: ${payload.next_quest || 'Pick the next proof-backed task.'}`);
1436
+ if (payload.files?.session_path) console.log(`Capsule: ${payload.files.session_path}`);
1437
+ console.log(`Integrity: ${xp.integrity_status || 'unknown'}`);
1438
+ return;
1439
+ }
1440
+
1441
+ if (payload.schema === 'atris.career_xp_profile.v1') {
1442
+ const progress = payload.next_level_progress || {};
1443
+ console.log(`${AGENT_XP_LABEL} Card`);
1444
+ console.log(`${AGENT_XP_LABEL} ${formatNumber(payload.total_agent_xp ?? payload.total_xp)} | Today ${formatNumber(payload.today_agent_xp ?? payload.today_xp)} | Career Level ${formatNumber(payload.level || 1)}`);
1445
+ console.log(`Next career level ${formatNumber(progress.current_xp)}/${formatNumber(progress.required_xp)} ${AGENT_XP_LABEL} (${formatNumber(progress.percent)}%) | ${formatNumber(progress.remaining_xp)} to go`);
1446
+ console.log(`Workspaces ${formatNumber(payload.verified_workspace_count)}/${formatNumber(payload.workspace_count)} verified`);
1447
+ renderContributionGraph(payload.contribution_graph);
1448
+ console.log('');
1449
+ for (const workspace of payload.workspaces || []) {
1450
+ const marker = workspace.included ? 'included' : 'excluded';
1451
+ console.log(`- ${workspace.name}: ${formatNumber(workspace.total_xp)} ${AGENT_XP_LABEL} | today ${formatNumber(workspace.today_xp)} | ${workspace.integrity_status} | ${marker}`);
1452
+ }
1453
+ if (payload.latest_accepted_proof) {
1454
+ const proof = payload.latest_accepted_proof;
1455
+ console.log(`Latest proof ${proof.workspace_name || 'workspace'} / ${proof.label || proof.title || 'Accepted proof'}: ${proof.proof}`);
1456
+ } else {
1457
+ console.log('Latest proof: none accepted yet');
1458
+ }
1459
+ const integrity = payload.integrity || {};
1460
+ console.log(`Integrity: ${integrity.status || 'unknown'} (${integrity.local_trust || 'local'})`);
1461
+ for (const warning of integrity.warnings || []) {
1462
+ console.log(`Warning: ${warning.workspace_root} ${warning.reason}`);
1463
+ }
1464
+ return;
1465
+ }
1466
+
1467
+ if (payload.schema === 'atris.career_xp_projection.v1') {
1468
+ const progress = payload.next_level_progress || {};
1469
+ console.log(`${AGENT_XP_LABEL} Card`);
1470
+ console.log(`${AGENT_XP_LABEL} ${formatNumber(payload.total_agent_xp ?? payload.total_xp)} | Today ${formatNumber(payload.today_agent_xp ?? payload.today_xp)} | Career Level ${formatNumber(payload.level || 1)}`);
1471
+ console.log(`Next career level ${formatNumber(progress.current_xp)}/${formatNumber(progress.required_xp)} ${AGENT_XP_LABEL} (${formatNumber(progress.percent)}%) | ${formatNumber(progress.remaining_xp)} to go`);
1472
+ renderContributionGraph(payload.contribution_graph);
1473
+ console.log('');
1474
+ if (payload.latest_accepted_proof) {
1475
+ const proof = payload.latest_accepted_proof;
1476
+ console.log(`Latest proof ${proof.label || proof.title || 'Accepted proof'}: ${proof.proof}`);
1477
+ } else {
1478
+ console.log('Latest proof: none accepted yet');
1479
+ }
1480
+ console.log(`Ledger: ${payload.ledger?.projection_path || CAREER_XP_PROJECTION_FILE}`);
1481
+ return;
1482
+ }
1483
+
1484
+ const graph = payload.contribution_graph || {};
1485
+ const form = currentForm(payload);
1486
+
1487
+ console.log(`${AGENT_XP_LABEL} ${formatNumber(payload.agent_xp ?? payload.career_xp)} | Career Level ${formatNumber(payload.level || 1)}`);
1488
+ if (form) {
1489
+ const stats = form.visibleStats.length ? ` | ${form.visibleStats.join(', ')}` : '';
1490
+ console.log(`Current form ${form.ovr}/99 | ${form.integrityStatus}${stats}`);
1491
+ }
1492
+ console.log(`Last ${formatNumber(graph.window_days || 365)} days: ${formatNumber(graph.total_xp)} ${AGENT_XP_LABEL} across ${formatNumber(graph.active_days)} active days`);
1493
+ console.log('');
1494
+ for (const row of buildContributionRows(graph.days)) {
1495
+ console.log(row);
1496
+ }
1497
+ console.log('');
1498
+ console.log('Legend: blank none | . started | : solid | * heavy | # breakout');
1499
+ if (payload.leaderboard_eligible === false) {
1500
+ console.log('Leaderboard: integrity review needed before public ranking.');
1501
+ }
1502
+ }
1503
+
1504
+ async function xpCommand(...args) {
1505
+ if (args.includes('--help') || args.includes('-h') || args[0] === 'help') {
1506
+ showHelp();
1507
+ return;
1508
+ }
1509
+
1510
+ const subcommand = args[0] && !args[0].startsWith('--') ? args[0] : null;
1511
+ if (subcommand === 'session') {
1512
+ const commandArgs = args.slice(1);
1513
+ let payload;
1514
+ try {
1515
+ payload = buildCareerXpSessionCapsule(commandArgs);
1516
+ } catch (error) {
1517
+ console.error(`Failed to build local XP session: ${error.message}`);
1518
+ process.exit(1);
1519
+ }
1520
+ if (args.includes('--json')) {
1521
+ console.log(JSON.stringify(payload, null, 2));
1522
+ return;
1523
+ }
1524
+ render(payload);
1525
+ return;
1526
+ }
1527
+
1528
+ if (subcommand === 'collect' || subcommand === 'status' || subcommand === 'card') {
1529
+ const commandArgs = args.slice(1);
1530
+ let payload;
1531
+ try {
1532
+ const explicitLocal = hasFlag(commandArgs, '--local')
1533
+ || hasFlag(commandArgs, '--workspace')
1534
+ || hasFlag(commandArgs, '--operator');
1535
+ const accountStatus = (subcommand === 'status' || subcommand === 'card') && !explicitLocal;
1536
+ payload = hasFlag(commandArgs, '--all') || accountStatus
1537
+ ? collectAllLocalXpProjection(commandArgs)
1538
+ : collectLocalXpProjection(commandArgs);
1539
+ } catch (error) {
1540
+ console.error(`Failed to collect local XP: ${error.message}`);
1541
+ process.exit(1);
1542
+ }
1543
+ if (args.includes('--json')) {
1544
+ console.log(JSON.stringify(payload, null, 2));
1545
+ return;
1546
+ }
1547
+ render(payload);
1548
+ return;
1549
+ }
1550
+
1551
+ if (subcommand) {
1552
+ console.error(`Unknown xp subcommand: ${subcommand}`);
1553
+ showHelp();
1554
+ process.exit(1);
1555
+ }
1556
+
1557
+ const jsonMode = args.includes('--json');
1558
+ const localMode = hasFlag(args, '--local') || hasFlag(args, '--workspace') || hasFlag(args, '--operator');
1559
+ if (localMode) {
1560
+ let payload;
1561
+ try {
1562
+ payload = collectLocalXpProjection(args);
1563
+ } catch (error) {
1564
+ console.error(`Failed to collect local XP: ${error.message}`);
1565
+ process.exit(1);
1566
+ }
1567
+ if (jsonMode) {
1568
+ console.log(JSON.stringify(payload));
1569
+ return;
1570
+ }
1571
+ render(payload);
1572
+ return;
1573
+ }
1574
+
1575
+ const ensured = await ensureValidCredentials(apiRequestJson);
1576
+ if (ensured.error) {
1577
+ console.error(`Not logged in. Run: atris login, or use --local inside a workspace${ensured.detail ? ` (${ensured.detail})` : ''}`);
1578
+ process.exit(1);
1579
+ }
1580
+
1581
+ const result = await apiRequestJson('/profile/contribution-graph', {
1582
+ method: 'GET',
1583
+ token: ensured.credentials.token,
1584
+ });
1585
+ if (!result.ok) {
1586
+ console.error(`Failed to load XP graph: ${result.error || result.status}`);
1587
+ process.exit(1);
1588
+ }
1589
+
1590
+ if (jsonMode) {
1591
+ console.log(JSON.stringify(result.data || {}));
1592
+ return;
1593
+ }
1594
+
1595
+ render(result.data || {});
1596
+ }
1597
+
1598
+ module.exports = {
1599
+ xpCommand,
1600
+ buildContributionRows,
1601
+ buildCareerXpProjection,
1602
+ buildAllCareerXpProjection,
1603
+ buildCareerXpSessionCapsule,
1604
+ collectAllLocalXpProjection,
1605
+ collectLocalXpProjection,
1606
+ receiptFromTaskEpisode,
1607
+ render,
1608
+ };