atris 3.35.0 → 3.36.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 (133) hide show
  1. package/AGENTS.md +37 -0
  2. package/README.md +5 -3
  3. package/atris/GETTING_STARTED.md +1 -1
  4. package/atris/atris.md +3 -0
  5. package/atris/policies/day-loop-voice.md +102 -0
  6. package/atris/policies/outbound-artifact-gate.md +2 -0
  7. package/atris/skills/design/SKILL.md +56 -32
  8. package/atris/skills/endgame/SKILL.md +12 -6
  9. package/atris/skills/engines/SKILL.md +22 -4
  10. package/atris/skills/fable-method/SKILL.md +66 -0
  11. package/atris/skills/improve/SKILL.md +65 -45
  12. package/atris/skills/youtube/SKILL.md +10 -1
  13. package/atris.md +2 -0
  14. package/ax +147 -19
  15. package/bin/atris.js +565 -265
  16. package/commands/activate.js +194 -88
  17. package/commands/agents.js +166 -0
  18. package/commands/autoland.js +459 -107
  19. package/commands/autopilot-front.js +20 -2
  20. package/commands/autopilot.js +118 -2
  21. package/commands/avail.js +407 -0
  22. package/commands/bench.js +188 -0
  23. package/commands/brain.js +3 -0
  24. package/commands/brief.js +651 -0
  25. package/commands/business-sync.js +192 -6
  26. package/commands/clean.js +50 -24
  27. package/commands/close.js +1083 -0
  28. package/commands/cloud.js +245 -0
  29. package/commands/compile.js +292 -1
  30. package/commands/computer.js +150 -3
  31. package/commands/dream.js +365 -0
  32. package/commands/drill.js +371 -0
  33. package/commands/engine.js +993 -32
  34. package/commands/experiments.js +28 -0
  35. package/commands/feedback.js +34 -12
  36. package/commands/fleet-report.js +206 -0
  37. package/commands/gm.js +23 -0
  38. package/commands/goal.js +247 -0
  39. package/commands/improve.js +642 -26
  40. package/commands/init.js +72 -44
  41. package/commands/interview.js +67 -1
  42. package/commands/land.js +152 -52
  43. package/commands/lifecycle.js +39 -3
  44. package/commands/log.js +84 -1
  45. package/commands/loops.js +220 -16
  46. package/commands/meet.js +220 -0
  47. package/commands/member.js +511 -34
  48. package/commands/mission.js +3029 -339
  49. package/commands/next.js +137 -0
  50. package/commands/now.js +220 -25
  51. package/commands/one-lap.js +776 -0
  52. package/commands/orb.js +314 -0
  53. package/commands/pack-craft.js +179 -0
  54. package/commands/pack.js +823 -0
  55. package/commands/play.js +3 -2
  56. package/commands/probe.js +30 -3
  57. package/commands/pulse.js +241 -46
  58. package/commands/push.js +260 -82
  59. package/commands/rainmaker.js +49 -0
  60. package/commands/report.js +415 -0
  61. package/commands/scout.js +147 -0
  62. package/commands/search.js +363 -0
  63. package/commands/skill.js +47 -3
  64. package/commands/slop.js +50 -2
  65. package/commands/soul.js +1 -1
  66. package/commands/stream.js +861 -0
  67. package/commands/study.js +693 -0
  68. package/commands/sync.js +67 -54
  69. package/commands/task.js +1346 -117
  70. package/commands/team.js +73 -0
  71. package/commands/verify.js +96 -0
  72. package/commands/watch.js +303 -0
  73. package/commands/wish.js +500 -0
  74. package/commands/workflow.js +11 -5
  75. package/commands/worktree.js +234 -13
  76. package/commands/xp.js +29 -11
  77. package/lib/auto-accept-certified.js +331 -34
  78. package/lib/autoland.js +319 -54
  79. package/lib/ax-auto-lane.js +79 -0
  80. package/lib/bench/context.js +147 -0
  81. package/lib/bench/engines.js +141 -0
  82. package/lib/bench/report.js +140 -0
  83. package/lib/bench/runner.js +512 -0
  84. package/lib/brief-ledger.js +350 -0
  85. package/lib/cloud-mission.js +259 -0
  86. package/lib/codex-flight.js +154 -0
  87. package/lib/default-runner.js +45 -0
  88. package/lib/default-verifier.js +70 -0
  89. package/lib/engine-registry.js +232 -0
  90. package/lib/experiments/daily.js +640 -0
  91. package/lib/fleet.js +2219 -67
  92. package/lib/improve-vitals-html.js +171 -0
  93. package/lib/known-commands.js +58 -0
  94. package/lib/loop-doctor.js +416 -0
  95. package/lib/member-switches.js +144 -0
  96. package/lib/mission-room.js +1 -0
  97. package/lib/mission-root.js +52 -0
  98. package/lib/next-moves.js +327 -10
  99. package/lib/one-lap-validator.js +60 -0
  100. package/lib/orb-context.js +477 -0
  101. package/lib/orb-scorecard.js +224 -0
  102. package/lib/policy-lessons.js +52 -1
  103. package/lib/pulse.js +277 -3
  104. package/lib/receipt-block.js +168 -0
  105. package/lib/receipt-evidence.js +65 -4
  106. package/lib/router-brain.js +352 -0
  107. package/lib/runner-command.js +10 -0
  108. package/lib/self-drive.js +258 -0
  109. package/lib/short-name.js +103 -0
  110. package/lib/spawn-env.js +18 -0
  111. package/lib/state-detection.js +56 -1
  112. package/lib/sync-status.js +59 -0
  113. package/lib/task-db.js +108 -29
  114. package/lib/task-proof.js +23 -1
  115. package/lib/team-presence.js +260 -0
  116. package/lib/tool-result-encode.js +7 -0
  117. package/lib/trust-tiers.js +90 -0
  118. package/lib/usage.js +107 -0
  119. package/lib/voice-gate.js +163 -0
  120. package/lib/wish-audit.js +1368 -0
  121. package/lib/wish-delegate.js +1840 -0
  122. package/lib/wish-design.js +110 -0
  123. package/lib/wish-stats.js +183 -0
  124. package/lib/wish-store.js +354 -0
  125. package/lib/zip.js +221 -0
  126. package/package.json +3 -1
  127. package/templates/loops/atris/loops/LOOPS.md +55 -0
  128. package/templates/loops/atris/loops/TICK.md +24 -0
  129. package/templates/loops/atris/loops/feedback.md +22 -0
  130. package/templates/loops/atris/loops/quality.md +22 -0
  131. package/templates/loops/atris/wiki/systems/loops.md +41 -0
  132. package/utils/api.js +5 -1
  133. package/utils/auth.js +57 -21
