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
@@ -0,0 +1,224 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ const DAY_MS = 24 * 60 * 60 * 1000;
7
+ const DEFAULT_DAYS = 7;
8
+
9
+ function emptyOrbScorecard(days = DEFAULT_DAYS) {
10
+ return {
11
+ days,
12
+ picks: 0,
13
+ dispatches: 0,
14
+ dispatches_by_kind: {},
15
+ dispatches_by_engine: {},
16
+ ok: 0,
17
+ fail: 0,
18
+ orphaned: 0,
19
+ completion_rate: null,
20
+ median_duration_ms: null,
21
+ failures: [],
22
+ orphans: [],
23
+ };
24
+ }
25
+
26
+ function readText(file) {
27
+ try {
28
+ return fs.readFileSync(file, 'utf8');
29
+ } catch {
30
+ return '';
31
+ }
32
+ }
33
+
34
+ function readJsonl(file) {
35
+ const rows = [];
36
+ for (const line of readText(file).split(/\r?\n/)) {
37
+ const trimmed = line.trim();
38
+ if (!trimmed) continue;
39
+ try {
40
+ const row = JSON.parse(trimmed);
41
+ if (row && typeof row === 'object') rows.push(row);
42
+ } catch {}
43
+ }
44
+ return rows;
45
+ }
46
+
47
+ function sortedCounts(values) {
48
+ const counts = new Map();
49
+ for (const value of values) {
50
+ const key = typeof value === 'string' && value.trim() ? value.trim() : 'unknown';
51
+ counts.set(key, (counts.get(key) || 0) + 1);
52
+ }
53
+ return Object.fromEntries([...counts.entries()].sort(([left], [right]) => left.localeCompare(right)));
54
+ }
55
+
56
+ function median(values) {
57
+ const numbers = values.filter((value) => Number.isFinite(value) && value >= 0).sort((a, b) => a - b);
58
+ if (!numbers.length) return null;
59
+ const middle = Math.floor(numbers.length / 2);
60
+ return numbers.length % 2 === 1
61
+ ? numbers[middle]
62
+ : (numbers[middle - 1] + numbers[middle]) / 2;
63
+ }
64
+
65
+ function readOrbPicks(root, cutoffDay, today) {
66
+ let picks = 0;
67
+ for (const line of readText(path.join(root, 'now.md')).split(/\r?\n/)) {
68
+ const match = line.match(/^orb:\s+.+\s+·\s+(\d{4}-\d{2}-\d{2})\s*$/);
69
+ if (match && match[1] >= cutoffDay && match[1] <= today) picks += 1;
70
+ }
71
+ return picks;
72
+ }
73
+
74
+ function pidIsAlive(pid) {
75
+ const numericPid = Number(pid);
76
+ if (!Number.isInteger(numericPid) || numericPid <= 0) return false;
77
+ try {
78
+ process.kill(numericPid, 0);
79
+ return true;
80
+ } catch {
81
+ return false;
82
+ }
83
+ }
84
+
85
+ function pairOrbRuns(records) {
86
+ const terminalsByLogPath = new Map();
87
+ for (const row of records) {
88
+ if (row.status === 'dispatched') continue;
89
+ const logPath = String(row.logPath || '');
90
+ const terminals = terminalsByLogPath.get(logPath) || [];
91
+ terminals.push(row);
92
+ terminalsByLogPath.set(logPath, terminals);
93
+ }
94
+
95
+ const consumedTerminals = new Set();
96
+ const runs = [];
97
+ const orphans = [];
98
+ for (const row of records) {
99
+ if (row.status !== 'dispatched') continue;
100
+ const terminals = terminalsByLogPath.get(String(row.logPath || '')) || [];
101
+ const terminal = terminals.find((candidate) => !consumedTerminals.has(candidate));
102
+ if (terminal) {
103
+ consumedTerminals.add(terminal);
104
+ runs.push({ ...row, ...terminal, status: terminal.status });
105
+ continue;
106
+ }
107
+ const orphaned = !pidIsAlive(row.pid);
108
+ runs.push({ ...row, orphaned });
109
+ if (orphaned) orphans.push({ ...row, orphaned: true });
110
+ }
111
+
112
+ for (const row of records) {
113
+ if (row.status !== 'dispatched' && !consumedTerminals.has(row)) runs.push(row);
114
+ }
115
+ return { runs, orphans };
116
+ }
117
+
118
+ function readOrbScorecard(root, { days = DEFAULT_DAYS, now = Date.now() } = {}) {
119
+ const windowDays = Number(days);
120
+ if (!Number.isInteger(windowDays) || windowDays < 1) {
121
+ throw new Error('orb scorecard days must be a positive integer');
122
+ }
123
+ const nowMs = now instanceof Date ? now.getTime() : Number(now);
124
+ if (!Number.isFinite(nowMs)) throw new Error('orb scorecard now must be a valid time');
125
+
126
+ const scorecard = emptyOrbScorecard(windowDays);
127
+ const cutoffMs = nowMs - (windowDays * DAY_MS);
128
+ const cutoffDay = new Date(cutoffMs).toISOString().slice(0, 10);
129
+ const today = new Date(nowMs).toISOString().slice(0, 10);
130
+ scorecard.picks = readOrbPicks(root, cutoffDay, today);
131
+
132
+ const indexPath = path.join(root, '.atris', 'state', 'orb-runs', 'index.jsonl');
133
+ const records = readJsonl(indexPath).filter((row) => {
134
+ const ts = Date.parse(String(row.ts || ''));
135
+ return Number.isFinite(ts) && ts >= cutoffMs && ts <= nowMs;
136
+ });
137
+ const paired = pairOrbRuns(records);
138
+ const runs = paired.runs;
139
+ const terminalRuns = runs.filter((row) => row.status !== 'dispatched');
140
+
141
+ scorecard.dispatches = runs.length;
142
+ scorecard.dispatches_by_kind = sortedCounts(runs.map((row) => row.kind));
143
+ scorecard.dispatches_by_engine = sortedCounts(runs.map((row) => row.engine));
144
+ scorecard.ok = terminalRuns.filter((row) => Number(row.exitCode) === 0).length;
145
+ scorecard.fail = terminalRuns.filter((row) => Number(row.exitCode) !== 0).length + paired.orphans.length;
146
+ scorecard.orphaned = paired.orphans.length;
147
+ const outcomes = scorecard.ok + scorecard.fail;
148
+ scorecard.completion_rate = outcomes ? scorecard.ok / outcomes : null;
149
+ scorecard.median_duration_ms = median(terminalRuns.map((row) => Number(row.durationMs)));
150
+ scorecard.failures = terminalRuns
151
+ .filter((row) => Number(row.exitCode) !== 0)
152
+ .map((row) => ({
153
+ ts: row.ts,
154
+ label: row.label,
155
+ kind: row.kind,
156
+ engine: row.engine,
157
+ exitCode: Number(row.exitCode),
158
+ durationMs: Number(row.durationMs),
159
+ logPath: row.logPath,
160
+ error: row.error || null,
161
+ }));
162
+ scorecard.orphans = paired.orphans.map((row) => ({
163
+ ts: row.ts,
164
+ label: row.label,
165
+ kind: row.kind,
166
+ engine: row.engine,
167
+ logPath: row.logPath,
168
+ pid: Number(row.pid),
169
+ status: row.status,
170
+ }));
171
+ return scorecard;
172
+ }
173
+
174
+ function formatCounts(counts) {
175
+ const entries = Object.entries(counts || {});
176
+ return entries.length ? entries.map(([key, count]) => `${key} ${count}`).join(', ') : 'none';
177
+ }
178
+
179
+ function renderOrbScorecard(scorecard) {
180
+ const rate = scorecard.completion_rate == null
181
+ ? 'n/a'
182
+ : `${(scorecard.completion_rate * 100).toFixed(1)}%`;
183
+ const duration = scorecard.median_duration_ms == null
184
+ ? 'n/a'
185
+ : `${scorecard.median_duration_ms} ms`;
186
+ return [
187
+ `orb scorecard: ${scorecard.days} days`,
188
+ `picks: ${scorecard.picks}`,
189
+ `dispatches: ${scorecard.dispatches}`,
190
+ `by kind: ${formatCounts(scorecard.dispatches_by_kind)}`,
191
+ `by engine: ${formatCounts(scorecard.dispatches_by_engine)}`,
192
+ `outcomes: ${scorecard.ok} ok, ${scorecard.fail} fail`,
193
+ `orphaned: ${scorecard.orphaned}`,
194
+ `completion rate: ${rate}`,
195
+ `median duration: ${duration}`,
196
+ ].join('\n');
197
+ }
198
+
199
+ function parseOrbScorecardDays(args = []) {
200
+ let raw = String(DEFAULT_DAYS);
201
+ for (let index = 0; index < args.length; index += 1) {
202
+ const arg = args[index];
203
+ if (arg === '--days') {
204
+ raw = args[index + 1];
205
+ index += 1;
206
+ } else if (String(arg).startsWith('--days=')) {
207
+ raw = String(arg).slice('--days='.length);
208
+ }
209
+ }
210
+ const days = Number(raw);
211
+ if (!Number.isInteger(days) || days < 1) {
212
+ return { ok: false, error: `invalid --days value: ${raw == null ? '(missing)' : raw}` };
213
+ }
214
+ return { ok: true, days };
215
+ }
216
+
217
+ module.exports = {
218
+ DAY_MS,
219
+ DEFAULT_DAYS,
220
+ emptyOrbScorecard,
221
+ readOrbScorecard,
222
+ renderOrbScorecard,
223
+ parseOrbScorecardDays,
224
+ };
@@ -16,6 +16,7 @@ const POLICY_LESSONS_FILE = path.join('.atris', 'state', 'policy_lessons.json');
16
16
  const CAREER_XP_RECEIPTS_FILE = path.join('.atris', 'state', 'career_xp_receipts.jsonl');
