atris 3.58.6 → 3.58.8

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 (90) hide show
  1. package/GETTING_STARTED.md +2 -2
  2. package/PERSONA.md +11 -11
  3. package/README.md +9 -1
  4. package/atris/GEMINI.md +7 -6
  5. package/atris/PERSONA.md +4 -4
  6. package/atris/features/README.md +77 -6
  7. package/atris/policies/engineering-principles.md +129 -0
  8. package/atris/policies/genesis.md +112 -0
  9. package/atris/policies/product-design-principles.md +100 -0
  10. package/atris/skills/README.md +44 -15
  11. package/atris/skills/aeo/SKILL.md +10 -10
  12. package/atris/skills/atris/SKILL.md +1 -1
  13. package/atris/skills/design/SKILL.md +3 -1
  14. package/atris/skills/engines/SKILL.md +42 -50
  15. package/atris/skills/meta/SKILL.md +4 -4
  16. package/atris/skills/youtube/SKILL.md +11 -11
  17. package/bin/atris.js +60 -8
  18. package/commands/aeo.js +9 -12
  19. package/commands/analytics.js +8 -11
  20. package/commands/auth.js +8 -2
  21. package/commands/autopilot.js +4 -3
  22. package/commands/avail.js +1 -1
  23. package/commands/brain.js +4 -1
  24. package/commands/business.js +13 -8
  25. package/commands/close.js +2 -1
  26. package/commands/computer.js +15 -10
  27. package/commands/context-sync.js +3 -3
  28. package/commands/design.js +362 -0
  29. package/commands/doc-health.js +377 -0
  30. package/commands/drive.js +32 -0
  31. package/commands/experiments.js +13 -4
  32. package/commands/improve.js +68 -2
  33. package/commands/integrations.js +2 -5
  34. package/commands/land.js +144 -4
  35. package/commands/learn.js +7 -2
  36. package/commands/member.js +239 -58
  37. package/commands/mission.js +94 -18
  38. package/commands/pack.js +2 -2
  39. package/commands/probe.js +11 -5
  40. package/commands/pull.js +1 -1
  41. package/commands/pulse.js +38 -0
  42. package/commands/push.js +1 -1
  43. package/commands/rainmaker.js +3 -16
  44. package/commands/rsi.js +156 -0
  45. package/commands/spaceship.js +1 -1
  46. package/commands/stream.js +27 -2
  47. package/commands/study.js +15 -9
  48. package/commands/sync.js +1 -1
  49. package/commands/task.js +54 -3
  50. package/commands/workflow.js +17 -7
  51. package/commands/youtube.js +76 -8
  52. package/lib/apply-gate.js +22 -4
  53. package/lib/codex-flight.js +28 -5
  54. package/lib/daily-log.js +88 -0
  55. package/lib/design-api.js +130 -0
  56. package/lib/engine-ask.js +1 -1
  57. package/lib/first-minute.js +1 -6
  58. package/lib/fleet.js +4 -1
  59. package/lib/improve-vitals-html.js +3 -2
  60. package/lib/known-commands.js +3 -3
  61. package/lib/lesson-contradiction.js +2 -2
  62. package/lib/map-refs.js +183 -0
  63. package/lib/member-context.js +42 -0
  64. package/lib/mission-room.js +4 -4
  65. package/lib/noninteractive.js +15 -4
  66. package/lib/policy-lessons.js +1 -1
  67. package/lib/receipt-block.js +137 -28
  68. package/lib/rsi-record.js +335 -0
  69. package/lib/runner-command.js +1 -1
  70. package/lib/scorecard.js +4 -4
  71. package/lib/sleep-sync.js +11 -0
  72. package/lib/state-detection.js +36 -17
  73. package/lib/task-db.js +74 -53
  74. package/lib/task-list-keeper.js +192 -0
  75. package/lib/todo-fallback.js +10 -4
  76. package/lib/todo.js +22 -10
  77. package/lib/wish-delegate.js +19 -5
  78. package/mcp/atris-mcp/index.mjs +227 -0
  79. package/package.json +4 -2
  80. package/scripts/det/det.js +2 -2
  81. package/scripts/det/pr-description.js +2 -2
  82. package/scripts/det/test.js +2 -2
  83. package/scripts/det/ytnotes +43 -8
  84. package/utils/api.js +1 -1
  85. package/utils/auth.js +79 -14
  86. package/utils/backend-root.js +31 -0
  87. package/utils/config.js +5 -4
  88. package/utils/owner-identity.js +61 -0
  89. package/atris/policies/LESSONS.md +0 -13
  90. package/atris/skills/autopilot/atris-autopilot.md +0 -71
