atris 3.15.56 → 3.16.0

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 (44) hide show
  1. package/AGENTS.md +2 -2
  2. package/GETTING_STARTED.md +1 -1
  3. package/PERSONA.md +4 -4
  4. package/README.md +11 -11
  5. package/atris/skills/copy-editor/SKILL.md +30 -4
  6. package/atris/skills/improve/SKILL.md +18 -20
  7. package/atris/wiki/concepts/agent-activation-contract.md +5 -3
  8. package/atris/wiki/concepts/workspace-initialization-contract.md +4 -4
  9. package/atris/wiki/index.md +1 -0
  10. package/ax +522 -73
  11. package/bin/atris.js +32 -31
  12. package/commands/align.js +0 -14
  13. package/commands/apps.js +102 -1
  14. package/commands/autopilot.js +197 -22
  15. package/commands/brain.js +219 -34
  16. package/commands/brainstorm.js +0 -829
  17. package/commands/computer.js +45 -83
  18. package/commands/improve.js +501 -0
  19. package/commands/integrations.js +228 -0
  20. package/commands/lesson.js +44 -0
  21. package/commands/member.js +4498 -226
  22. package/commands/mission.js +302 -27
  23. package/commands/now.js +89 -1
  24. package/commands/radar.js +181 -56
  25. package/commands/skill.js +37 -6
  26. package/commands/soul.js +0 -4
  27. package/commands/task.js +5582 -517
  28. package/commands/terminal.js +14 -10
  29. package/commands/wiki.js +87 -1
  30. package/commands/workflow.js +288 -73
  31. package/commands/worktree.js +52 -15
  32. package/commands/xp.js +41 -65
  33. package/lib/auto-accept-certified.js +294 -0
  34. package/lib/file-ops.js +0 -184
  35. package/lib/member-alive.js +232 -0
  36. package/lib/policy-lessons.js +280 -0
  37. package/lib/receipt-evidence.js +64 -0
  38. package/lib/state-detection.js +34 -0
  39. package/lib/task-db.js +568 -16
  40. package/lib/task-proof.js +43 -0
  41. package/package.json +1 -1
  42. package/utils/auth.js +13 -4
  43. package/commands/research.js +0 -52
  44. package/lib/section-merge.js +0 -196
@@ -146,12 +146,6 @@ function formatWorkerName(worker) {
146
146
  return active === 'openai' ? 'OpenAI' : 'Claude';
147
147
  }
148
148
 
