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.
- package/GETTING_STARTED.md +2 -2
- package/PERSONA.md +11 -11
- package/README.md +9 -1
- package/atris/GEMINI.md +7 -6
- package/atris/PERSONA.md +4 -4
- package/atris/features/README.md +77 -6
- package/atris/policies/engineering-principles.md +129 -0
- package/atris/policies/genesis.md +112 -0
- package/atris/policies/product-design-principles.md +100 -0
- package/atris/skills/README.md +44 -15
- package/atris/skills/aeo/SKILL.md +10 -10
- package/atris/skills/atris/SKILL.md +1 -1
- package/atris/skills/design/SKILL.md +3 -1
- package/atris/skills/engines/SKILL.md +42 -50
- package/atris/skills/meta/SKILL.md +4 -4
- package/atris/skills/youtube/SKILL.md +11 -11
- package/bin/atris.js +60 -8
- package/commands/aeo.js +9 -12
- package/commands/analytics.js +8 -11
- package/commands/auth.js +8 -2
- package/commands/autopilot.js +4 -3
- package/commands/avail.js +1 -1
- package/commands/brain.js +4 -1
- package/commands/business.js +13 -8
- package/commands/close.js +2 -1
- package/commands/computer.js +15 -10
- package/commands/context-sync.js +3 -3
- package/commands/design.js +362 -0
- package/commands/doc-health.js +377 -0
- package/commands/drive.js +32 -0
- package/commands/experiments.js +13 -4
- package/commands/improve.js +68 -2
- package/commands/integrations.js +2 -5
- package/commands/land.js +144 -4
- package/commands/learn.js +7 -2
- package/commands/member.js +239 -58
- package/commands/mission.js +94 -18
- package/commands/pack.js +2 -2
- package/commands/probe.js +11 -5
- package/commands/pull.js +1 -1
- package/commands/pulse.js +38 -0
- package/commands/push.js +1 -1
- package/commands/rainmaker.js +3 -16
- package/commands/rsi.js +156 -0
- package/commands/spaceship.js +1 -1
- package/commands/stream.js +27 -2
- package/commands/study.js +15 -9
- package/commands/sync.js +1 -1
- package/commands/task.js +54 -3
- package/commands/workflow.js +17 -7
- package/commands/youtube.js +76 -8
- package/lib/apply-gate.js +22 -4
- package/lib/codex-flight.js +28 -5
- package/lib/daily-log.js +88 -0
- package/lib/design-api.js +130 -0
- package/lib/engine-ask.js +1 -1
- package/lib/first-minute.js +1 -6
- package/lib/fleet.js +4 -1
- package/lib/improve-vitals-html.js +3 -2
- package/lib/known-commands.js +3 -3
- package/lib/lesson-contradiction.js +2 -2
- package/lib/map-refs.js +183 -0
- package/lib/member-context.js +42 -0
- package/lib/mission-room.js +4 -4
- package/lib/noninteractive.js +15 -4
- package/lib/policy-lessons.js +1 -1
- package/lib/receipt-block.js +137 -28
- package/lib/rsi-record.js +335 -0
- package/lib/runner-command.js +1 -1
- package/lib/scorecard.js +4 -4
- package/lib/sleep-sync.js +11 -0
- package/lib/state-detection.js +36 -17
- package/lib/task-db.js +74 -53
- package/lib/task-list-keeper.js +192 -0
- package/lib/todo-fallback.js +10 -4
- package/lib/todo.js +22 -10
- package/lib/wish-delegate.js +19 -5
- package/mcp/atris-mcp/index.mjs +227 -0
- package/package.json +4 -2
- package/scripts/det/det.js +2 -2
- package/scripts/det/pr-description.js +2 -2
- package/scripts/det/test.js +2 -2
- package/scripts/det/ytnotes +43 -8
- package/utils/api.js +1 -1
- package/utils/auth.js +79 -14
- package/utils/backend-root.js +31 -0
- package/utils/config.js +5 -4
- package/utils/owner-identity.js +61 -0
- package/atris/policies/LESSONS.md +0 -13
- package/atris/skills/autopilot/atris-autopilot.md +0 -71
package/lib/receipt-block.js
CHANGED
|
@@ -98,18 +98,116 @@ function directProofText(value) {
|
|
|
98
98
|
return ran ? `${ran[1]} passed` : text;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
|
|
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 || '
|
|
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 '
|
|
111
|
-
|
|
112
|
-
|
|
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 =
|
|
122
|
-
const
|
|
123
|
-
|
|
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 `-
|
|
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
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
148
|
-
const
|
|
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:
|
|
156
|
-
sub:
|
|
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
|
+
};
|
package/lib/runner-command.js
CHANGED
|
@@ -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-
|
|
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
|
|
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
|
|
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}**
|
|
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
|
|
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 };
|