@@ -98,18 +98,116 @@ function directProofText(value) {
98
98
  return ran ? `${ran[1]} passed` : text;
99
99
  }
100
100
 
101
- function proofText(receipt) {
101
+ // A receipt is a text from a good employee: one sentence a friend would send,
102
+ // plus one link a stranger can check. Ids, statuses, scores and verifier
103
+ // output stay underneath (they feed the rating); they never lead the surface.
104
+ // Standard: atris-labs atris/wiki/systems/receipt-standard.md
105
+
106
+ function sentence(value) {
107
+ const text = trimPunctuation(value);
108
+ if (!text) return '';
109
+ return `${text.charAt(0).toUpperCase()}${text.slice(1)}.`;
110
+ }
111
+
112
+ // Bracketed IPv6 hosts need their own alternative: the plain class stops at
113
+ // ] and would cut http://[::1]:3000 down to http://[::1 .
114
+ const URL_RE = /https?:\/\/\[[0-9a-f.:]+\][^\s)>\]]*|https?:\/\/[^\s)>\]]+/i;
115
+ const URL_RE_GLOBAL = new RegExp(URL_RE.source, 'gi');
116
+
117
+ // A stranger cannot open localhost, loopback or *.local addresses, so they
118
+ // never count as the check link no matter which field they came from.
119
+ // Parsing with URL normalizes tricks like 127.1 or 0x7f.1 into 127.0.0.1.
120
+ function localCheckUrl(url) {
121
+ let host;
122
+ try {
123
+ host = new URL(url).hostname.toLowerCase();
124
+ } catch {
125
+ return false;
126
+ }
127
+ if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1);
128
+ return host === 'localhost'
129
+ || host === '0.0.0.0'
130
+ || host === '::1'
131
+ || host.startsWith('127.')
132
+ || host.endsWith('.local');
133
+ }
134
+
135
+ // A checked/tested note that is nothing but a local URL carries no check a
136
+ // stranger can run; the verifier line speaks instead.
137
+ function localOnlyUrl(text) {
138
+ const value = trimPunctuation(text);
139
+ return /^https?:\/\/\S+$/i.test(value) && localCheckUrl(value);
140
+ }
141
+
142
+ function firstUrl(...values) {
143
+ for (const value of values) {
144
+ if (!value) continue;
145
+ for (const raw of String(value).match(URL_RE_GLOBAL) || []) {
146
+ const url = raw.replace(/[.,;:]+$/, '');
147
+ if (!localCheckUrl(url)) return url;
148
+ }
149
+ }
150
+ return '';
151
+ }
152
+
153
+ // The link a stranger can click. Read explicit link fields first, then any
154
+ // URL the landing text already mentions. Local-only hosts never count, and
155
+ // empty means the receipt has none.
156
+ function checkLink(receipt) {
157
+ const land = landing(receipt);
158
+ const res = result(receipt);
159
+ return firstUrl(
160
+ land.link, land.url, land.check_url, land.proof_url,
161
+ res.link, res.url, res.pr_url, res.share_url, res.tick?.pr_url, res.tick?.share_url,
162
+ land.checked, land.tested,
163
+ );
164
+ }
165
+
166
+ // What a reader can check: the link when there is one, else the plain
167
+ // check that passed. Never "verifier passed" or "receipt is present".
168
+ function checkText(receipt) {
169
+ const link = checkLink(receipt);
170
+ if (link) return link;
102
171
  const land = landing(receipt);
103
172
  const verifier = verifierResult(receipt);
104
- if (land.checked) return directProofText(land.checked);
105
- if (land.tested) return directProofText(land.tested);
173
+ if (land.checked && !localOnlyUrl(land.checked)) return directProofText(land.checked);
174
+ if (land.tested && !localOnlyUrl(land.tested)) return directProofText(land.tested);
106
175
  if (verifier) {
107
- const command = lowerLine(verifier.command || receipt?.verifier || 'configured verifier');
176
+ const command = lowerLine(verifier.command || receipt?.verifier || 'the checks');
108
177
  return `${command} ${verifier.passed ? 'passed' : 'failed'}`;
109
178
  }
110
- if (result(receipt).passed === true) return 'receipt says verifier passed';
111
- if (receipt?.at) return `receipt saved at ${lowerLine(receipt.at)}`;
112
- return 'receipt is present';
179
+ if (result(receipt).passed === true) return 'the checks passed';
180
+ return '';
181
+ }
182
+
183
+ // Dollars a mission actually brought in. Read from the landing (or result)
184
+ // so a member can write it next to what happened; the backend sums the same
185
+ // field into the member rating. Only a real number or a plain decimal string
186
+ // may count: Number() alone would read true as 1, [5] as 5, "0x10" as 16 and
187
+ // "1e3" as 1000. Junk and negatives count as zero.
188
+ const PLAIN_DECIMAL_RE = /^\d+(\.\d+)?$/;
189
+
190
+ function plainDecimalAmount(value) {
191
+ if (typeof value === 'number') return Number.isFinite(value) ? value : 0;
192
+ if (typeof value !== 'string') return 0;
193
+ const text = value.trim();
194
+ return PLAIN_DECIMAL_RE.test(text) ? Number(text) : 0;
195
+ }
196
+
197
+ function earnedUsd(receipt) {
198
+ const land = landing(receipt);
199
+ const res = result(receipt);
200
+ for (const value of [land.earned_usd, res.earned_usd, res.tick?.earned_usd]) {
201
+ if (value === undefined || value === null || value === '') continue;
202
+ const amount = plainDecimalAmount(value);
203
+ return amount > 0 ? Math.round(amount * 100) / 100 : 0;
204
+ }
205
+ return 0;
206
+ }
207
+
208
+ function usdText(amount) {
209
+ const whole = Number.isInteger(amount);
210
+ return `$${amount.toLocaleString('en-US', { minimumFractionDigits: whole ? 0 : 2, maximumFractionDigits: 2 })}`;
113
211
  }