149
- function formatBillingMode(worker) {
150
- return activeWorker(worker) === 'openai'
151
- ? 'Atris credits'
152
- : 'Claude subscription lane';
153
- }
154
-
155
149
  function extractAttachedWorkspaceMismatch(...values) {
156
150
  const text = values
157
151
  .filter((value) => value !== null && value !== undefined)
@@ -412,12 +406,6 @@ async function printCloudSessionStatus(token, ctx, worker, model) {
412
406
  if (d.endpoint) console.log(` Endpoint: ${d.endpoint}`);
413
407
  }
414
408
 
415
- function formatDropdownLine(choice, selected) {
416
- const pointer = selected ? '>' : ' ';
417
- const label = selected ? ui.bold(choice.label) : choice.label;
418
- return `${pointer} ${label} ${ui.dim(choice.detail || '')}`.trimEnd();
419
- }
420
-
421
409
  function questionAsync(rl, question) {
422
410
  return new Promise((resolve) => rl.question(question, resolve));
423
411
  }
@@ -445,38 +433,6 @@ async function selectFromDropdown(title, choices) {
445
433
  return choices[0];
446
434
  }
447
435
 
448
- function describeLocalClaudeAuth() {
449
- if (process.env.ANTHROPIC_API_KEY) {
450
- return 'Local auth: ANTHROPIC_API_KEY set on this Mac';
451
- }
452
- const hasClaude = spawnSync('which', ['claude'], { encoding: 'utf8', timeout: 1000 }).status === 0;
453
- if (!hasClaude) {
454
- return 'Local auth: Claude CLI not found; use Cloud workspace or install Claude Code';
455
- }
456
- const status = spawnSync('claude', ['auth', 'status', '--json'], {
457
- encoding: 'utf8',
458
- timeout: 1500,
459
- stdio: 'pipe',
460
- });
461
- if (status.error && status.error.code === 'ETIMEDOUT') {
462
- return 'Local auth: Claude CLI installed, auth check timed out; Cloud subscription does not carry over';
463
- }
464
- const raw = String(status.stdout || status.stderr || '').trim();
465
- try {
466
- const parsed = JSON.parse(raw);
467
- if (parsed.loggedIn || parsed.status === 'logged_in' || parsed.authMethod) {
468
- const plan = parsed.subscriptionType || parsed.plan || parsed.authMethod || 'connected';
469
- return `Local auth: Claude logged in on this Mac (${plan})`;
470
- }
471
- } catch {
472
- // Fall through to text checks.
473
- }
474
- if (/logged\s*in|subscription|max|pro/i.test(raw)) {
475
- return 'Local auth: Claude appears logged in on this Mac';
476
- }
477
- return 'Local auth: not confirmed; run `claude login` on this Mac or choose Cloud workspace';
478
- }
479
-
480
436
  async function chooseComputerSurface(hasBusinessBinding, hasLocalHarness) {
481
437
  if (!useInteractiveTerminalUi()) {
482
438
  return hasBusinessBinding ? 'cloud' : 'local';
@@ -723,22 +679,6 @@ function findAtrisCodeTerminal() {
723
679
  return null;
724
680
  }
725
681
 
726
- function findAtrisCodePython(terminalPath) {
727
- const envPython = process.env.ATRIS_CODE_PYTHON;
728
- if (envPython && fs.existsSync(envPython)) return envPython;
729
- if (!terminalPath) return 'python3';
730
-
731
- const projectRoot = path.dirname(path.dirname(terminalPath));
732
- const candidates = [
733
- path.join(projectRoot, 'venv', 'bin', 'python3'),
734
- path.join(projectRoot, '.venv', 'bin', 'python3'),
735
- ];
736
- for (const p of candidates) {
737
- if (fs.existsSync(p)) return p;
738
- }
739
- return 'python3';
740
- }
741
-
742
682
  function computerLocalLegacy(extraArgs = []) {
743
683
  printModeBanner('LOCAL', process.cwd(), [
744
684
  'Current folder is the workspace.',
@@ -2550,34 +2490,56 @@ async function streamBusinessChatResult(token, ctx, executionId, rl = null, opti
2550
2490
 
2551
2491
  errors = 0;
2552
2492
  let done = false;
2553
- for (const event of (events.data?.events || [])) {
2554
- fromIndex++;
2555
- if (event.type === 'assistant_text' && event.content) {
2556
- sawVisibleOutput = true;
2557
- process.stdout.write(event.content);
2558
- } else if (event.type === 'result' && event.result && !sawVisibleOutput) {
2559
- sawVisibleOutput = true;
2560
- process.stdout.write(String(event.result));
2561
- } else if (!options.quiet && event.type === 'tool_use' && event.tool) {
2562
- const arg = event.input?.file_path || event.input?.path || event.input?.pattern || event.input?.command || '';
2563
- if (arg) {
2564
- console.log(`\n [${event.tool}] ${String(arg).slice(0, 120)}`);
2565
- } else {
2566
- console.log(`\n [${event.tool}]`);
2493
+ const emitEvents = (items, { showTools = true } = {}) => {
2494
+ let batchDone = false;
2495
+ for (const event of (items || [])) {
2496
+ if ((event.type === 'assistant_text' || event.type === 'text') && event.content) {
2497
+ sawVisibleOutput = true;
2498
+ process.stdout.write(event.content);
2499
+ } else if (event.type === 'result' && event.result && !sawVisibleOutput) {
2500
+ sawVisibleOutput = true;
2501
+ process.stdout.write(String(event.result));
2502
+ } else if (showTools && !options.quiet && event.type === 'tool_use' && event.tool) {
2503
+ const arg = event.input?.file_path || event.input?.path || event.input?.pattern || event.input?.command || '';
2504
+ if (arg) {
2505
+ console.log(`\n [${event.tool}] ${String(arg).slice(0, 120)}`);
2506
+ } else {
2507
+ console.log(`\n [${event.tool}]`);
2508
+ }
2509
+ } else if (event.type === 'error') {
2510
+ if (event.error) console.error(`\n${event.error}`);
2511
+ terminalStatus = 'error';
2512
+ batchDone = true;
2513
+ break;
2514
+ } else if (event.type === 'complete') {
2515
+ terminalStatus = 'completed';
2516
+ batchDone = true;
2517
+ break;
2567
2518
  }
2568
- } else if (event.type === 'error') {
2569
- if (event.error) console.error(`\n${event.error}`);
2570
- terminalStatus = 'error';
2571
- done = true;
2572
- break;
2573
- } else if (event.type === 'complete') {
2574
- terminalStatus = 'completed';
2575
- done = true;
2576
- break;
2577
2519
  }
2520
+ return batchDone;
2521
+ };
2522
+
2523
+ const batch = events.data?.events || [];
2524
+ done = emitEvents(batch);
2525
+ const nextIndex = events.data?.next_index;
2526
+ if (Number.isInteger(nextIndex) && nextIndex >= fromIndex) {
2527
+ fromIndex = nextIndex;
2528
+ } else {
2529
+ fromIndex += batch.length;
2578
2530
  }
2579
2531
 
2580
2532
  if (done || ['completed', 'error', 'failed', 'cancelled'].includes(events.data?.status)) {
2533
+ if (!sawVisibleOutput && events.data?.status === 'completed') {
2534
+ const fullEvents = await apiRequestJson(
2535
+ `/business/${ctx.businessId}/chat/events?execution_id=${executionId}&workspace_id=${ctx.workspaceId}&from_index=0`,
2536
+ { method: 'GET', token, timeoutMs: 60000 }
2537
+ );
2538
+ if (fullEvents.ok) {
2539
+ emitEvents(fullEvents.data?.events || [], { showTools: false });
2540
+ }
2541
+ }
2542
+
2581
2543
  if (!process.stdout.write('\n')) {
2582
2544
  // no-op: keep line handling stable
2583
2545
  }
@@ -0,0 +1,501 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * atris improve — run one paid RL improvement tick on the workspace.
5
+ *
6
+ * Calls POST /api/improve on the backend, which plans one task, builds it,
7
+ * runs the verify command, scores it, and deducts Atris credits per
8
+ * successful tick. Returns what shipped + reward and writes a per-tick
9
+ * scorecard row to .atris/state/scorecards.jsonl (the receipt the brain
10
+ * ledger already counts).
11
+ *
12
+ * This is the CLI entrypoint for the headline paid capability. The member
13
+ * loop and the /improve skill both call it. If the backend is unreachable
14
+ * or the user is not logged in, it falls back to a local autopilot tick
15
+ * (same loop, local inference) instead of erroring silently.
16
+ *
17
+ * The orchestrator (runImprove) takes injected deps so the network, auth,
18
+ * fallback, and scorecard writes can all be faked in tests.
19
+ */
20
+
21
+ const fs = require('fs');
22
+ const os = require('os');
23
+ const path = require('path');
24
+ const { spawnSync } = require('child_process');
25
+ const { apiRequestJson, getApiBaseUrl } = require('../utils/api');
26
+ const { loadCredentials } = require('../utils/auth');
27
+
28
+ /**
29
+ * Expand a leading `~` to the real home directory for LOCAL filesystem
30
+ * writes. The path sent to the backend is left untouched — a remote tick
31
+ * may target a server-side `~/...` workspace the server expands itself — but
32
+ * local scorecard/journal writes must never create a literal `~` directory.
33
+ * (Surfaced by a live plan tick whose `~/arena/...` arg wrote junk locally.)
34
+ */
35
+ function expandHome(p) {
36
+ if (typeof p === 'string' && (p === '~' || p.startsWith('~/'))) {
37
+ return path.join(os.homedir(), p.slice(1));
38
+ }
39
+ return p;
40
+ }
41
+
42
+ const SCORECARD_SCHEMA = 'atris.improve_tick.v1';
43
+ const DEFAULT_TIMEOUT_MS = 300000;
44
+ const VALID_MODES = new Set(['full', 'plan', 'delegate']);
45
+
46
+ /**
47
+ * Resolve the improve endpoint path relative to the configured API base.
48
+ * The backend mounts the router at /api/improve. The CLI's default base
49
+ * (https://api.atris.ai/api) already includes /api, so we append /improve;
50
+ * a bare base (e.g. http://localhost:8000) needs the full /api/improve.
51
+ */
52
+ function improveApiPath(baseUrl) {
53
+ const base = String(baseUrl || '').replace(/\/+$/, '');
54
+ return base.endsWith('/api') ? '/improve' : '/api/improve';
55
+ }
56
+
57
+ function parseImproveArgs(argv = []) {
58
+ const args = Array.isArray(argv) ? [...argv] : [];
59
+ const opts = {
60
+ mode: 'full',
61
+ model: null,
62
+ member: null,
63
+ dryRun: false,
64
+ json: false,
65
+ fallback: true,
66
+ workspace: process.cwd(),
67
+ timeoutMs: DEFAULT_TIMEOUT_MS,
68
+ help: false,
69
+ history: false,
70
+ };
71
+
72
+ for (let i = 0; i < args.length; i++) {
73
+ const a = args[i];
74
+ if (a === '--help' || a === '-h') { opts.help = true; continue; }
75
+ if (a === 'history' || a === '--history') { opts.history = true; continue; }
76
+ if (a === '--json') { opts.json = true; continue; }
77
+ if (a === '--no-fallback') { opts.fallback = false; continue; }
78
+ if (a === '--dry-run' || a === 'dry-run' || a === 'dry_run') { opts.dryRun = true; continue; }
79
+ if (a === '--mode') { const v = args[++i]; if (v) opts.mode = v; continue; }
80
+ if (a === '--model') { const v = args[++i]; if (v) opts.model = v; continue; }
81
+ if (a === '--member') { const v = args[++i]; if (v) opts.member = v; continue; }
82
+ if (a === '--workspace') { const v = args[++i]; if (v) opts.workspace = v; continue; }
83
+ if (a === '--timeout') { const v = Number(args[++i]); if (v > 0) opts.timeoutMs = Math.round(v * 1000); continue; }
84
+ if (a.startsWith('--timeout=')) { const v = Number(a.split('=')[1]); if (v > 0) opts.timeoutMs = Math.round(v * 1000); continue; }
85
+ // positional mode (plan|full|delegate)
86
+ if (!a.startsWith('-') && VALID_MODES.has(a)) { opts.mode = a; continue; }
87
+ }
88
+
89
+ if (!VALID_MODES.has(opts.mode)) opts.mode = 'full';
90
+ return opts;
91
+ }
92
+
93
+ function buildImprovePayload(opts = {}) {
94
+ const body = {
95
+ workspace: opts.workspace || process.cwd(),
96
+ mode: opts.mode || 'full',
97
+ };
98
+ if (opts.model) body.model = opts.model;
99
+ if (opts.dryRun) body.dry_run = true;
100
+ if (opts.businessId) body.business_id = opts.businessId;
101
+ return body;
102
+ }
103
+
104
+ /**
105
+ * Normalize the /api/improve response into a stable summary, reading every
106
+ * field defensively. The full-mode ImproveResponse omits credits_deducted
107
+ * (credits are still billed server-side), so credits may be null even on a
108
+ * successful, charged tick — callers must not assume it is present.
109
+ */
110
+ function summarizeImproveResponse(data = {}) {
111
+ const d = data && typeof data === 'object' ? data : {};
112
+ const verify = d.verify_passed != null ? d.verify_passed
113
+ : (d.verify_pass != null ? d.verify_pass : null);
114
+ const credits = d.credits_deducted != null ? d.credits_deducted
115
+ : (d.credits_charged != null ? d.credits_charged : null);
116
+ const files = Array.isArray(d.files_written) ? d.files_written
117
+ : Array.isArray(d.files_changed) ? d.files_changed
118
+ : Array.isArray(d.files) ? d.files : [];
119
+ const reward = typeof d.reward === 'number' ? d.reward
120
+ : (d.reward != null && !Number.isNaN(Number(d.reward)) ? Number(d.reward) : null);
121
+ return {
122
+ shipped: d.what_shipped || d.summary || d.task_description || d.task || null,
123
+ reward,
124
+ verify,
125
+ credits,
126
+ files,
127
+ model: d.model_used || d.model || null,
128
+ taskId: d.task_id || d.taskId || null,
129
+ elapsedMs: typeof d.elapsed_ms === 'number' ? d.elapsed_ms : null,
130
+ scorecardWritten: d.scorecard_written === true,
131
+ error: d.error || null,
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Decide whether to fall back to a local autopilot tick. Fallback only when
137
+ * the backend is genuinely unavailable: no auth, or unreachable (status 0).
138
+ * A real HTTP error (insufficient credits 402, server error 5xx) is reported
139
+ * honestly — we never silently run local work and bill nothing on what was a
140
+ * real, answerable failure.
141
+ */
142
+ function shouldFallbackLocal({ creds, apiResult } = {}) {
143
+ if (!creds || !creds.token) return { fallback: true, reason: 'no_auth' };
144
+ if (!apiResult) return { fallback: false, reason: 'no_result' };
145
+ if (apiResult.ok) return { fallback: false, reason: 'api_ok' };
146
+ if (apiResult.status === 0) return { fallback: true, reason: 'unreachable' };
147
+ return { fallback: false, reason: `api_error_${apiResult.status}` };
148
+ }
149
+
150
+ function buildScorecardRow(summary = {}, meta = {}) {
151
+ return {
152
+ schema: SCORECARD_SCHEMA,
153
+ ts: meta.ts || new Date().toISOString(),
154
+ source: meta.source || 'api',
155
+ member: meta.member || null,
156
+ mode: meta.mode || 'full',
157
+ reward: summary.reward != null ? summary.reward : null,
158
+ verify_passed: summary.verify != null ? summary.verify : null,
159
+ credits_deducted: summary.credits != null ? summary.credits : null,
160
+ what_shipped: summary.shipped || null,
161
+ files_written: Array.isArray(summary.files) ? summary.files : [],
162
+ model_used: summary.model || null,
163
+ task_id: summary.taskId || null,
164
+ elapsed_ms: summary.elapsedMs != null ? summary.elapsedMs : null,
165
+ };
166
+ }
167
+
168
+ function appendScorecardRow(workspace, row) {
169
+ const dir = path.join(expandHome(workspace), '.atris', 'state');
170
+ fs.mkdirSync(dir, { recursive: true });
171
+ const file = path.join(dir, 'scorecards.jsonl');
172
+ fs.appendFileSync(file, `${JSON.stringify(row)}\n`, 'utf8');
173
+ return file;
174
+ }
175
+
176
+ /**
177
+ * Read the improve-tick scorecard rows the loop writes. This is the
178
+ * substrate that makes the loop recursive: each tick appends a receipt,
179
+ * and the next tick (or a human) reads the accumulated rows to see whether
180
+ * the loop is actually compounding.
181
+ */
182
+ function readTickHistory(workspace) {
183
+ const file = path.join(expandHome(workspace), '.atris', 'state', 'scorecards.jsonl');
184
+ if (!fs.existsSync(file)) return [];
185
+ const rows = [];
186
+ for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
187
+ const trimmed = line.trim();
188
+ if (!trimmed) continue;
189
+ try {
190
+ const row = JSON.parse(trimmed);
191
+ if (row && row.schema === SCORECARD_SCHEMA) rows.push(row);
192
+ } catch {
193
+ // skip non-JSON / foreign rows
194
+ }
195
+ }
196
+ return rows;
197
+ }
198
+
199
+ /**
200
+ * Summarize the tick history into the compounding signal: how many ticks
201
+ * shipped, the reward trend, total credits spent, and the verify pass rate.
202
+ * This is the answer to "is the self-improvement loop getting better?".
203
+ */
204
+ function summarizeTickHistory(rows = []) {
205
+ const ticks = Array.isArray(rows) ? rows : [];
206
+ const verified = ticks.filter((r) => r.verify_passed === true);
207
+ const rewards = ticks.map((r) => (typeof r.reward === 'number' ? r.reward : 0));
208
+ const totalReward = rewards.reduce((a, b) => a + b, 0);
209
+ const totalCredits = ticks.reduce((a, r) => a + (typeof r.credits_deducted === 'number' ? r.credits_deducted : 0), 0);
210
+ const rewardTrend = ticks.map((r) => (typeof r.reward === 'number' ? r.reward : null));
211
+ return {
212
+ ticks: ticks.length,
213
+ shipped: verified.length,
214
+ passRate: ticks.length ? verified.length / ticks.length : 0,
215
+ totalReward,
216
+ avgReward: ticks.length ? totalReward / ticks.length : 0,
217
+ totalCredits,
218
+ rewardTrend,
219
+ first: ticks[0] || null,
220
+ latest: ticks[ticks.length - 1] || null,
221
+ };
222
+ }
223
+
224
+ function formatTickHistory(summary = {}) {
225
+ const lines = [];
226
+ lines.push('improve loop — tick history');
227
+ lines.push('');
228
+ lines.push(` ticks: ${summary.ticks}`);
229
+ lines.push(` shipped: ${summary.shipped}/${summary.ticks} (verify pass ${Math.round((summary.passRate || 0) * 100)}%)`);
230
+ lines.push(` reward: total ${summary.totalReward}, avg ${(summary.avgReward || 0).toFixed(1)}`);
231
+ lines.push(` credits: ${summary.totalCredits} deducted`);
232
+ if (summary.rewardTrend && summary.rewardTrend.length) {
233
+ lines.push(` trend: ${summary.rewardTrend.map((r) => (r == null ? '·' : r)).join(' → ')}`);
234
+ }
235
+ if (!summary.ticks) lines.push(' (no ticks yet — run `atris improve`)');
236
+ return lines.join('\n');
237
+ }
238
+
239
+ // Local calendar day — journal receipts are local workspace files, never UTC
240
+ // (see lessons.md: now-front-door-uses-local-date).
241
+ function localDateKey(d = new Date()) {
242
+ const y = d.getFullYear();
243
+ const m = String(d.getMonth() + 1).padStart(2, '0');
244
+ const day = String(d.getDate()).padStart(2, '0');
245
+ return `${y}-${m}-${day}`;
246
+ }
247
+
248
+ function localHourMinute(d = new Date()) {
249
+ return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
250
+ }
251
+
252
+ /**
253
+ * Append a human-readable tick entry to today's journal under ## Notes.
254
+ * The skill contract says every tick lands in the journal; the JSONL
255
+ * scorecard is the machine receipt, this is the operator-readable trail.
256
+ */
257
+ function appendTickToJournal(workspace, summary = {}, opts = {}) {
258
+ const dateKey = opts.dateKey || localDateKey();
259
+ const time = opts.time || localHourMinute();
260
+ const year = dateKey.slice(0, 4);
261
+ const logFile = path.join(expandHome(workspace), 'atris', 'logs', year, `${dateKey}.md`);
262
+ fs.mkdirSync(path.dirname(logFile), { recursive: true });
263
+
264
+ const source = opts.source || 'api';
265
+ const verify = summary.verify === true ? 'pass' : summary.verify === false ? 'fail' : 'unknown';
266
+ const credits = summary.credits != null ? summary.credits : 'server-side';
267
+ const owner = opts.member ? ` · member: ${opts.member}` : '';
268
+ const block = [
269
+ `### Improve Tick — ${time}`,
270
+ `- shipped: ${summary.shipped || '(no description)'}`,
271
+ `- verify: ${verify} · reward: ${summary.reward != null ? summary.reward : '?'} · credits: ${credits} · source: ${source}${owner}`,
272
+ '',
273
+ ].join('\n');
274
+
275
+ let content = fs.existsSync(logFile) ? fs.readFileSync(logFile, 'utf8') : `# ${dateKey}\n\n## Notes\n`;
276
+ if (content.includes('## Notes')) {
277
+ content = content.replace('## Notes\n', `## Notes\n\n${block}`);
278
+ } else {
279
+ content = `${content.replace(/\n*$/, '')}\n\n## Notes\n\n${block}`;
280
+ }
281
+ fs.writeFileSync(logFile, content, 'utf8');
282
+ return logFile;
283
+ }
284
+
285
+ function resolveAtrisBin() {
286
+ const local = path.join(__dirname, '..', 'bin', 'atris.js');
287
+ if (fs.existsSync(local)) return local;
288
+ return process.env.ATRIS_BIN || 'atris';
289
+ }
290
+
291
+ function runLocalFallback(opts = {}) {
292
+ const bin = resolveAtrisBin();
293
+ const isScript = bin.endsWith('.js');
294
+ const cmd = isScript ? process.execPath : bin;
295
+ const argv = (isScript ? [bin] : []).concat(['autopilot', '--auto', '--iterations=1']);
296
+ const r = spawnSync(cmd, argv, {
297
+ cwd: opts.workspace || process.cwd(),
298
+ encoding: 'utf8',
299
+ env: process.env,
300
+ stdio: opts.json ? ['ignore', 'pipe', 'pipe'] : 'inherit',
301
+ timeout: Math.max(60, Number(opts.timeoutSec) || 600) * 1000,
302
+ });
303
+ return {
304
+ ok: r.status === 0,
305
+ status: r.status == null ? 1 : r.status,
306
+ stdout: r.stdout || '',
307
+ stderr: r.stderr || '',
308
+ };
309
+ }
310
+
311
+ /**
312
+ * Run one improvement tick. Dependency-injected so tests can fake the
313
+ * network (apiRequestJson), auth (loadCredentials), the local fallback,
314
+ * and the scorecard sink.
315
+ *
316
+ * Returns a structured result:
317
+ * { ok, source: 'api'|'local'|'none', reason, summary?, scorecardPath?, local?, apiResult?, error? }
318
+ */
319
+ async function runImprove(opts = {}, deps = {}) {
320
+ const apiFn = deps.apiRequestJson || apiRequestJson;
321
+ const loadCreds = deps.loadCredentials || loadCredentials;
322
+ const localFn = deps.runLocalFallback || runLocalFallback;
323
+ const writeRow = deps.appendScorecardRow || appendScorecardRow;
324
+ const writeJournal = deps.appendTickToJournal || appendTickToJournal;
325
+ const baseFn = deps.getApiBaseUrl || getApiBaseUrl;
326
+ const now = deps.now || (() => new Date().toISOString());
327
+ const log = deps.log || (() => {});
328
+
329
+ const workspace = opts.workspace || process.cwd();
330
+ const timeoutSec = Math.round((opts.timeoutMs || DEFAULT_TIMEOUT_MS) / 1000);
331
+ const startedAt = now();
332
+ const creds = loadCreds();
333
+
334
+ // No auth → local fallback (or report if fallback disabled).
335
+ if (!creds || !creds.token) {
336
+ if (!opts.fallback) {
337
+ return {
338
+ ok: false, source: 'none', reason: 'no_auth',
339
+ error: 'Not logged in and --no-fallback set. Run: atris login',
340
+ startedAt, finishedAt: now(),
341
+ };
342
+ }
343
+ log('not logged in — falling back to a local autopilot tick');
344
+ const local = localFn({ workspace, json: opts.json, timeoutSec });
345
+ return { ok: local.ok, source: 'local', reason: 'no_auth', local, startedAt, finishedAt: now() };
346
+ }
347
+
348
+ // Attempt the paid API tick.
349
+ const apiPath = improveApiPath(baseFn());
350
+ const body = buildImprovePayload({ ...opts, workspace });
351
+ const apiResult = await apiFn(apiPath, {
352
+ method: 'POST',
353
+ token: creds.token,
354
+ body,
355
+ timeoutMs: opts.timeoutMs || DEFAULT_TIMEOUT_MS,
356
+ });
357
+
358
+ if (apiResult && apiResult.ok) {
359
+ const summary = summarizeImproveResponse(apiResult.data);
360
+ const finishedAt = now();
361
+ // Only a real, shipping tick earns a receipt. Plan/delegate/dry-run ship
362
+ // nothing, and an error inside an ok envelope (e.g. "workspace not found")
363
+ // is not a shipped change — none of these should write a scorecard/journal.
364
+ const shipped = (opts.mode || 'full') === 'full' && !opts.dryRun && !summary.error;
365
+ if (!shipped) {
366
+ return { ok: true, source: 'api', summary, scorecardPath: null, journalPath: null, receipt: 'skipped', startedAt, finishedAt };
367
+ }
368
+ const row = buildScorecardRow(summary, { source: 'api', mode: opts.mode || 'full', ts: finishedAt, member: opts.member });
369
+ let scorecardPath = null;
370
+ try {
371
+ scorecardPath = writeRow(workspace, row);
372
+ } catch (e) {
373
+ log(`scorecard write failed: ${e.message}`);
374
+ }
375
+ let journalPath = null;
376
+ try {
377
+ journalPath = writeJournal(workspace, summary, { source: 'api', member: opts.member });
378
+ } catch (e) {
379
+ log(`journal write failed: ${e.message}`);
380
+ }
381
+ return { ok: true, source: 'api', summary, scorecardPath, journalPath, receipt: 'written', row, startedAt, finishedAt };
382
+ }
383
+
384
+ const decide = shouldFallbackLocal({ creds, apiResult });
385
+ if (decide.fallback && opts.fallback) {
386
+ log(`backend ${decide.reason} — falling back to a local autopilot tick`);
387
+ const local = localFn({ workspace, json: opts.json, timeoutSec });
388
+ return { ok: local.ok, source: 'local', reason: decide.reason, local, apiResult, startedAt, finishedAt: now() };
389
+ }
390
+
391
+ // Real, answerable failure (e.g. insufficient credits, server error). Report it.
392
+ return {
393
+ ok: false, source: 'api', reason: decide.reason,
394
+ error: (apiResult && (apiResult.error || `HTTP ${apiResult.status}`)) || 'request failed',
395
+ apiResult, startedAt, finishedAt: now(),
396
+ };
397
+ }
398
+
399
+ function formatImproveReport(result = {}) {
400
+ const lines = [];
401
+ if (result.source === 'api' && result.ok) {
402
+ const s = result.summary || {};
403
+ lines.push('improved.');
404
+ lines.push('');
405
+ lines.push(` task: ${s.shipped || '(no description returned)'}`);
406
+ lines.push(` verify: ${s.verify === true ? 'pass' : s.verify === false ? 'FAIL' : 'unknown'}`);
407
+ lines.push(` reward: ${s.reward != null ? s.reward : '?'}`);
408
+ lines.push(` credits: ${s.credits != null ? s.credits : 'billed server-side (not echoed)'}`);
409
+ if (s.files && s.files.length) lines.push(` files: ${s.files.join(', ')}`);
410
+ if (s.model) lines.push(` model: ${s.model}`);
411
+ if (s.elapsedMs != null) lines.push(` time: ${(s.elapsedMs / 1000).toFixed(0)}s`);
412
+ if (result.scorecardPath) {
413
+ lines.push('');
414
+ lines.push(` scorecard: ${path.relative(process.cwd(), result.scorecardPath)}`);
415
+ }
416
+ return lines.join('\n');
417
+ }
418
+ if (result.source === 'local') {
419
+ lines.push(result.ok ? 'improved (local fallback).' : 'local fallback tick failed.');
420
+ lines.push(` reason: backend ${result.reason} — ran a local autopilot tick instead`);
421
+ if (result.local && !result.ok && result.local.stderr) {
422
+ lines.push(` error: ${result.local.stderr.trim().split('\n').slice(-1)[0]}`);
423
+ }
424
+ return lines.join('\n');
425
+ }
426
+ lines.push('improve tick did not run.');
427
+ lines.push(` reason: ${result.reason || 'unknown'}`);
428
+ if (result.error) lines.push(` error: ${result.error}`);
429
+ return lines.join('\n');
430
+ }
431
+
432
+ function showHelp() {
433
+ console.log(`atris improve — run one paid RL improvement tick
434
+
435
+ Usage:
436
+ atris improve [mode] [options]
437
+
438
+ Modes (positional or --mode):
439
+ full plan + build + verify + score (default)
440
+ plan return the plan only, no changes
441
+ delegate queue the tick for a local Claude Code session
442
+ history show the tick history (reward trend, credits, pass rate)
443
+
444
+ Options:
445
+ --member <name> attribute the tick to a member (the loop's owner)
446
+ --model <id> override the model (e.g. claude-sonnet-4-6)
447
+ --dry-run plan and run but do not commit
448
+ --no-fallback do not fall back to a local tick if the backend is down
449
+ --workspace <p> workspace path (default: cwd)
450
+ --timeout <sec> request timeout in seconds (default: 300)
451
+ --json machine-readable output (for the member loop)
452
+ -h, --help this help
453
+
454
+ Calls POST /api/improve, which ships one verifiable change and deducts
455
+ Atris credits per successful tick. Writes a per-tick scorecard to
456
+ .atris/state/scorecards.jsonl. Falls back to a local autopilot tick when
457
+ the backend is unreachable or you are not logged in.`);
458
+ }
459
+
460
+ async function run(argv = []) {
461
+ const opts = parseImproveArgs(argv);
462
+ if (opts.help) { showHelp(); return 0; }
463
+
464
+ if (opts.history) {
465
+ const summary = summarizeTickHistory(readTickHistory(opts.workspace));
466
+ if (opts.json) console.log(JSON.stringify(summary));
467
+ else console.log(formatTickHistory(summary));
468
+ return 0;
469
+ }
470
+
471
+ const result = await runImprove(opts, {
472
+ log: opts.json ? () => {} : (m) => console.error(` ${m}`),
473
+ });
474
+
475
+ if (opts.json) {
476
+ console.log(JSON.stringify(result));
477
+ } else {
478
+ console.log(formatImproveReport(result));
479
+ }
480
+ return result.ok ? 0 : 1;
481
+ }
482
+
483
+ module.exports = {
484
+ run,
485
+ runImprove,
486
+ parseImproveArgs,
487
+ buildImprovePayload,
488
+ summarizeImproveResponse,
489
+ shouldFallbackLocal,
490
+ buildScorecardRow,
491
+ appendScorecardRow,
492
+ appendTickToJournal,
493
+ expandHome,
494
+ readTickHistory,
495
+ summarizeTickHistory,
496
+ formatTickHistory,
497
+ improveApiPath,
498
+ formatImproveReport,
499
+ runLocalFallback,
500
+ SCORECARD_SCHEMA,
501
+ };