17
17
  const TASK_EPISODES_FILE = path.join('.atris', 'state', 'task_episodes.jsonl');
18
18
  const SCORECARDS_FILE = path.join('.atris', 'state', 'scorecards.jsonl');
19
+ const DAY_MS = 24 * 60 * 60 * 1000;
19
20
 
20
21
  // Review actors that are agents, not the human gate. Mining must split the
21
22
  // two: agent self-review churn and human accept/bounce are different signals.
@@ -93,6 +94,7 @@ function mineProofPolicy(history, opts = {}) {
93
94
  const { receipts = [], episodes = [], scorecards = [] } = history || {};
94
95
  const minHumanReviewed = Number.isFinite(opts.minHumanReviewed) ? opts.minHumanReviewed : 10;
95
96
  const now = opts.now instanceof Date ? opts.now : new Date();
97
+ const orbWindowDays = Number.isInteger(opts.orbWindowDays) && opts.orbWindowDays > 0 ? opts.orbWindowDays : 7;
96
98
 
97
99
  const reviewed = episodes.filter((e) => e && e.rl && e.action && e.action.actor);
98
100
  const humanReviewed = reviewed.filter((e) => !isAgentActor(e.action.actor));
@@ -117,6 +119,15 @@ function mineProofPolicy(history, opts = {}) {
117
119
 
118
120
  const improveTicks = scorecards.filter((s) => s && s.schema === 'atris.improve_tick.v1');
119
121
  const brainScorecards = scorecards.filter((s) => s && s.schema === 'atris.brain.scorecard.v1');
122
+ const orbFailures = scorecards.filter((s) => s
123
+ && s.schema === 'atris.improve_tick.v1'
124
+ && s.source === 'orb'
125
+ && Number(s.orb_exit_code) !== 0);
126
+ const orbCutoff = now.getTime() - (orbWindowDays * DAY_MS);
127
+ const recentOrbFailures = orbFailures.filter((row) => {
128
+ const ts = Date.parse(String(row.ts || ''));
129
+ return Number.isFinite(ts) && ts >= orbCutoff && ts <= now.getTime();
130
+ });
120
131
  const avgReward = (rows) => (rows.length
121
132
  ? Math.round((rows.reduce((sum, r) => sum + (Number(r.reward) || 0), 0) / rows.length) * 100) / 100
122
133
  : null);
@@ -139,6 +150,11 @@ function mineProofPolicy(history, opts = {}) {
139
150
  total: scorecards.length,
140
151
  improve_ticks: { count: improveTicks.length, avg_reward: avgReward(improveTicks) },
141
152
  brain: { count: brainScorecards.length, avg_reward: avgReward(brainScorecards) },
153
+ orb: {
154
+ failures: orbFailures.length,
155
+ recent_failures: recentOrbFailures.length,
156
+ window_days: orbWindowDays,
157
+ },
142
158
  },
143
159
  };
144
160
 
@@ -173,6 +189,41 @@ function mineProofPolicy(history, opts = {}) {
173
189
  });