114
212
 
115
213
  function nextText(receipt) {
@@ -117,43 +215,53 @@ function nextText(receipt) {
117
215
  return lowerLine(text);
118
216
  }
119
217
 
218
+ function whatText(receipt, max = 180) {
219
+ return historicalLandingText(trimPunctuation(changedText(receipt)), max);
220
+ }
221
+
222
+ // One line: "What happened. Check it: <link or check>."
223
+ function checkLine(receipt, max = 160) {
224
+ const check = trimPunctuation(checkText(receipt));
225
+ if (!check) return '';
226
+ // A link stays bare so it copies clean; a plain check reads as a sentence.
227
+ return firstUrl(check) ? `Check it: ${check}` : `Check it: ${truncateLine(check, max)}.`;
228
+ }
229
+
120
230
  function renderEmailLine(receipt) {
121
- const what = historicalLandingText(trimPunctuation(changedText(receipt)), 180);
122
- const scale = scaleText(receipt);
123
- const proof = historicalLandingText(trimPunctuation(proofText(receipt)), 90);
124
- return `${what}; ${scale}; we know because ${proof}.`;
231
+ const what = sentence(whatText(receipt));
232
+ const check = checkLine(receipt);
233
+ return check ? `${what} ${check}` : what;
125
234
  }
126
235
 
127
236
  function renderMorningCardRow(receipt) {
128
- return `- mission: ${renderEmailLine(receipt).replace(/\.$/, '')}`;
237
+ return `- ${renderEmailLine(receipt).replace(/\.$/, '')}`;
129
238
  }
130
239
 
240
+ // Three lines lead. The machinery (size, status, mission id) sits below as
241
+ // details, where a builder can find it and a customer never has to read it.
131
242
  function renderPageSection(receipt) {
132
243
  const next = nextText(receipt);
133
- const lines = [
134
- '## mission receipt',
135
- '',
136
- `- what: ${truncateLine(trimPunctuation(changedText(receipt)), 160)}`,
137
- `- how big: ${scaleText(receipt)}`,
138
- `- how we know: ${truncateLine(trimPunctuation(proofText(receipt)), 160)}`,
139
- `- status: ${statusText(receipt)}`,
140
- ];
141
- if (receipt?.mission_id) lines.push(`- mission: ${lowerLine(receipt.mission_id)}`);
142
- if (next) lines.push(`- next: ${truncateLine(trimPunctuation(next), 160)}`);
244
+ const check = checkLine(receipt);
245
+ const lines = ['## mission receipt', '', sentence(whatText(receipt, 160))];
246
+ if (check) lines.push(check);
247
+ if (next) lines.push(`Next: ${sentence(truncateLine(next, 160))}`);
248
+ const details = [scaleText(receipt), `status ${statusText(receipt)}`];
249
+ const earned = earnedUsd(receipt);
250
+ if (earned > 0) details.push(`earned ${usdText(earned)}`);
251
+ if (receipt?.mission_id) details.push(`mission ${lowerLine(receipt.mission_id)}`);
252
+ lines.push('', `details: ${details.join('; ')}`);
143
253
  return lines.join('\n');
144
254
  }
145
255
 
146
256
  function renderCard(receipt) {
147
- const status = statusText(receipt);
148
- const what = truncateLine(trimPunctuation(changedText(receipt)), 64);
149
- const scale = scaleText(receipt);
150
- const proof = truncateLine(trimPunctuation(proofText(receipt)), 88);
257
+ const what = sentence(whatText(receipt, 64));
258
+ const check = trimPunctuation(checkText(receipt));
151
259
  return {
152
260
  kind: 'statement',
153
261
  headline: what,
154
262
  text: what,
155
- kicker: `${status} mission receipt`,
156
- sub: `${scale}; ${proof}`,
263
+ kicker: 'mission receipt',
264
+ sub: check ? `Check it: ${truncateLine(check, 88)}` : '',
157
265
  brand: 'atris',
158
266
  size: 'og',
159
267
  theme: 'atris',
@@ -161,6 +269,7 @@ function renderCard(receipt) {
161
269
  }
162
270
 
163
271
  module.exports = {
272
+ earnedUsd,
164
273
  renderCard,
165
274
  renderPageSection,
166
275
  renderEmailLine,
@@ -0,0 +1,335 @@
1
+ 'use strict';
2
+
3
+ // Record one bounded improvement attempt into the Dream-RSI ledger.
4
+ //
5
+ // When the workspace root contains backend/scripts/rsi/record.py (the
6
+ // backend checkout with the recorder), a command that runs one bounded
7
+ // attempt at improving the workspace (improve tick, pulse tick, drive)
8
+ // wraps the work in start-tree -> choose -> open -> finish. Every step is
9
+ // best-effort: a missing recorder, a failed choose, or a dead python logs
10
+ // one line and the command's behavior and exit code stay untouched.
11
+ //
12
+ // State lands in $ATRIS_RSI_STATE (default <root>/.atris/state/rsi), the
13
+ // same paths backend/scripts/rsi/schema.py uses.
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const { spawnSync } = require('child_process');
18
+
19
+ // Lane name for bounded self-improvement attempts recorded from this CLI.
20
+ const IMPROVE_LANE = 'improve_tick';
21
+ // While an attempt node is open, nested atris subprocesses (local mission
22
+ // fallback, autopilot, mission ticks spawned by drive) must not open their
23
+ // own nodes: one command run is one attempt.
24
+ const GUARD_ENV = 'ATRIS_RSI_ATTEMPT_NODE';
25
+ const SPAWN_TIMEOUT_MS = 20000;
26
+ const NODE_SCHEMA = 'atris.rsi.node.v1';
27
+
28
+ function scriptsDir(root) {
29
+ return path.join(root, 'backend', 'scripts', 'rsi');
30
+ }
31
+
32
+ function recorderPath(root) {
33
+ const p = path.join(scriptsDir(root), 'record.py');
34
+ return fs.existsSync(p) ? p : null;
35
+ }
36
+
37
+ // The repo's own venv first (same as scripts/rsi_record_wrap.sh), else
38
+ // whatever python3 is on PATH.
39
+ function pythonBin(root) {
40
+ const venv = path.join(root, 'venv', 'bin', 'python');
41
+ try {
42
+ fs.accessSync(venv, fs.constants.X_OK);
43
+ return venv;
44
+ } catch {
45
+ return 'python3';
46
+ }
47
+ }
48
+
49
+ function stateDir(root) {
50
+ return process.env.ATRIS_RSI_STATE || path.join(root, '.atris', 'state', 'rsi');
51
+ }
52
+
53
+ function attemptsPath(root) {
54
+ return path.join(stateDir(root), 'attempts.jsonl');
55
+ }
56
+
57
+ function dreamsPath(root) {
58
+ return path.join(stateDir(root), 'dreams.jsonl');
59
+ }
60
+
61
+ function currentPolicyId(root, fallback = 'p-0001') {
62
+ try {
63
+ const raw = fs.readFileSync(path.join(root, 'atris', 'rsi', 'policy.current'), 'utf8').trim();
64
+ return raw || fallback;
65
+ } catch {
66
+ return fallback;
67
+ }
68
+ }
69
+
70
+ function localDate(d = new Date()) {
71
+ const y = d.getFullYear();
72
+ const m = String(d.getMonth() + 1).padStart(2, '0');
73
+ const day = String(d.getDate()).padStart(2, '0');
74
+ return `${y}-${m}-${day}`;
75
+ }
76
+
77
+ // "<prefix>-YYYY-MM-DD[-N]" -> "YYYY-MM-DD" (record.py start-tree shape).
78
+ function treeDate(treeId) {
79
+ const parts = String(treeId || '').split('-');
80
+ if (parts.length < 4) return null;
81
+ const date = parts.slice(1, 4).join('-');
82
+ return /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null;
83
+ }
84
+
85
+ // Read attempts.jsonl tolerantly: blank lines, non-JSON, and foreign rows
86
+ // are skipped. Latest row per node id wins (finish appends a new row).
87
+ function readAttemptRows(root) {
88
+ let lines;
89
+ try {
90
+ lines = fs.readFileSync(attemptsPath(root), 'utf8').split(/\r?\n/);
91
+ } catch {
92
+ return [];
93
+ }
94
+ const rows = [];
95
+ for (const line of lines) {
96
+ const trimmed = line.trim();
97
+ if (!trimmed) continue;
98
+ try {
99
+ const row = JSON.parse(trimmed);
100
+ if (row && row.schema === NODE_SCHEMA) rows.push(row);
101
+ } catch {
102
+ // skip non-JSON rows
103
+ }
104
+ }
105
+ return rows;
106
+ }
107
+
108
+ function latestNodes(root) {
109
+ const latest = new Map();
110
+ for (const row of readAttemptRows(root)) {
111
+ if (row && row.id) latest.set(String(row.id), row);
112
+ }
113
+ return [...latest.values()];
114
+ }
115
+
116
+ // Reuse today's tree for the lane when one exists (the newest one wins when
117
+ // a day has several), so every tick on a day shares one tree.
118
+ function todayTreeId(root, lane, today = localDate()) {
119
+ const matches = new Set();
120
+ for (const row of readAttemptRows(root)) {
121
+ if (row && row.lane === lane && treeDate(row.tree_id) === today) matches.add(String(row.tree_id));
122
+ }
123
+ const sorted = [...matches].sort();
124
+ return sorted.length ? sorted[sorted.length - 1] : null;
125
+ }
126
+
127
+ function runPy(root, script, args, timeoutMs = SPAWN_TIMEOUT_MS) {
128
+ const scriptPath = path.join(scriptsDir(root), script);
129
+ const r = spawnSync(pythonBin(root), [scriptPath, ...args], {
130
+ cwd: root,
131
+ encoding: 'utf8',
132
+ timeout: timeoutMs,
133
+ env: process.env,
134
+ });
135
+ return {
136
+ ok: r.status === 0,
137
+ stdout: String(r.stdout || '').trim(),
138
+ stderr: String(r.stderr || '').trim(),
139
+ error: r.error || null,
140
+ };
141
+ }
142
+
143
+ function startTree(root, lane) {
144
+ const today = localDate();
145
+ const existing = todayTreeId(root, lane, today);
146
+ if (existing) return existing;
147
+ const r = runPy(root, 'record.py', ['start-tree', '--lane', lane, '--date', today]);
148
+ const id = r.ok ? (r.stdout.split('\n').pop() || '').trim() : '';
149
+ return id || null;
150
+ }
151
+
152
+ // Ask the live policy for one action (--w 1). Returns the first action or
153
+ // null; an empty list means the policy chose to stop, a failure returns null.
154
+ function chooseAction(root, treeId, { queueItems = 0, w = 1 } = {}) {
155
+ const choose = path.join(scriptsDir(root), 'choose.py');
156
+ if (!fs.existsSync(choose)) return null;
157
+ const r = runPy(root, 'choose.py', [
158
+ '--tree', treeId,
159
+ '--date', localDate(),
160
+ '--queue-items', String(queueItems),
161
+ '--w', String(w),
162
+ ]);
163
+ if (!r.ok) return null;
164
+ try {
165
+ const actions = JSON.parse(r.stdout);
166
+ return Array.isArray(actions) && actions.length ? actions[0] : null;
167
+ } catch {
168
+ return null;
169
+ }
170
+ }
171
+
172
+ function gitHead(root, short = false) {
173
+ try {
174
+ const args = short ? ['rev-parse', '--short', 'HEAD'] : ['rev-parse', 'HEAD'];
175
+ const r = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8', timeout: 15000 });
176
+ return r.status === 0 ? String(r.stdout || '').trim() : null;
177
+ } catch {
178
+ return null;
179
+ }
180
+ }
181
+
182
+ function gitDirtyFiles(root) {
183
+ try {
184
+ const r = spawnSync('git', ['-C', root, 'status', '--porcelain'], { encoding: 'utf8', timeout: 15000 });
185
+ if (r.status !== 0) return [];
186
+ return String(r.stdout || '')
187
+ .split('\n')
188
+ .map((l) => l.slice(3).trim())
189
+ .filter(Boolean)
190
+ .map((f) => {
191
+ const arrow = f.indexOf(' -> ');
192
+ return arrow >= 0 ? f.slice(arrow + 4) : f;
193
+ });
194
+ } catch {
195
+ return [];
196
+ }
197
+ }
198
+
199
+ // A cheap working-tree snapshot: HEAD + the set of dirty paths. Comparing
200
+ // two snapshots gives a tick's ACTUAL contribution; pre-existing dirt is
201
+ // excluded so a tick is not credited for mess it did not make.
202
+ function gitSnapshot(root) {
203
+ return { head: gitHead(root), dirty: new Set(gitDirtyFiles(root)) };
204
+ }
205
+
206
+ // Commits and files between a captured snapshot (or bare HEAD string) and
207
+ // now: the committed diff plus paths the tick newly dirtied.
208
+ function gitDelta(root, before) {
209
+ const out = { commits: 0, files: [] };
210
+ if (!root) return out;
211
+ const beforeHead = typeof before === 'string' ? before : (before && before.head);
212
+ const beforeDirty = before && before.dirty instanceof Set ? before.dirty : new Set();
213
+ const files = new Set();
214
+ try {
215
+ const after = gitHead(root);
216
+ if (beforeHead && after && beforeHead !== after) {
217
+ const count = spawnSync('git', ['-C', root, 'rev-list', '--count', `${beforeHead}..${after}`], { encoding: 'utf8', timeout: 15000 });
218
+ if (count.status === 0) out.commits = Number(String(count.stdout).trim()) || 0;
219
+ const diff = spawnSync('git', ['-C', root, 'diff', '--name-only', beforeHead, after], { encoding: 'utf8', timeout: 15000 });
220
+ if (diff.status === 0) String(diff.stdout).split('\n').map((f) => f.trim()).filter(Boolean).forEach((f) => files.add(f));
221
+ }
222
+ for (const f of gitDirtyFiles(root)) {
223
+ if (!beforeDirty.has(f)) files.add(f);
224
+ }
225
+ } catch {
226
+ // git missing or not a repo: commits 0, no files
227
+ }
228
+ out.files = [...files].slice(0, 200);
229
+ return out;
230
+ }
231
+
232
+ // Map a model name to a recorded engine. The schema's ENGINES are
233
+ // codex/devin/cursor/agy/claude; unknown models return null so callers fall
234
+ // back to 'claude' (the CLI's own runner default).
235
+ function engineFromModel(model) {
236
+ const m = String(model || '').toLowerCase();
237
+ if (!m) return null;
238
+ if (m.includes('codex')) return 'codex';
239
+ if (m.includes('devin')) return 'devin';
240
+ if (m.includes('cursor') || m.includes('composer')) return 'cursor';
241
+ if (m.includes('agy') || m.includes('antigravity')) return 'agy';
242
+ if (m.includes('claude') || m.includes('sonnet') || m.includes('opus') || m.includes('haiku')) return 'claude';
243
+ return null;
244
+ }
245
+
246
+ /**
247
+ * Open one recorded attempt. Returns null when the workspace has no
248
+ * recorder, when a parent attempt is already open (nested call), or when
249
+ * any recorder step fails. Never throws.
250
+ *
251
+ * opts.engine: the engine the tick will actually use (default 'claude').
252
+ * opts.ground: the ground the tick targets; '' is allowed when the tick has
253
+ * no notion of target.
254
+ * The returned attempt carries .hint = the policy-chosen action (or null),
255
+ * so a tick with a notion of target can steer by hint.ground.
256
+ */
257
+ function beginAttempt(root, { lane = IMPROVE_LANE, engine = 'claude', ground = '', queueItems = 0, log = () => {} } = {}) {
258
+ try {
259
+ if (!root || process.env[GUARD_ENV]) return null;
260
+ if (!recorderPath(root)) return null;
261
+ const tree = startTree(root, lane);
262
+ if (!tree) {
263
+ log('rsi: recorder present but start-tree failed; attempt not recorded');
264
+ return null;
265
+ }
266
+ const chosen = chooseAction(root, tree, { queueItems, w: 1 });
267
+ const action = {
268
+ policy_id: (chosen && chosen.policy_id) || currentPolicyId(root),
269
+ kind: 'open',
270
+ // The ground the tick actually targeted. Callers without a notion of
271
+ // target pass '' (allowed); the policy's pick is exposed as .hint
272
+ // instead of being claimed as the ground the tick took.
273
+ ground: String(ground || ''),
274
+ engine: engine || 'claude',
275
+ model: chosen && chosen.model != null ? chosen.model : null,
276
+ cap_s: Number(chosen && chosen.cap_s) || 1800,
277
+ prompt_variant: (chosen && chosen.prompt_variant) || 'v1',
278
+ };
279
+ const context = {
280
+ base_commit: gitHead(root, true) || '',
281
+ ground: action.ground,
282
+ queue_items: queueItems,
283
+ };
284
+ const r = runPy(root, 'record.py', [
285
+ 'open', '--tree', tree, '--lane', lane,
286
+ '--action-json', JSON.stringify(action),
287
+ '--context-json', JSON.stringify(context),
288
+ ]);
289
+ const node = r.ok ? (r.stdout.split('\n').pop() || '').trim() : '';
290
+ if (!node) {
291
+ log('rsi: node open failed; attempt not recorded');
292
+ return null;
293
+ }
294
+ process.env[GUARD_ENV] = node;
295
+ return { tree, node, lane, action, hint: chosen || null };
296
+ } catch (e) {
297
+ log(`rsi: attempt record skipped (${e && e.message ? e.message : e})`);
298
+ return null;
299
+ }
300
+ }
301
+
302
+ // Close an attempt opened by beginAttempt. Never throws; returns true when
303
+ // the finish row was written.
304
+ function finishAttempt(root, attempt, outcome = {}, { log = () => {} } = {}) {
305
+ if (!attempt || !attempt.node) return false;
306
+ if (process.env[GUARD_ENV] === attempt.node) delete process.env[GUARD_ENV];
307
+ try {
308
+ const r = runPy(root, 'record.py', [
309
+ 'finish', '--node', attempt.node,
310
+ '--outcome-json', JSON.stringify(outcome),
311
+ ]);
312
+ if (!r.ok) {
313
+ log(`rsi: node finish failed${r.stderr ? ` (${r.stderr.split('\n').pop()})` : ''}`);
314
+ return false;
315
+ }
316
+ return true;
317
+ } catch (e) {
318
+ log(`rsi: node finish skipped (${e && e.message ? e.message : e})`);
319
+ return false;
320
+ }
321
+ }
322
+
323
+ module.exports = {
324
+ IMPROVE_LANE,
325
+ GUARD_ENV,
326
+ attemptsPath,
327
+ dreamsPath,
328
+ currentPolicyId,
329
+ latestNodes,
330
+ gitSnapshot,
331
+ gitDelta,
332
+ engineFromModel,
333
+ beginAttempt,
334
+ finishAttempt,
335
+ };
@@ -8,7 +8,7 @@
8
8
  // so autonomous runs are reproducible, while keeping explicit per-run/env knobs
9
9
  // first in precedence. Precedence: explicit model -> ATRIS_RUNNER_MODEL env ->
10
10
  // ATRIS_RUNNER_PROFILE -> legacy ATRIS_CLAUDE_MODEL env -> pinned default.
11
- const DEFAULT_CLAUDE_RUNNER_MODEL = 'claude-opus-4-8';
11
+ const DEFAULT_CLAUDE_RUNNER_MODEL = 'claude-opus-5-5';
12
12
  const DEFAULT_FABLE_RUNNER_MODEL = 'claude-fable-5';
13
13
  const DEFAULT_CLAUDE_RUNNER_BIN = 'claude';
14
14
 
package/lib/scorecard.js CHANGED
@@ -245,14 +245,14 @@ function writeScorecard(atrisDir, data) {
245
245
 
246
246
  // Ensure scorecards.md exists
247
247
  if (!fs.existsSync(scorecardsPath)) {
248
- const template = `# scorecards.md \u2014 Endgame Results\n\n> Append-only. One line per closed endgame. Records outcome metrics from the horizon.\n\n---\n\n`;
248
+ const template = `# scorecards.md: Endgame Results\n\n> Append-only. One line per closed endgame. Records outcome metrics from the horizon.\n\n---\n\n`;
249
249
  fs.writeFileSync(scorecardsPath, template, 'utf8');
250
250
  }
251
251
 
252
- // Format: - **[date] slug**, shipped: X/Y, wall-clock: Nh, halt: Z%, reward: total, lessons: N
252
+ // Format: - **[date] slug** - shipped: X/Y - wall-clock: Nh - halt: Z% - reward: total - lessons: N
253
253
  const haltPercent = Math.round(haltRatio * 100);
254
254
  const wallClockStr = wallClockHours < 1 ? `${Math.round(wallClockHours * 60)}m` : `${wallClockHours.toFixed(1)}h`;
255
- const line = `- **[${endDate}] ${slug}** \u2014 shipped: ${tasksShipped}/${tasksAttempted} \u2014 wall-clock: ${wallClockStr} \u2014 halt: ${haltPercent}% \u2014 reward: ${totalReward} \u2014 lessons: ${lessonsGenerated}\n`;
255
+ const line = `- **[${endDate}] ${slug}** - shipped: ${tasksShipped}/${tasksAttempted} - wall-clock: ${wallClockStr} - halt: ${haltPercent}% - reward: ${totalReward} - lessons: ${lessonsGenerated}\n`;
256
256
 
257
257
  // Append to file
258
258
  fs.appendFileSync(scorecardsPath, line, 'utf8');
@@ -302,7 +302,7 @@ function readScorecards(atrisDir) {
302
302
  const scorecards = [];
303
303
 
304
304
  for (const line of content.split('\n')) {
305
- const match = line.match(/^- \*\*\[(.+?)\]\s+(.+?)\*\*\s*\u2014\s*shipped:\s*(\d+)\/(\d+)\s*\u2014\s*wall-clock:\s*(.+?)\s*\u2014\s*halt:\s*(\d+)%\s*\u2014\s*reward:\s*(-?\d+)\s*\u2014\s*lessons:\s*(\d+)$/);
305
+ const match = line.match(/^- \*\*\[(.+?)\]\s+(.+?)\*\*\s*[-\u2014]\s*shipped:\s*(\d+)\/(\d+)\s*[-\u2014]\s*wall-clock:\s*(.+?)\s*[-\u2014]\s*halt:\s*(\d+)%\s*[-\u2014]\s*reward:\s*(-?\d+)\s*[-\u2014]\s*lessons:\s*(\d+)$/);
306
306
  if (!match) continue;
307
307
 
308
308
  const [, endDate, slug, shipped, attempted, wallClockStr, haltPercent, reward, lessons] = match;
@@ -0,0 +1,11 @@
1
+ 'use strict';
2
+
3
+ // Block the current thread for ms milliseconds without burning a core.
4
+ // Atomics.wait parks the thread; a Date.now() loop keeps it at full CPU.
5
+ function sleepSync(ms) {
6
+ const duration = Math.max(0, Number(ms) || 0);
7
+ if (!duration) return;
8
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, duration);
9
+ }
10
+
11
+ module.exports = { sleepSync };