@@ -11,7 +11,7 @@
11
11
  *
12
12
  * This is the CLI entrypoint for the headline paid capability. The member
13
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
14
+ * or the user is not logged in, it falls back to one local mission tick
15
15
  * (same loop, local inference) instead of erroring silently.
16
16
  *
17
17
  * The orchestrator (runImprove) takes injected deps so the network, auth,
@@ -24,6 +24,11 @@ const path = require('path');
24
24
  const { spawnSync } = require('child_process');
25
25
  const { apiRequestJson, getApiBaseUrl } = require('../utils/api');
26
26
  const { loadCredentials } = require('../utils/auth');
27
+ const pulse = require('../lib/pulse');
28
+ const { cronInstalled } = require('./pulse');
29
+ const close = require('./close');
30
+ const { readUsage } = require('../lib/usage');
31
+ const { knownCommands } = require('../lib/known-commands');
27
32
 
28
33
  /**
29
34
  * Expand a leading `~` to the real home directory for LOCAL filesystem
@@ -40,8 +45,10 @@ function expandHome(p) {
40
45
  }
41
46
 
42
47
  const SCORECARD_SCHEMA = 'atris.improve_tick.v1';
48
+ const IMPROVE_VITALS_SCHEMA = 'atris.improve_vitals.v1';
43
49
  const DEFAULT_TIMEOUT_MS = 300000;
44
50
  const VALID_MODES = new Set(['full', 'plan', 'delegate']);
51
+ const DAY_MS = 24 * 60 * 60 * 1000;
45
52
 
46
53
  /**
47
54
  * Resolve the improve endpoint path relative to the configured API base.
@@ -54,6 +61,22 @@ function improveApiPath(baseUrl) {
54
61
  return base.endsWith('/api') ? '/improve' : '/api/improve';
55
62
  }
56
63
 
64
+ function hostedApiCannotReachLocalWorkspace(workspace, baseUrl) {
65
+ if (!path.isAbsolute(String(workspace || ''))) return false;
66
+ try {
67
+ if (!fs.statSync(workspace).isDirectory()) return false;
68
+ const hostname = new URL(baseUrl).hostname.replace(/^\[|\]$/g, '').toLowerCase();
69
+ const loopback = hostname === 'localhost'
70
+ || hostname.endsWith('.localhost')
71
+ || hostname === '::1'
72
+ || hostname === '0.0.0.0'
73
+ || hostname.startsWith('127.');
74
+ return !loopback;
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
79
+
57
80
  function parseImproveArgs(argv = []) {
58
81
  const args = Array.isArray(argv) ? [...argv] : [];
59
82
  const opts = {
@@ -133,7 +156,7 @@ function summarizeImproveResponse(data = {}) {
133
156
  }
134
157
 
135
158
  /**
136
- * Decide whether to fall back to a local autopilot tick. Fallback only when
159
+ * Decide whether to fall back to one local mission tick. Fallback only when
137
160
  * the backend is genuinely unavailable: no auth, or unreachable (status 0).
138
161
  * A real HTTP error (insufficient credits 402, server error 5xx) is reported
139
162
  * honestly — we never silently run local work and bill nothing on what was a
@@ -144,9 +167,28 @@ function shouldFallbackLocal({ creds, apiResult } = {}) {
144
167
  if (!apiResult) return { fallback: false, reason: 'no_result' };
145
168
  if (apiResult.ok) return { fallback: false, reason: 'api_ok' };
146
169
  if (apiResult.status === 0) return { fallback: true, reason: 'unreachable' };
170
+ // The hosted backend validates workspace_path against its own filesystem,
171
+ // so a local-only folder 403s even for an authed, funded user. That is an
172
+ // unreachable-workspace condition — run the same tick locally instead of
173
+ // dying on it. Other 403s (real permission failures) are still reported.
174
+ if (apiResult.status === 403 && isWorkspaceNotAllowedError(apiResult)) {
175
+ return { fallback: true, reason: 'workspace_not_on_backend' };
176
+ }
147
177
  return { fallback: false, reason: `api_error_${apiResult.status}` };
148
178
  }
149
179
 
180
+ const WORKSPACE_NOT_ALLOWED_TEXT = 'workspace_path must be under an allowed directory';
181
+
182
+ function isWorkspaceNotAllowedError(apiResult = {}) {
183
+ const candidates = [
184
+ apiResult.error,
185
+ apiResult.error && apiResult.error.error,
186
+ apiResult.data && apiResult.data.detail,
187
+ apiResult.data && apiResult.data.detail && apiResult.data.detail.error,
188
+ ];
189
+ return candidates.some((c) => typeof c === 'string' && c.includes(WORKSPACE_NOT_ALLOWED_TEXT));
190
+ }
191
+
150
192
  function buildScorecardRow(summary = {}, meta = {}) {
151
193
  return {
152
194
  schema: SCORECARD_SCHEMA,
@@ -249,6 +291,239 @@ function localHourMinute(d = new Date()) {
249
291
  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
250
292
  }
251
293
 
294
+ function readJsonFile(file, fallback = null) {
295
+ try {
296
+ if (!fs.existsSync(file)) return fallback;
297
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
298
+ } catch {
299
+ return fallback;
300
+ }
301
+ }
302
+
303
+ function readJsonlFile(file) {
304
+ try {
305
+ if (!fs.existsSync(file)) return [];
306
+ return fs.readFileSync(file, 'utf8')
307
+ .split(/\r?\n/)
308
+ .map((line) => line.trim())
309
+ .filter(Boolean)
310
+ .map((line) => {
311
+ try {
312
+ return JSON.parse(line);
313
+ } catch {
314
+ return null;
315
+ }
316
+ })
317
+ .filter(Boolean);
318
+ } catch {
319
+ return [];
320
+ }
321
+ }
322
+
323
+ function timestampMs(value) {
324
+ const ms = Date.parse(String(value || ''));
325
+ return Number.isFinite(ms) ? ms : null;
326
+ }
327
+
328
+ function plural(n, word) {
329
+ return `${n} ${word}${n === 1 ? '' : 's'}`;
330
+ }
331
+
332
+ function formatReward(value) {
333
+ const n = Number(value) || 0;
334
+ if (Number.isInteger(n)) return String(n);
335
+ return String(Number(n.toFixed(2)));
336
+ }
337
+
338
+ function agePhrase(ts, nowMs = Date.now()) {
339
+ const ms = timestampMs(ts);
340
+ if (ms == null) return null;
341
+ const delta = Math.max(0, nowMs - ms);
342
+ if (delta < 60 * 1000) return 'just now';
343
+ if (delta < 60 * 60 * 1000) return `${plural(Math.round(delta / (60 * 1000)), 'minute')} ago`;
344
+ if (delta < DAY_MS) return `${plural(Math.round(delta / (60 * 60 * 1000)), 'hour')} ago`;
345
+ if (delta < 30 * DAY_MS) return `${plural(Math.round(delta / DAY_MS), 'day')} ago`;
346
+ return `on ${new Date(ms).toISOString().slice(0, 10)}`;
347
+ }
348
+
349
+ function plainSentence(value) {
350
+ return String(value || '')
351
+ .replace(/\s+/g, ' ')
352
+ .trim()
353
+ .toLowerCase();
354
+ }
355
+
356
+ function latestByTime(rows, fields) {
357
+ let latest = null;
358
+ let latestMs = -Infinity;
359
+ for (const row of Array.isArray(rows) ? rows : []) {
360
+ for (const field of fields) {
361
+ const ms = timestampMs(row && row[field]);
362
+ if (ms != null && ms > latestMs) {
363
+ latest = row;
364
+ latestMs = ms;
365
+ }
366
+ }
367
+ }
368
+ return latest ? { row: latest, ms: latestMs } : null;
369
+ }
370
+
371
+ function scoutFindingLanded(row = {}) {
372
+ if (!row || typeof row !== 'object') return false;
373
+ if (row.finding_landed === true || row.finding === true) return true;
374
+ if (Array.isArray(row.findings) && row.findings.length > 0) return true;
375
+ if (row.result && typeof row.result === 'object') {
376
+ if (row.result.finding_landed === true || row.result.finding === true) return true;
377
+ if (Array.isArray(row.result.findings) && row.result.findings.length > 0) return true;
378
+ }
379
+ const landing = row.last_landing || (row.result && row.result.landing) || row.landing;
380
+ if (landing && typeof landing === 'object') {
381
+ const text = `${landing.finding || ''} ${landing.findings || ''}`.trim();
382
+ if (text) return true;
383
+ }
384
+ return false;
385
+ }
386
+
387
+ function collectImproveVitals(options = {}, deps = {}) {
388
+ const root = expandHome(options.workspace || process.cwd());
389
+ const nowDate = options.now ? new Date(options.now) : new Date();
390
+ const nowMs = Number.isFinite(nowDate.getTime()) ? nowDate.getTime() : Date.now();
391
+ const today = localDateKey(new Date(nowMs));
392
+
393
+ const readPulse = deps.readPulseReceipts || pulse.readPulseReceipts;
394
+ const pulsePath = (deps.pulseReceiptsPath || pulse.pulseReceiptsPath)(root);
395
+ const receipts = readPulse(root);
396
+ const finished = (Array.isArray(receipts) ? receipts : []).filter((row) => row && row.phase === 'finished');
397
+ const latestPulse = latestByTime(finished, ['ts']);
398
+ const rewardSince = nowMs - DAY_MS;
399
+ const rewardToday = finished.reduce((sum, row) => {
400
+ const ms = timestampMs(row.ts);
401
+ return ms != null && ms >= rewardSince ? sum + (Number(row.reward) || 0) : sum;
402
+ }, 0);
403
+ const heartbeatAge = latestPulse ? agePhrase(latestPulse.row.ts, nowMs) : null;
404
+ const heartbeatSentence = latestPulse
405
+ ? `the scheduled improve heartbeat last beat ${heartbeatAge} and earned ${formatReward(rewardToday)} reward today.`
406
+ : `the scheduled improve heartbeat has not beaten yet and earned ${formatReward(rewardToday)} reward today.`;
407
+ const cronFn = deps.cronInstalled || cronInstalled;
408
+ // Per-repo slots (pr 310): the crontab marker is derived from the root, so
409
+ // the check must ask about THIS repo's markers, not the legacy default.
410
+ const slotMarkers = (() => {
411
+ try { return pulse.resolvePulseSlot(root).markers; } catch { return undefined; }
412
+ })();
413
+ const installed = Boolean(slotMarkers ? cronFn(slotMarkers) : cronFn());
414
+ const installNudge = installed ? null : 'the scheduled improve loop is off. turn it on: atris pulse install --model claude-sonnet-5';
415
+
416
+ const experimentsPath = path.join(root, '.atris', 'state', 'experiments-daily.json');
417
+ const experiments = readJsonFile(experimentsPath, {});
418
+ const history = Array.isArray(experiments && experiments.history) ? experiments.history : [];
419
+ const experimentRanToday = String(experiments && experiments.last_run_date || '') === today;
420
+ const exploitSentence = `${experimentRanToday ? 'todays experiment already ran' : 'no experiment yet today'}, with ${plural(history.length, 'total experiment')}.`;
421
+
422
+ const missionsPath = path.join(root, '.atris', 'state', 'missions.jsonl');
423
+ const missions = readJsonlFile(missionsPath);
424
+ const scoutMissions = missions.filter((row) => {
425
+ const owner = String(row && row.owner || '').toLowerCase();
426
+ return owner === 'scout' || owner === 'signal-scout' || owner.endsWith('-scout');
427
+ });
428
+ const latestScout = latestByTime(scoutMissions, [
429
+ 'last_tick_at',
430
+ 'last_tick_finished_at',
431
+ 'finished_at',
432
+ 'updated_at',
433
+ 'created_at',
434
+ 'started_at',
435
+ ]);
436
+ const scoutAge = latestScout ? agePhrase(new Date(latestScout.ms).toISOString(), nowMs) : null;
437
+ const findingLanded = latestScout ? scoutFindingLanded(latestScout.row) : false;
438
+ const exploreSentence = latestScout
439
+ ? `the scout last explored ${scoutAge} and ${findingLanded ? 'landed a finding' : 'no finding landed'}.`
440
+ : 'the scout has not explored yet and no finding landed.';
441
+
442
+ const openFlags = (deps.openFlags || close.openFlags)(root, { now: new Date(nowMs) });
443
+ const sweep = (deps.sweepState || close.sweepState)(root, new Date(nowMs), { dryRun: true });
444
+ const overdue = Array.isArray(sweep.overdue) ? sweep.overdue : openFlags.filter((flag) => flag && flag.overdue);
445
+ const topOverdueSentence = overdue[0] ? plainSentence((deps.sweepLine || close.sweepLine)(overdue[0])) : null;
446
+ const excreteSentence = `the excretion loop has ${plural(openFlags.length, 'open loop')} and ${plural(overdue.length, 'overdue loop')}.`;
447
+
448
+ const usageRows = (deps.readUsage || readUsage)(root, { sinceDays: 7, now: new Date(nowMs).toISOString() });
449
+ const known = deps.knownCommands || knownCommands;
450
+ const knownSet = new Set(known);
451
+ const usedThisWeek = new Set((Array.isArray(usageRows) ? usageRows : [])
452
+ .map((row) => row && row.cmd)
453
+ .filter((cmd) => knownSet.has(cmd)));
454
+ const usageSentence = `you used ${usedThisWeek.size} of ${known.length} known commands this week.`;
455
+
456
+ const heartbeat = {
457
+ receipts_path_exists: fs.existsSync(pulsePath),
458
+ last_finished_at: latestPulse ? latestPulse.row.ts : null,
459
+ last_finished_ago: heartbeatAge,
460
+ reward_last_24h: Number(formatReward(rewardToday)),
461
+ cron_installed: installed,
462
+ sentence: plainSentence(heartbeatSentence),
463
+ };
464
+ const exploit = {
465
+ ran_today: experimentRanToday,
466
+ total_experiments: history.length,
467
+ last_run_date: experiments && experiments.last_run_date || null,
468
+ sentence: plainSentence(exploitSentence),
469
+ };
470
+ const explore = {
471
+ total_scout_missions: scoutMissions.length,
472
+ last_tick_at: latestScout ? new Date(latestScout.ms).toISOString() : null,
473
+ last_tick_ago: scoutAge,
474
+ finding_landed: findingLanded,
475
+ sentence: plainSentence(exploreSentence),
476
+ };
477
+ const excrete = {
478
+ open: openFlags.length,
479
+ overdue: overdue.length,
480
+ top_overdue_sentence: topOverdueSentence,
481
+ sentence: plainSentence(excreteSentence),
482
+ };
483
+ const usage = {
484
+ used_this_week: usedThisWeek.size,
485
+ known_commands: known.length,
486
+ sentence: plainSentence(usageSentence),
487
+ };
488
+
489
+ const sentences = [
490
+ heartbeat.sentence,
491
+ exploit.sentence,
492
+ explore.sentence,
493
+ excrete.sentence,
494
+ ...(topOverdueSentence ? [`the top overdue loop says ${topOverdueSentence}`] : []),
495
+ usage.sentence,
496
+ ];
497
+ const groups = [
498
+ [heartbeat.sentence, installNudge].filter(Boolean),
499
+ [exploit.sentence],
500
+ [explore.sentence],
501
+ [excrete.sentence, ...(topOverdueSentence ? [`the top overdue loop says ${topOverdueSentence}`] : [])],
502
+ [usage.sentence],
503
+ ];
504
+
505
+ return {
506
+ schema: IMPROVE_VITALS_SCHEMA,
507
+ generated_at: new Date(nowMs).toISOString(),
508
+ heartbeat,
509
+ exploit,
510
+ explore,
511
+ excrete,
512
+ usage,
513
+ install_nudge: installNudge,
514
+ sentences,
515
+ groups,
516
+ };
517
+ }
518
+
519
+ function formatImproveVitals(vitals = {}) {
520
+ const groups = Array.isArray(vitals.groups) ? vitals.groups : [];
521
+ return groups
522
+ .map((group) => group.filter(Boolean).map(plainSentence).join('\n'))
523
+ .filter(Boolean)
524
+ .join('\n\n');
525
+ }
526
+
252
527
  /**
253
528
  * Append a human-readable tick entry to today's journal under ## Notes.
254
529
  * The skill contract says every tick lands in the journal; the JSONL
@@ -288,23 +563,108 @@ function resolveAtrisBin() {
288
563
  return process.env.ATRIS_BIN || 'atris';
289
564
  }
290
565
 
566
+ // The improve contract is one call -> one tick. Use the mission runtime
567
+ // directly so the tick count and verifier result come back as structured JSON
568
+ // instead of hiding several mission ticks inside one autopilot leg.
569
+ const LOCAL_FALLBACK_ARGS = ['mission', 'run', '--due', '--headless', '--max-ticks', '1', '--complete-on-pass', '--json'];
570
+
571
+ function localFallbackArgs(budgetSec) {
572
+ return LOCAL_FALLBACK_ARGS.concat(['--max-wall', String(Math.max(60, Math.round(budgetSec)))]);
573
+ }
574
+
575
+ function localSummaryText(tick = {}, mission = {}) {
576
+ const candidates = [
577
+ tick.claude && tick.claude.summary,
578
+ tick.atris2 && tick.atris2.summary,
579
+ tick.drill && tick.drill.summary,
580
+ mission.objective,
581
+ ];
582
+ return String(candidates.find((value) => String(value || '').trim()) || 'verified local improvement')
583
+ .replace(/\s+/g, ' ')
584
+ .trim();
585
+ }
586
+
587
+ /**
588
+ * Turn one mission-run JSON result into the same stable summary the paid API
589
+ * returns. A local tick earns the conservative local reward (+1) only after
590
+ * its real verifier passes; missing proof is a failed improve call.
591
+ */
592
+ function summarizeLocalMissionRun(payload = {}) {
593
+ const ticks = Array.isArray(payload.ticks) ? payload.ticks : [];
594
+ const tickCount = Number(payload.tick_count != null ? payload.tick_count : ticks.length);
595
+ if (!payload.ok || payload.action !== 'mission_run') {
596
+ throw new Error(`local improve did not run a mission tick${payload.reason ? `: ${payload.reason}` : ''}`);
597
+ }
598
+ if (tickCount !== 1 || ticks.length !== 1) {
599
+ throw new Error(`local improve must run exactly one tick; got ${tickCount}`);
600
+ }
601
+ const tick = ticks[0] || {};
602
+ if (tick.status !== 'ran') {
603
+ throw new Error(`local improve tick did not run${tick.reason ? `: ${tick.reason}` : ''}`);
604
+ }
605
+ if (tick.claude?.skipped === true || ['caller-session-runner', 'no-claude-mode'].includes(tick.reason)) {
606
+ throw new Error('local improve worker did not run');
607
+ }
608
+ if (tick.verifier_passed !== true) {
609
+ throw new Error('local improve verifier did not pass');
610
+ }
611
+ const mission = payload.mission || {};
612
+ const files = tick.worktree && Array.isArray(tick.worktree.new_since_baseline_sample)
613
+ ? tick.worktree.new_since_baseline_sample
614
+ : [];
615
+ return {
616
+ shipped: localSummaryText(tick, mission),
617
+ reward: 1,
618
+ verify: true,
619
+ credits: 0,
620
+ files,
621
+ model: mission.model || mission.runner || null,
622
+ taskId: mission.task_id || mission.current_task_id || null,
623
+ elapsedMs: null,
624
+ scorecardWritten: false,
625
+ };
626
+ }
627
+
291
628
  function runLocalFallback(opts = {}) {
292
629
  const bin = resolveAtrisBin();
293
630
  const isScript = bin.endsWith('.js');
294
631
  const cmd = isScript ? process.execPath : bin;
295
- const argv = (isScript ? [bin] : []).concat(['autopilot', '--auto', '--iterations=1']);
632
+ // Tell autopilot its time budget so it lands gracefully ("budget spent")
633
+ // instead of being SIGKILLed mid-leg and reporting a failed tick. The spawn
634
+ // timeout stays as a backstop, one minute past the budget.
635
+ const budgetSec = Math.max(60, Number(opts.timeoutSec) || 600);
636
+ const argv = (isScript ? [bin] : []).concat(localFallbackArgs(budgetSec));
296
637
  const r = spawnSync(cmd, argv, {
297
638
  cwd: opts.workspace || process.cwd(),
298
639
  encoding: 'utf8',
299
640
  env: process.env,
300
- stdio: opts.json ? ['ignore', 'pipe', 'pipe'] : 'inherit',
301
- timeout: Math.max(60, Number(opts.timeoutSec) || 600) * 1000,
641
+ // Mission output is JSON even for the human-facing improve command because
642
+ // proof must be parsed before improve can claim success or write a receipt.
643
+ stdio: ['ignore', 'pipe', 'pipe'],
644
+ timeout: (budgetSec + 60) * 1000,
302
645
  });
646
+ let payload = null;
647
+ let summary = null;
648
+ let error = null;
649
+ if (r.status === 0) {
650
+ try {
651
+ payload = JSON.parse(String(r.stdout || '').trim());
652
+ summary = summarizeLocalMissionRun(payload);
653
+ } catch (e) {
654
+ error = e.message;
655
+ }
656
+ } else {
657
+ error = String(r.stderr || '').trim().split('\n').filter(Boolean).slice(-1)[0]
658
+ || `local improve exited ${r.status == null ? 1 : r.status}`;
659
+ }
303
660
  return {
304
- ok: r.status === 0,
661
+ ok: r.status === 0 && Boolean(summary),
305
662
  status: r.status == null ? 1 : r.status,
306
663
  stdout: r.stdout || '',
307
664
  stderr: r.stderr || '',
665
+ payload,
666
+ summary,
667
+ error,
308
668
  };
309
669
  }