174
190
  }
175
191
 
192
+ if (recentOrbFailures.length > 0) {
193
+ const samples = recentOrbFailures.slice(0, 3).map((row) => {
194
+ const label = String(row.what_shipped || '').replace(/^orb job failed:\s*/i, '') || 'unlabelled job';
195
+ const logPath = row.orb_log_path || '.atris/state/orb-runs/index.jsonl';
196
+ return `${label} (${row.orb_engine || 'unknown'} exit ${row.orb_exit_code}, \`${logPath}\`)`;
197
+ });
198
+ const extra = recentOrbFailures.length > samples.length ? `, plus ${recentOrbFailures.length - samples.length} more` : '';
199
+ lessons.push({
200
+ id: 'orb-job-failures',
201
+ status: 'fail',
202
+ hint_when: null,
203
+ lesson: `${recentOrbFailures.length} orb job failure${recentOrbFailures.length === 1 ? '' : 's'} landed in the last ${orbWindowDays} days: ${samples.join('; ')}${extra}. Treat each log as an improvement candidate: inspect it, repair \`commands/orb.js\` dispatch or the picked job, then rerun the pick.`,
204
+ evidence: {
205
+ source: 'scorecards.orb_failure',
206
+ window_days: orbWindowDays,
207
+ failures: recentOrbFailures.map((row) => ({
208
+ ts: row.ts,
209
+ label: String(row.what_shipped || '').replace(/^orb job failed:\s*/i, ''),
210
+ engine: row.orb_engine,
211
+ exit_code: row.orb_exit_code,
212
+ log_path: row.orb_log_path,
213
+ next_task: row.next_task_suggestion,
214
+ })),
215
+ },
216
+ });
217
+ } else if (orbFailures.length > 0) {
218
+ lessons.push({
219
+ id: 'orb-job-failures',
220
+ status: 'pass',
221
+ hint_when: null,
222
+ lesson: `No orb job failures remain in the last ${orbWindowDays} days; the prior failure window aged out.`,
223
+ evidence: { source: 'scorecards.orb_failure', window_days: orbWindowDays, failures: [] },
224
+ });
225
+ }
226
+
176
227
  return {
177
228
  schema: 'atris.policy_lessons.v1',
178
229
  mined_at: now.toISOString(),
@@ -243,7 +294,7 @@ function syncLessonsMd(root, mined) {
243
294
  const today = (mined.mined_at || new Date().toISOString()).split('T')[0];
244
295
  const lines = (mined.lessons || []).map((lesson) => ({
245
296
  id: lesson.id,
246
- line: `- **[${today}] policy-${lesson.id}** — pass — ${lesson.lesson} (mined from ${mined.sources.career_xp_receipts} receipts / ${mined.sources.task_episodes} episodes / ${mined.sources.scorecards} scorecards)`,
297
+ line: `- **[${today}] policy-${lesson.id}** — ${lesson.status || 'pass'} — ${lesson.lesson} (mined from ${mined.sources.career_xp_receipts} receipts / ${mined.sources.task_episodes} episodes / ${mined.sources.scorecards} scorecards)`,
247
298
  }));
248
299
  if (!lines.length) return { path: lessonsPath, written: [] };
249
300
 
package/lib/pulse.js CHANGED
@@ -13,14 +13,18 @@
13
13
  // command (commands/pulse.js) wires it to the engine and the cron shell.
14
14
 
15
15
  const fs = require('fs');
16
+ const crypto = require('crypto');
17
+ const os = require('os');
16
18
  const path = require('path');
17
19
  const { DEFAULT_CLAUDE_RUNNER_MODEL } = require('./runner-command');
20
+ const { readOrbScorecard } = require('./orb-scorecard');
18
21
 
19
22
  const PULSE_RECEIPT_SCHEMA = 'atris.pulse_tick.v1';
20
23
  // Reuse the improve-tick scorecard schema so the brain + policy-lessons see
21
24
  // pulse reward as fresh feedback signal (source:'pulse' keeps it attributable).
22
25
  const SCORECARD_SCHEMA = 'atris.improve_tick.v1';
23
26
  const PULSE_MARKER = 'ATRIS_PULSE_SELF_IMPROVE';
27
+ const LEGACY_STATE_DIRNAME = 'atris-cli-self-improve';
24
28
  // Hourly at an off-clock minute (avoid :00/:30 fleet sync). Each tick spawns a
25
29
  // real worker + full verify, so default conservative; raise with --cadence.
26
30
  const DEFAULT_CADENCE_CRON = '23 * * * *';
@@ -47,6 +51,77 @@ function pulseLockDir(root) {
47
51
  return path.join(stateDir(root), 'pulse.lock');
48
52
  }
49
53
 
54
+ // --- install slot identity ---
55
+
56
+ function safeSlugPart(value) {
57
+ const cleaned = String(value || '')
58
+ .toLowerCase()
59
+ .replace(/[^a-z0-9._-]+/g, '-')
60
+ .replace(/-+/g, '-')
61
+ .replace(/^-+|-+$/g, '');
62
+ return cleaned || 'repo';
63
+ }
64
+
65
+ function pulseRepoSlug(root) {
66
+ const absoluteRoot = path.resolve(root || process.cwd());
67
+ const base = safeSlugPart(path.basename(absoluteRoot));
68
+ const hash = crypto.createHash('sha256').update(absoluteRoot).digest('hex').slice(0, 6);
69
+ return `${base}-${hash}`;
70
+ }
71
+
72
+ function pulseStateHome(root, homeDir = os.homedir()) {
73
+ return path.join(homeDir, '.atris', 'overnight', `pulse-${pulseRepoSlug(root)}`);
74
+ }
75
+
76
+ function pulseMarker(root) {
77
+ return `ATRIS_PULSE_${pulseRepoSlug(root).toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`;
78
+ }
79
+
80
+ function legacyPulseStateHome(homeDir = os.homedir()) {
81
+ return path.join(homeDir, '.atris', 'overnight', LEGACY_STATE_DIRNAME);
82
+ }
83
+
84
+ function readTickScriptRoot(stateHome) {
85
+ try {
86
+ const script = fs.readFileSync(path.join(stateHome, 'tick.sh'), 'utf8');
87
+ const match = script.match(/^ROOT=(?:"([^"\n]*)"|'([^'\n]*)'|([^\s\n]+))/m);
88
+ if (!match) return null;
89
+ return match[1] || match[2] || match[3] || null;
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ function legacyPulseStateMatchesRoot(root, homeDir = os.homedir()) {
96
+ const legacyStateHome = legacyPulseStateHome(homeDir);
97
+ const scriptRoot = readTickScriptRoot(legacyStateHome);
98
+ if (!scriptRoot) return false;
99
+ return path.resolve(scriptRoot) === path.resolve(root || process.cwd());
100
+ }
101
+
102
+ function resolvePulseSlot(root, options = {}) {
103
+ const homeDir = options.homeDir || os.homedir();
104
+ const stateHome = pulseStateHome(root, homeDir);
105
+ const marker = pulseMarker(root);
106
+ const legacyStateHome = legacyPulseStateHome(homeDir);
107
+ const legacyMatches = legacyPulseStateMatchesRoot(root, homeDir);
108
+ const hasStateHome = fs.existsSync(stateHome);
109
+ const activeStateHome = legacyMatches && !hasStateHome ? legacyStateHome : stateHome;
110
+ const activeMarker = legacyMatches && !hasStateHome ? PULSE_MARKER : marker;
111
+ const markers = legacyMatches ? [marker, PULSE_MARKER] : [marker];
112
+ return {
113
+ slug: pulseRepoSlug(root),
114
+ stateHome,
115
+ marker,
116
+ activeStateHome,
117
+ activeMarker,
118
+ legacyStateHome,
119
+ legacyMarker: PULSE_MARKER,
120
+ legacyMatches,
121
+ markers,
122
+ };
123
+ }
124
+
50
125
  // --- receipt + scorecard building (pure) ---
51
126
 
52
127
  function buildPulseReceipt(input = {}) {
@@ -65,6 +140,9 @@ function buildPulseReceipt(input = {}) {
65
140
  elapsed_ms: input.elapsedMs != null ? input.elapsedMs : null,
66
141
  prev_tick_stale: input.prevTickStale != null ? input.prevTickStale : false,
67
142
  reward: input.reward != null ? input.reward : null,
143
+ orb_scorecard: input.orbScorecard || null,
144
+ improvement_candidates: Array.isArray(input.improvementCandidates) ? input.improvementCandidates : [],
145
+ orb_ingest_error: input.orbIngestError || null,
68
146
  };
69
147
  }
70
148
 
@@ -83,6 +161,98 @@ function buildPulseScorecardRow(input = {}) {
83
161
  model_used: input.model || null,
84
162
  task_id: input.taskId || null,
85
163
  elapsed_ms: input.elapsedMs != null ? input.elapsedMs : null,
164
+ orb_scorecard: input.orbScorecard || null,
165
+ improvement_candidates: Array.isArray(input.improvementCandidates) ? input.improvementCandidates : [],
166
+ };
167
+ }
168
+
169
+ function orbRunKey(run = {}) {
170
+ return [run.ts, run.logPath, run.engine, run.label].map((value) => String(value || '')).join('|');
171
+ }
172
+
173
+ function orbFailureCandidate(run = {}, mode = 'orb_failure') {
174
+ const label = String(run.label || 'unlabelled orb job');
175
+ const engine = String(run.engine || 'unknown engine');
176
+ const exitCode = Number.isFinite(Number(run.exitCode)) ? Number(run.exitCode) : 1;
177
+ const logPath = String(run.logPath || '.atris/state/orb-runs/index.jsonl');
178
+ const orphaned = mode === 'orb_orphan';
179
+ return {
180
+ source: 'orb',
181
+ label,
182
+ engine,
183
+ exit_code: exitCode,
184
+ log_path: logPath,
185
+ next_task: orphaned
186
+ ? `investigate orphaned orb job "${label}" from ${engine}; open ${logPath}`
187
+ : `investigate failed orb job "${label}" from ${engine} exit ${exitCode}; open ${logPath}`,
188
+ };
189
+ }
190
+
191
+ function buildOrbFailureScorecardRow(run = {}, mode = 'orb_failure') {
192
+ const candidate = orbFailureCandidate(run, mode);
193
+ const orphaned = mode === 'orb_orphan';
194
+ const parsedTs = Date.parse(String(run.ts || ''));
195
+ return {
196
+ schema: SCORECARD_SCHEMA,
197
+ ts: Number.isFinite(parsedTs) ? new Date(parsedTs).toISOString() : new Date().toISOString(),
198
+ source: 'orb',
199
+ member: 'pulse',
200
+ mode,
201
+ reward: -1,
202
+ verify_passed: false,
203
+ credits_deducted: 0,
204
+ what_shipped: `orb job ${orphaned ? 'orphaned' : 'failed'}: ${candidate.label}`,
205
+ files_written: [],
206
+ model_used: candidate.engine,
207
+ task_id: null,
208
+ elapsed_ms: Number.isFinite(Number(run.durationMs)) ? Number(run.durationMs) : null,
209
+ next_task_suggestion: candidate.next_task,
210
+ orb_run_key: orbRunKey(run),
211
+ orb_kind: run.kind || 'unknown',
212
+ orb_engine: candidate.engine,
213
+ orb_exit_code: candidate.exit_code,
214
+ orb_log_path: candidate.log_path,
215
+ orb_error: run.error || null,
216
+ };
217
+ }
218
+
219
+ function orbSummaryForRail(scorecard) {
220
+ return {
221
+ days: scorecard.days,
222
+ picks: scorecard.picks,
223
+ dispatches: scorecard.dispatches,
224
+ dispatches_by_kind: scorecard.dispatches_by_kind,
225
+ dispatches_by_engine: scorecard.dispatches_by_engine,
226
+ ok: scorecard.ok,
227
+ fail: scorecard.fail,
228
+ orphaned: scorecard.orphaned,
229
+ completion_rate: scorecard.completion_rate,
230
+ median_duration_ms: scorecard.median_duration_ms,
231
+ };
232
+ }
233
+
234
+ function ingestOrbScorecard(root, options = {}) {
235
+ const scorecard = readOrbScorecard(root, options);
236
+ const existing = readJsonl(scorecardsPath(root));
237
+ const seen = new Set(existing.filter((row) => row && row.orb_run_key).map((row) => row.orb_run_key));
238
+ const written = [];
239
+ const actionableRuns = [
240
+ ...scorecard.failures.map((run) => ({ run, mode: 'orb_failure' })),
241
+ ...scorecard.orphans.map((run) => ({ run, mode: 'orb_orphan' })),
242
+ ];
243
+ for (const { run, mode } of actionableRuns) {
244
+ const row = buildOrbFailureScorecardRow(run, mode);
245
+ if (seen.has(row.orb_run_key)) continue;
246
+ appendScorecard(root, row);
247
+ seen.add(row.orb_run_key);
248
+ written.push(row);
249
+ }
250
+ return {
251
+ summary: orbSummaryForRail(scorecard),
252
+ improvement_candidates: actionableRuns.map(({ run, mode }) => orbFailureCandidate(run, mode)),
253
+ scorecards_written: written.length,
254
+ written,
255
+ historical_failures: existing.filter((row) => row && row.source === 'orb' && ['orb_failure', 'orb_orphan'].includes(row.mode)).length + written.length,
86
256
  };
87
257
  }
88
258
 
@@ -130,6 +300,68 @@ function shouldWriteScorecard({ reward } = {}) {
130
300
  return reward !== 0;
131
301
  }
132
302
 
303
+ // --- non-git work detection ---
304
+ //
305
+ // A workspace without .git made producedWork permanently false (git snapshots
306
+ // return an empty delta), so every tick scored 0 regardless of what the engine
307
+ // wrote — the loop was blind, not idle. When git is absent, pulse falls back to
308
+ // a filesystem snapshot: path → mtime+size, excluding churn dirs.
309
+
310
+ const FS_SNAPSHOT_SKIP = new Set(['.git', 'node_modules', '.atris']);
311
+ const FS_SNAPSHOT_MAX_ENTRIES = 20000;
312
+
313
+ function fsSnapshot(root) {
314
+ const entries = new Map();
315
+ const walk = (dir, rel) => {
316
+ if (entries.size >= FS_SNAPSHOT_MAX_ENTRIES) return;
317
+ let names;
318
+ try {
319
+ names = fs.readdirSync(dir, { withFileTypes: true });
320
+ } catch {
321
+ return;
322
+ }
323
+ for (const d of names) {
324
+ if (entries.size >= FS_SNAPSHOT_MAX_ENTRIES) return;
325
+ if (FS_SNAPSHOT_SKIP.has(d.name)) continue;
326
+ const abs = path.join(dir, d.name);
327
+ const r = rel ? `${rel}/${d.name}` : d.name;
328
+ if (d.isDirectory()) walk(abs, r);
329
+ else if (d.isFile()) {
330
+ try {
331
+ const st = fs.statSync(abs);
332
+ entries.set(r, `${st.mtimeMs}:${st.size}`);
333
+ } catch {}
334
+ }
335
+ }
336
+ };
337
+ walk(root, '');
338
+ return entries;
339
+ }
340
+
341
+ // New or modified files between two fsSnapshots. Deletions are ignored — the
342
+ // signal we need is "did the tick author anything", not a full diff.
343
+ function diffFsSnapshots(before, after) {
344
+ const changed = [];
345
+ for (const [rel, sig] of after) {
346
+ if (before.get(rel) !== sig) changed.push(rel);
347
+ }
348
+ return changed.sort();
349
+ }
350
+
351
+ // The verify default must match the workspace. Defaulting to `npm test` in a
352
+ // root with no package.json guaranteed -1 on any productive tick; a package.json
353
+ // with no "test" script did the same (18 straight -1 ticks in atrisos-backend).
354
+ function defaultVerifyCmd(root) {
355
+ const pkgPath = path.join(root, 'package.json');
356
+ if (!fs.existsSync(pkgPath)) return null;
357
+ try {
358
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
359
+ return pkg && pkg.scripts && typeof pkg.scripts.test === 'string' ? 'npm test' : null;
360
+ } catch {
361
+ return null;
362
+ }
363
+ }
364
+
133
365
  // --- ghost / stale detection (pure) ---
134
366
 
135
367
  // Pair started+finished by tick_index; any 'started' with no 'finished' partner
@@ -365,7 +597,9 @@ function buildTickScript(opts = {}) {
365
597
  if (!root) throw new Error('buildTickScript: root is required');
366
598
  if (!stateHome) throw new Error('buildTickScript: stateHome is required');
367
599
  if (!deadlineEpoch) throw new Error('buildTickScript: deadlineEpoch is required');
368
- const safeVerify = String(verifyCmd).replace(/'/g, "'\\''");
600
+ // A null verifyCmd (e.g. non-npm workspace) omits --verify so the tick
601
+ // resolves the workspace-appropriate default at run time.
602
+ const safeVerify = verifyCmd == null ? null : String(verifyCmd).replace(/'/g, "'\\''");
369
603
  const runnerModelExport = runnerEnvAliasExport({
370
604
  genericName: 'ATRIS_RUNNER_MODEL',
371
605
  legacyName: 'ATRIS_CLAUDE_MODEL',
@@ -412,6 +646,14 @@ now="$(date +%s)"
412
646
  if [ "$now" -ge "$DEADLINE_EPOCH" ]; then
413
647
  crontab -l 2>/dev/null | grep -v "$MARKER" | crontab - 2>/dev/null || true
414
648
  echo "$(date -Iseconds) pulse expired; removed cron" >> "$LOG_DIR/control.log"
649
+ expired_at="$(date -Iseconds)"
650
+ journal_year="$(date +%Y)"
651
+ journal_day="$(date +%F)"
652
+ journal_file="$ROOT/atris/logs/$journal_year/$journal_day.md"
653
+ mkdir -p "$ROOT/atris/logs/$journal_year" || true
654
+ printf '%s pulse heartbeat expired; renew with: atris pulse install\\n' "$expired_at" >> "$journal_file" || true
655
+ mkdir -p "$ROOT/.atris/state" || true
656
+ printf '{"expired_at":"%s","state_home":"%s","renew_command":"atris pulse install"}\\n' "$expired_at" "$STATE" >> "$ROOT/.atris/state/pulse-expired.json" || true
415
657
  exit 0
416
658
  fi
417
659
 
@@ -428,8 +670,26 @@ ${runnerBinExport}
428
670
  ${runnerCommandTemplateExport}
429
671
  export ATRIS_SKIP_UPDATE_CHECK=1
430
672
 
431
- "$ATRIS" pulse tick --json --verify '${safeVerify}' >> "$log" 2>&1
432
- echo "done: $(date -Iseconds) exit=$?" >> "$log"
673
+ "$ATRIS" pulse tick --json${safeVerify == null ? '' : ` --verify '${safeVerify}'`} >> "$log" 2>&1
674
+ tick_status=$?
675
+
676
+ # runDaily no-ops via last_run_date after the first daily run.
677
+ # hourly invocation is safe because repeated calls do no work.
678
+ "$ATRIS" experiments daily >> "$log" 2>&1 || true
679
+ echo "done: $(date -Iseconds) exit=$tick_status" >> "$log"
680
+
681
+ # Ticks commit locally but nothing reaches the remote without this: push any
682
+ # commits the loop has landed since the last successful push.
683
+ if [ -e "$ROOT/.git" ]; then
684
+ branch="$(git -C "$ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null)"
685
+ if [ -n "$branch" ] && [ "$branch" != "HEAD" ]; then
686
+ ahead="$(git -C "$ROOT" rev-list --count "origin/$branch..$branch" 2>/dev/null || echo 0)"
687
+ if [ "$ahead" -gt 0 ]; then
688
+ git -C "$ROOT" push origin "$branch" >> "$log" 2>&1
689
+ echo "pushed $ahead commit(s) to origin/$branch: $(date -Iseconds)" >> "$log"
690
+ fi
691
+ fi
692
+ fi
433
693
  `;
434
694
  }
435
695
 
@@ -443,18 +703,32 @@ module.exports = {
443
703
  PULSE_RECEIPT_SCHEMA,
444
704
  SCORECARD_SCHEMA,
445
705
  PULSE_MARKER,
706
+ LEGACY_STATE_DIRNAME,
446
707
  DEFAULT_CADENCE_CRON,
447
708
  STALE_TICK_MS,
448
709
  LIVENESS_STALE_MS,
449
710
  stateDir,
711
+ pulseRepoSlug,
712
+ pulseStateHome,
713
+ pulseMarker,
714
+ legacyPulseStateHome,
715
+ readTickScriptRoot,
716
+ legacyPulseStateMatchesRoot,
717
+ resolvePulseSlot,
450
718
  pulseReceiptsPath,
451
719
  scorecardsPath,
452
720
  pulseCounterPath,
453
721
  pulseLockDir,
454
722
  buildPulseReceipt,
455
723
  buildPulseScorecardRow,
724
+ buildOrbFailureScorecardRow,
456
725
  buildInterruptedPulseReceipt,
726
+ ingestOrbScorecard,
727
+ orbRunKey,
457
728
  scoreTick,
729
+ fsSnapshot,
730
+ diffFsSnapshots,
731
+ defaultVerifyCmd,
458
732
  normalizeExpiryDuration,
459
733
  shouldWriteScorecard,
460
734
  shouldFallbackToAutopilot,