310
670
 
@@ -330,23 +690,89 @@ async function runImprove(opts = {}, deps = {}) {
330
690
  const timeoutSec = Math.round((opts.timeoutMs || DEFAULT_TIMEOUT_MS) / 1000);
331
691
  const startedAt = now();
332
692
  const creds = loadCreds();
693
+ const shippingTick = (opts.mode || 'full') === 'full' && !opts.dryRun;
694
+ const localFallbackEligible = opts.fallback && shippingTick;
695
+
696
+ const finishLocalFallback = (reason, apiResult = null) => {
697
+ const local = localFn({ workspace, json: opts.json, timeoutSec });
698
+ const finishedAt = now();
699
+ if (!local.ok || !local.summary || local.summary.verify !== true) {
700
+ return {
701
+ ok: false,
702
+ source: 'local',
703
+ reason,
704
+ error: local.error || 'local improve verifier did not pass',
705
+ local,
706
+ ...(apiResult ? { apiResult } : {}),
707
+ startedAt,
708
+ finishedAt,
709
+ };
710
+ }
711
+ const row = buildScorecardRow(local.summary, {
712
+ source: 'local',
713
+ mode: opts.mode || 'full',
714
+ ts: finishedAt,
715
+ member: opts.member,
716
+ });
717
+ let scorecardPath;
718
+ let journalPath;
719
+ try {
720
+ scorecardPath = writeRow(workspace, row);
721
+ journalPath = writeJournal(workspace, local.summary, { source: 'local', member: opts.member });
722
+ } catch (e) {
723
+ return {
724
+ ok: false,
725
+ source: 'local',
726
+ reason,
727
+ error: `local improve receipt write failed: ${e.message}`,
728
+ local,
729
+ ...(apiResult ? { apiResult } : {}),
730
+ startedAt,
731
+ finishedAt,
732
+ };
733
+ }
734
+ return {
735
+ ok: true,
736
+ source: 'local',
737
+ reason,
738
+ summary: local.summary,
739
+ scorecardPath,
740
+ journalPath,
741
+ receipt: 'written',
742
+ row,
743
+ local,
744
+ ...(apiResult ? { apiResult } : {}),
745
+ startedAt,
746
+ finishedAt,
747
+ };
748
+ };
333
749
 
334
750
  // No auth → local fallback (or report if fallback disabled).
335
751
  if (!creds || !creds.token) {
336
- if (!opts.fallback) {
752
+ if (!localFallbackEligible) {
337
753
  return {
338
754
  ok: false, source: 'none', reason: 'no_auth',
339
- error: 'Not logged in and --no-fallback set. Run: atris login',
755
+ error: shippingTick
756
+ ? 'Not logged in and --no-fallback set. Run: atris login'
757
+ : 'Not logged in. Plan, delegate, and dry-run modes require the hosted API; run: atris login',
340
758
  startedAt, finishedAt: now(),
341
759
  };
342
760
  }
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() };
761
+ log('not logged in — falling back to one local mission tick');
762
+ return finishLocalFallback('no_auth');
763
+ }
764
+
765
+ // A hosted backend cannot read an absolute workspace that only exists on
766
+ // this machine. Skip the known-impossible request and use the same verified
767
+ // local mission fallback that its workspace-path 403 would have selected.
768
+ const apiBase = baseFn();
769
+ if (localFallbackEligible && hostedApiCannotReachLocalWorkspace(workspace, apiBase)) {
770
+ log('hosted backend cannot reach local workspace; falling back to one local mission tick');
771
+ return finishLocalFallback('workspace_not_on_backend');
346
772
  }
347
773
 
348
774
  // Attempt the paid API tick.
349
- const apiPath = improveApiPath(baseFn());
775
+ const apiPath = improveApiPath(apiBase);
350
776
  const body = buildImprovePayload({ ...opts, workspace });
351
777
  const apiResult = await apiFn(apiPath, {
352
778
  method: 'POST',
@@ -361,7 +787,7 @@ async function runImprove(opts = {}, deps = {}) {
361
787
  // Only a real, shipping tick earns a receipt. Plan/delegate/dry-run ship
362
788
  // nothing, and an error inside an ok envelope (e.g. "workspace not found")
363
789
  // is not a shipped change — none of these should write a scorecard/journal.
364
- const shipped = (opts.mode || 'full') === 'full' && !opts.dryRun && !summary.error;
790
+ const shipped = shippingTick && !summary.error;
365
791
  if (!shipped) {
366
792
  return { ok: true, source: 'api', summary, scorecardPath: null, journalPath: null, receipt: 'skipped', startedAt, finishedAt };
367
793
  }
@@ -382,10 +808,9 @@ async function runImprove(opts = {}, deps = {}) {
382
808
  }
383
809
 
384
810
  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() };
811
+ if (decide.fallback && localFallbackEligible) {
812
+ log(`backend ${decide.reason} — falling back to one local mission tick`);
813
+ return finishLocalFallback(decide.reason, apiResult);
389
814
  }
390
815
 
391
816
  // Real, answerable failure (e.g. insufficient credits, server error). Report it.
@@ -417,9 +842,16 @@ function formatImproveReport(result = {}) {
417
842
  }
418
843
  if (result.source === 'local') {
419
844
  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]}`);
845
+ lines.push(` reason: backend ${result.reason} — ran one local mission tick instead`);
846
+ if (result.ok) {
847
+ const s = result.summary || {};
848
+ lines.push(` task: ${s.shipped || '(no description returned)'}`);
849
+ lines.push(` verify: pass`);
850
+ lines.push(` reward: ${s.reward != null ? s.reward : '?'}`);
851
+ if (s.files && s.files.length) lines.push(` files: ${s.files.join(', ')}`);
852
+ if (result.scorecardPath) lines.push(` scorecard: ${path.relative(process.cwd(), result.scorecardPath)}`);
853
+ } else if (result.error) {
854
+ lines.push(` error: ${result.error}`);
423
855
  }
424
856
  return lines.join('\n');
425
857
  }
@@ -430,16 +862,21 @@ function formatImproveReport(result = {}) {
430
862
  }
431
863
 
432
864
  function showHelp() {
433
- console.log(`atris improve — run one paid RL improvement tick
865
+ console.log(`atris improve - show the self-improvement metabolism vitals
434
866
 
435
867
  Usage:
436
- atris improve [mode] [options]
868
+ atris improve
869
+ atris improve --json
870
+ atris improve doctor [--json] [--fix] [--check <kind>]
871
+ atris improve tick [mode] [options]
872
+ atris improve [mode|history] [options]
437
873
 
438
874
  Modes (positional or --mode):
439
875
  full plan + build + verify + score (default)
440
876
  plan return the plan only, no changes
441
877
  delegate queue the tick for a local Claude Code session
442
878
  history show the tick history (reward trend, credits, pass rate)
879
+ doctor scan loop receipts and optionally file one repair mission
443
880
 
444
881
  Options:
445
882
  --member <name> attribute the tick to a member (the loop's owner)
@@ -448,17 +885,183 @@ Options:
448
885
  --no-fallback do not fall back to a local tick if the backend is down
449
886
  --workspace <p> workspace path (default: cwd)
450
887
  --timeout <sec> request timeout in seconds (default: 300)
888
+ --check <kind> exit 0 when the doctor finding is absent, or 1 when present
451
889
  --json machine-readable output (for the member loop)
452
890
  -h, --help this help
453
891
 
454
892
  Calls POST /api/improve, which ships one verifiable change and deducts
455
893
  Atris credits per successful tick. Writes a per-tick scorecard to
456
- .atris/state/scorecards.jsonl. Falls back to a local autopilot tick when
894
+ .atris/state/scorecards.jsonl. Falls back to one local mission tick when
457
895
  the backend is unreachable or you are not logged in.`);
458
896
  }
459
897
 
460
- async function run(argv = []) {
461
- const opts = parseImproveArgs(argv);
898
+ function isBareVitalsArgs(argv = []) {
899
+ return argv.length === 0 || (argv.length === 1 && argv[0] === '--json');
900
+ }
901
+
902
+ const LOOP_DOCTOR_OPEN_STATUSES = new Set(['planning', 'ready', 'running', 'paused', 'blocked']);
903
+
904
+ function loopDoctorKey(finding) {
905
+ return `[loop-doctor:${finding.kind}]`;
906
+ }
907
+
908
+ function openLoopDoctorMission(root, finding) {
909
+ const key = loopDoctorKey(finding);
910
+ const rows = readJsonlFile(path.join(root, '.atris', 'state', 'missions.jsonl'));
911
+ const latest = new Map();
912
+ rows.forEach((row, index) => {
913
+ const mission = row && row.mission && typeof row.mission === 'object' ? row.mission : row;
914
+ if (mission && typeof mission === 'object') latest.set(String(mission.id || `row-${index}`), mission);
915
+ });
916
+ return [...latest.values()].find((mission) => LOOP_DOCTOR_OPEN_STATUSES.has(String(mission.status || '').toLowerCase())
917
+ && String(mission.objective || '').includes(key)) || null;
918
+ }
919
+
920
+ function formatLoopDoctor(findings, fix = null, closed = []) {
921
+ const lines = [`loop doctor: ${findings.length} finding${findings.length === 1 ? '' : 's'}`];
922
+ findings.forEach((finding, index) => {
923
+ lines.push(`${index + 1}. ${finding.kind}: ${finding.evidence.detail} (${finding.count})`);
924
+ lines.push(` repair: ${finding.suggested_mission.objective}`);
925
+ });
926
+ if (!findings.length) lines.push('the recent improve-loop receipts are clean.');
927
+ if (fix && fix.action === 'mission_started') lines.push(`filed one mission: ${fix.mission.id}`);
928
+ if (fix && fix.action === 'mission_exists') lines.push(`no mission filed: ${fix.mission.id} already covers the top finding.`);
929
+ if (Array.isArray(closed) && closed.length) {
930
+ lines.push(`credited ${closed.length} verified repair${closed.length === 1 ? '' : 's'}: ${closed.map((row) => row.kind).join(', ')}`);
931
+ }
932
+ return lines.join('\n');
933
+ }
934
+
935
+ // Close the reward loop the doctor opens. When it files a repair it writes a
936
+ // reward-0 loop_doctor row; the fix only earns once its finding is provably
937
+ // gone. This reconciles those open rows against the current findings: a filed
938
+ // row whose kind no longer fires gets one closing credit, so the scorecard
939
+ // reads filed then paid instead of a pile of zero-reward filings. Deduped by
940
+ // mission id so a finding that clears stays closed even if it flickers back.
941
+ function reconcileLoopDoctorRewards(root, findings, now, appendScorecard) {
942
+ // Read the raw rows, not readTickHistory: that filters to improve_tick
943
+ // schema and would drop the loop_doctor rows this reconciliation is about.
944
+ const rows = readJsonlFile(path.join(expandHome(root), '.atris', 'state', 'scorecards.jsonl'));
945
+ const openByMission = new Map();
946
+ const closedMissions = new Set();
947
+ for (const row of rows) {
948
+ if (!row || row.schema !== 'atris.loop_doctor.v1' || !row.mission_id) continue;
949
+ if (row.closed === true) { closedMissions.add(String(row.mission_id)); continue; }
950
+ if (Number(row.reward) === 0) openByMission.set(String(row.mission_id), row);
951
+ }
952
+ const liveKinds = new Set(findings.map((finding) => finding.kind));
953
+ const closed = [];
954
+ for (const [missionId, row] of openByMission) {
955
+ if (closedMissions.has(missionId)) continue;
956
+ if (liveKinds.has(row.kind)) continue; // finding still fires, repair not proven yet
957
+ const closingRow = {
958
+ schema: 'atris.loop_doctor.v1',
959
+ ts: now.toISOString(),
960
+ source: 'loop_doctor',
961
+ kind: row.kind,
962
+ mission_id: missionId,
963
+ reward: 3,
964
+ closed: true,
965
+ note: 'repair verified: the finding it filed against is no longer present',
966
+ };
967
+ appendScorecard(root, closingRow);
968
+ closedMissions.add(missionId);
969
+ closed.push(closingRow);
970
+ }
971
+ return closed;
972
+ }
973
+
974
+ function runLoopDoctor(argv = [], deps = {}) {
975
+ const args = Array.isArray(argv) ? argv : [];
976
+ const json = args.includes('--json');
977
+ const fixRequested = args.includes('--fix');
978
+ const checkIndex = args.indexOf('--check');
979
+ const checkRequested = checkIndex !== -1;
980
+ const checkKind = checkRequested ? String(args[checkIndex + 1] || '').trim() : null;
981
+ const root = process.cwd();
982
+ const scan = deps.scanLoopReceipts || require('../lib/loop-doctor').scanLoopReceipts;
983
+ const findings = scan({ root, now: deps.now || new Date() });
984
+ let fix = null;
985
+
986
+ if (checkRequested) {
987
+ const finding = checkKind && findings.find((item) => item.kind === checkKind);
988
+ const check = {
989
+ kind: checkKind,
990
+ ok: Boolean(checkKind) && !finding,
991
+ reason: !checkKind ? 'missing finding kind' : (finding ? `${checkKind} is still present` : null),
992
+ };
993
+ const payload = { schema: 'atris.loop_doctor.v1', findings, fix, check };
994
+ if (json) console.log(JSON.stringify(payload));
995
+ else if (check.ok) console.log(`loop doctor check passed: no ${checkKind} finding.`);
996
+ else console.log(`loop doctor check failed: ${check.reason}.`);
997
+ return payload;
998
+ }
999
+
1000
+ // Credit any past filing whose finding has since cleared. This runs on plain
1001
+ // `doctor` and `doctor --fix`, never on `--check` (a pure verifier that must
1002
+ // not write), so the reward closes on the next observation after a repair.
1003
+ const reconcileNow = deps.now || new Date();
1004
+ const reconcileAppend = deps.appendScorecardRow || appendScorecardRow;
1005
+ const closed = reconcileLoopDoctorRewards(deps.workspace || root, findings, reconcileNow, reconcileAppend);
1006
+
1007
+ if (fixRequested && findings.length) {
1008
+ const finding = findings[0];
1009
+ const existing = openLoopDoctorMission(root, finding);
1010
+ if (existing) {
1011
+ fix = { action: 'mission_exists', finding_kind: finding.kind, mission: existing };
1012
+ } else {
1013
+ const suggested = finding.suggested_mission;
1014
+ const objective = `${loopDoctorKey(finding)} ${suggested.objective}`;
1015
+ const start = deps.startMission || require('./mission').startMission;
1016
+ const started = start([
1017
+ objective,
1018
+ '--owner', suggested.owner,
1019
+ '--verify', suggested.verifier,
1020
+ '--cadence', suggested.cadence,
1021
+ '--runner', 'auto',
1022
+ '--always-on',
1023
+ ], { silent: true });
1024
+ fix = { action: 'mission_started', finding_kind: finding.kind, mission: started.mission };
1025
+ const workspace = deps.workspace || root;
1026
+ const appendScorecard = deps.appendScorecardRow || appendScorecardRow;
1027
+ appendScorecard(workspace, {
1028
+ schema: 'atris.loop_doctor.v1',
1029
+ ts: (deps.now || new Date()).toISOString(),
1030
+ source: 'loop_doctor',
1031
+ kind: finding.kind,
1032
+ mission_id: started.mission.id,
1033
+ reward: 0,
1034
+ note: 'repair filed; reward is earned by the repair tick, not the filing',
1035
+ });
1036
+ }
1037
+ }
1038
+
1039
+ const payload = { schema: 'atris.loop_doctor.v1', findings, fix, closed };
1040
+ if (json) console.log(JSON.stringify(payload));
1041
+ else console.log(formatLoopDoctor(findings, fix, closed));
1042
+ return payload;
1043
+ }
1044
+
1045
+ async function run(argv = [], deps = {}) {
1046
+ const args = Array.isArray(argv) ? [...argv] : [];
1047
+ if (args[0] === 'doctor') {
1048
+ const result = runLoopDoctor(args.slice(1), deps);
1049
+ return result.check && !result.check.ok ? 1 : 0;
1050
+ }
1051
+ if (isBareVitalsArgs(args)) {
1052
+ const vitals = (deps.collectImproveVitals || collectImproveVitals)({ workspace: process.cwd() }, deps);
1053
+ if (args.includes('--json')) console.log(JSON.stringify(vitals));
1054
+ else console.log(formatImproveVitals(vitals));
1055
+ // Keep the live page in step with what the terminal just said.
1056
+ try {
1057
+ const file = require('../lib/improve-vitals-html').writeVitalsHtml(process.cwd(), deps);
1058
+ if (!args.includes('--json')) console.log(`\nlive page: ${file} (open it once, it refreshes itself)`);
1059
+ } catch { /* page is a bonus, never a failure */ }
1060
+ return 0;
1061
+ }
1062
+
1063
+ const routedArgs = args[0] === 'tick' ? args.slice(1) : args;
1064
+ const opts = parseImproveArgs(routedArgs);
462
1065
  if (opts.help) { showHelp(); return 0; }
463
1066
 
464
1067
  if (opts.history) {
@@ -468,7 +1071,8 @@ async function run(argv = []) {
468
1071
  return 0;
469
1072
  }
470
1073
 
471
- const result = await runImprove(opts, {
1074
+ const improveFn = deps.runImprove || runImprove;
1075
+ const result = await improveFn(opts, {
472
1076
  log: opts.json ? () => {} : (m) => console.error(` ${m}`),
473
1077
  });
474
1078
 
@@ -491,11 +1095,23 @@ module.exports = {
491
1095
  appendScorecardRow,
492
1096
  appendTickToJournal,
493
1097
  expandHome,
1098
+ collectImproveVitals,
1099
+ formatImproveVitals,
1100
+ isBareVitalsArgs,
494
1101
  readTickHistory,
495
1102
  summarizeTickHistory,
496
1103
  formatTickHistory,
497
1104
  improveApiPath,
498
1105
  formatImproveReport,
1106
+ runLoopDoctor,
1107
+ reconcileLoopDoctorRewards,
1108
+ formatLoopDoctor,
1109
+ openLoopDoctorMission,
1110
+ loopDoctorKey,
499
1111
  runLocalFallback,
1112
+ summarizeLocalMissionRun,
1113
+ LOCAL_FALLBACK_ARGS,
1114
+ localFallbackArgs,
500
1115
  SCORECARD_SCHEMA,
1116
+ IMPROVE_VITALS_SCHEMA,
501
1117
  };