atris 3.16.1 → 3.22.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.
- package/README.md +32 -7
- package/atris/skills/atris/SKILL.md +15 -2
- package/atris/skills/atris-feedback/SKILL.md +7 -0
- package/atris/skills/design/SKILL.md +29 -2
- package/atris/skills/engines/SKILL.md +44 -0
- package/atris/skills/flow/SKILL.md +1 -1
- package/atris/skills/wake/SKILL.md +37 -0
- package/atris/skills/youtube/SKILL.md +13 -39
- package/atris/team/validator/MEMBER.md +1 -0
- package/atris/wiki/concepts/agent-activation-contract.md +3 -3
- package/atris/wiki/concepts/workspace-initialization-contract.md +3 -3
- package/atris/wiki/index.md +1 -0
- package/atris.md +43 -19
- package/bin/atris.js +413 -31
- package/commands/agent-spawn.js +480 -0
- package/commands/analytics.js +6 -3
- package/commands/apps.js +11 -0
- package/commands/autopilot.js +42 -18
- package/commands/brain.js +74 -7
- package/commands/brainstorm.js +9 -58
- package/commands/clean.js +1 -4
- package/commands/compile.js +9 -4
- package/commands/console.js +8 -3
- package/commands/deck.js +184 -0
- package/commands/init.js +22 -11
- package/commands/lesson.js +76 -0
- package/commands/member.js +252 -48
- package/commands/mission.js +405 -13
- package/commands/now.js +4 -2
- package/commands/probe.js +105 -27
- package/commands/pulse.js +504 -0
- package/commands/radar.js +1 -0
- package/commands/recap.js +71 -25
- package/commands/run.js +615 -22
- package/commands/site.js +48 -0
- package/commands/slop.js +307 -0
- package/commands/spaceship.js +39 -0
- package/commands/sync.js +0 -2
- package/commands/task.js +429 -37
- package/commands/theme.js +217 -0
- package/commands/verify.js +7 -3
- package/lib/activity-stream.js +166 -0
- package/lib/auto-accept-certified.js +23 -1
- package/lib/context-gatherer.js +170 -0
- package/lib/deck-from-md.js +110 -0
- package/lib/escape-regexp.js +13 -0
- package/lib/file-ops.js +6 -3
- package/lib/html-render.js +257 -0
- package/lib/journal.js +1 -1
- package/lib/lesson-contradiction.js +113 -0
- package/lib/memory-view.js +95 -0
- package/lib/policy-lessons.js +3 -2
- package/lib/pulse.js +401 -0
- package/lib/runner-command.js +156 -0
- package/lib/site.js +114 -0
- package/lib/slides-deck.js +237 -0
- package/lib/state-detection.js +1 -4
- package/lib/task-db.js +101 -4
- package/lib/task-proof.js +1 -1
- package/lib/theme.js +264 -0
- package/lib/todo-fallback.js +2 -1
- package/lib/todo-sections.js +33 -0
- package/package.json +1 -2
- package/utils/api.js +14 -2
- package/atris/atrisDev.md +0 -717
package/lib/policy-lessons.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
const fs = require('fs');
|
|
11
11
|
const path = require('path');
|
|
12
12
|
const { RECEIPT_PATH_PATTERN, extractReceiptEvidence } = require('./receipt-evidence');
|
|
13
|
+
const escapeRegExp = require('./escape-regexp');
|
|
13
14
|
|
|
14
15
|
const POLICY_LESSONS_FILE = path.join('.atris', 'state', 'policy_lessons.json');
|
|
15
16
|
const CAREER_XP_RECEIPTS_FILE = path.join('.atris', 'state', 'career_xp_receipts.jsonl');
|
|
@@ -20,7 +21,7 @@ const SCORECARDS_FILE = path.join('.atris', 'state', 'scorecards.jsonl');
|
|
|
20
21
|
// two: agent self-review churn and human accept/bounce are different signals.
|
|
21
22
|
const AGENT_ACTOR_PATTERN = /review|validator|certifier|auto|codex|claude|devin|droid|improver|second-pass|agent|bot/i;
|
|
22
23
|
// "Names a runnable verify command" — the check a reviewer could replay.
|
|
23
|
-
const VERIFY_COMMAND_PATTERN = /\b(npm (run )?test|node --test|node --check|node bin\/|pytest|cargo test|go test|make test|atris verify|grep
|
|
24
|
+
const VERIFY_COMMAND_PATTERN = /\b(npm (run )?test|node --test|node --check|node bin\/|pytest|cargo test|go test|make test|atris verify|grep\s+-[A-Za-z]*q[A-Za-z]*|rg\s+(?:-\S+\s+)*(?:"[^"]+"|'[^']+'|\S+)\s+(?:\.{0,2}\/|~\/|\/|[\w.-]+\/|[\w.-]+\.[A-Za-z0-9]|\b(?:atris|bin|commands|lib|scripts|src|test)\b)|git diff --(?:check|exit-code|quiet)|diff (?:-u|--brief)|cmp -s|test -[fs])\b|--verify\b|\bverify:\s/;
|
|
24
25
|
const COMMIT_REF_PATTERN = /\bcommit\s+[0-9a-f]{7,40}\b/i;
|
|
25
26
|
|
|
26
27
|
function readJsonlFile(filePath) {
|
|
@@ -254,7 +255,7 @@ function syncLessonsMd(root, mined) {
|
|
|
254
255
|
}
|
|
255
256
|
const written = [];
|
|
256
257
|
for (const { id, line } of lines) {
|
|
257
|
-
const marker = new RegExp(`^- \\*\\*\\[\\d{4}-\\d{2}-\\d{2}\\] policy-${id
|
|
258
|
+
const marker = new RegExp(`^- \\*\\*\\[\\d{4}-\\d{2}-\\d{2}\\] policy-${escapeRegExp(id)}\\*\\*.*$`, 'm');
|
|
258
259
|
if (marker.test(content)) {
|
|
259
260
|
content = content.replace(marker, line);
|
|
260
261
|
} else {
|
package/lib/pulse.js
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Pulse: the durable overnight self-improvement heartbeat for atris-cli itself.
|
|
4
|
+
//
|
|
5
|
+
// The /loop skill schedules a heartbeat via Claude Code's CronCreate, but that
|
|
6
|
+
// only fires while Claude Code is open and idle and dies with the session. The
|
|
7
|
+
// proven overnight pattern is an OS cron (see the commander tick.sh template) —
|
|
8
|
+
// it fires regardless of whether Claude Code is running. `atris pulse` brings
|
|
9
|
+
// that pattern home: one OS-cron tick that runs the existing mission engine,
|
|
10
|
+
// verifies, and writes BOTH a pulse receipt (revives the Pulse AGI loop-health
|
|
11
|
+
// channel brain.js watches) and a reward scorecard (revives the feedback signal
|
|
12
|
+
// that policy-lessons mines). This module holds the pure, testable core; the
|
|
13
|
+
// command (commands/pulse.js) wires it to the engine and the cron shell.
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
|
|
18
|
+
const PULSE_RECEIPT_SCHEMA = 'atris.pulse_tick.v1';
|
|
19
|
+
// Reuse the improve-tick scorecard schema so the brain + policy-lessons see
|
|
20
|
+
// pulse reward as fresh feedback signal (source:'pulse' keeps it attributable).
|
|
21
|
+
const SCORECARD_SCHEMA = 'atris.improve_tick.v1';
|
|
22
|
+
const PULSE_MARKER = 'ATRIS_PULSE_SELF_IMPROVE';
|
|
23
|
+
// Hourly at an off-clock minute (avoid :00/:30 fleet sync). Each tick spawns a
|
|
24
|
+
// real worker + full verify, so default conservative; raise with --cadence.
|
|
25
|
+
const DEFAULT_CADENCE_CRON = '23 * * * *';
|
|
26
|
+
// Lock-steal timeout: a tick still holding the lock after 30m is hung → steal it.
|
|
27
|
+
const STALE_TICK_MS = 30 * 60 * 1000;
|
|
28
|
+
// Liveness timeout: how long since the last finished tick before the loop reads
|
|
29
|
+
// as "stale" (stopped firing). Must exceed the cadence or it flaps stale between
|
|
30
|
+
// every tick — default cadence is hourly, so allow ~2 missed ticks before alarm.
|
|
31
|
+
const LIVENESS_STALE_MS = 150 * 60 * 1000;
|
|
32
|
+
|
|
33
|
+
function stateDir(root) {
|
|
34
|
+
return path.join(root, '.atris', 'state');
|
|
35
|
+
}
|
|
36
|
+
function pulseReceiptsPath(root) {
|
|
37
|
+
return path.join(stateDir(root), 'pulse_agi_loop_receipts.jsonl');
|
|
38
|
+
}
|
|
39
|
+
function scorecardsPath(root) {
|
|
40
|
+
return path.join(stateDir(root), 'scorecards.jsonl');
|
|
41
|
+
}
|
|
42
|
+
function pulseCounterPath(root) {
|
|
43
|
+
return path.join(stateDir(root), 'pulse.tick-count');
|
|
44
|
+
}
|
|
45
|
+
function pulseLockDir(root) {
|
|
46
|
+
return path.join(stateDir(root), 'pulse.lock');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// --- receipt + scorecard building (pure) ---
|
|
50
|
+
|
|
51
|
+
function buildPulseReceipt(input = {}) {
|
|
52
|
+
return {
|
|
53
|
+
schema: PULSE_RECEIPT_SCHEMA,
|
|
54
|
+
ts: input.ts || new Date().toISOString(),
|
|
55
|
+
tick_index: input.tickIndex != null ? input.tickIndex : null,
|
|
56
|
+
phase: input.phase || 'finished', // 'started' | 'finished'
|
|
57
|
+
actor: input.actor || null, // 'mission_run_due' | 'noop' | ...
|
|
58
|
+
actor_ok: input.actorOk != null ? input.actorOk : null,
|
|
59
|
+
actor_reason: input.actorReason || null, // 'completed' | 'no_due_mission' | 'error'
|
|
60
|
+
verify_cmd: input.verifyCmd || null,
|
|
61
|
+
verify_passed: input.verifyPassed != null ? input.verifyPassed : null,
|
|
62
|
+
changed_files: Array.isArray(input.changedFiles) ? input.changedFiles : [],
|
|
63
|
+
what: input.what || null,
|
|
64
|
+
elapsed_ms: input.elapsedMs != null ? input.elapsedMs : null,
|
|
65
|
+
prev_tick_stale: input.prevTickStale != null ? input.prevTickStale : false,
|
|
66
|
+
reward: input.reward != null ? input.reward : null,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function buildPulseScorecardRow(input = {}) {
|
|
71
|
+
return {
|
|
72
|
+
schema: SCORECARD_SCHEMA,
|
|
73
|
+
ts: input.ts || new Date().toISOString(),
|
|
74
|
+
source: 'pulse',
|
|
75
|
+
member: 'pulse',
|
|
76
|
+
mode: input.mode || 'tick',
|
|
77
|
+
reward: input.reward != null ? input.reward : 0,
|
|
78
|
+
verify_passed: input.verifyPassed != null ? input.verifyPassed : null,
|
|
79
|
+
credits_deducted: 0,
|
|
80
|
+
what_shipped: input.what || null,
|
|
81
|
+
files_written: Array.isArray(input.changedFiles) ? input.changedFiles : [],
|
|
82
|
+
model_used: input.model || null,
|
|
83
|
+
task_id: input.taskId || null,
|
|
84
|
+
elapsed_ms: input.elapsedMs != null ? input.elapsedMs : null,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The heartbeat's full composition (mirrors the /loop skill): run the due
|
|
89
|
+
// mission to continue an existing goal; if none is due, fall back to an
|
|
90
|
+
// autopilot tick — that path is where proposeCandidateHorizons AUTHORS a new
|
|
91
|
+
// goal at an endgame boundary. The fallback needs a worker, so skip it under
|
|
92
|
+
// --no-claude (goal-authoring can't happen without the model in the loop).
|
|
93
|
+
function shouldFallbackToAutopilot({ missionReason, autopilotFallback = true, noClaude = false } = {}) {
|
|
94
|
+
if (!autopilotFallback) return false;
|
|
95
|
+
if (noClaude) return false;
|
|
96
|
+
return missionReason === 'no_due_mission';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Reward gating mirrors the improve.js tick-5 lesson: only verified,
|
|
100
|
+
// work-producing ticks earn positive reward; verify failure is punished;
|
|
101
|
+
// a tick that produced no work scores 0. `producedWork` MUST be the tick's
|
|
102
|
+
// actual delta (new commit or newly-dirtied files), never the whole dirty tree —
|
|
103
|
+
// crediting pre-existing dirt re-rewards the same change every tick (the reward
|
|
104
|
+
// inflation bug). The caller computes producedWork from a before/after snapshot.
|
|
105
|
+
function scoreTick({ verifyPassed, producedWork } = {}) {
|
|
106
|
+
if (verifyPassed === false) return -1;
|
|
107
|
+
if (!producedWork) return 0;
|
|
108
|
+
return verifyPassed === true ? 1 : 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Only write a scorecard when there is signal. A pure no-op tick still leaves a
|
|
112
|
+
// pulse receipt (for liveness) but must not spam the reward channel with noise.
|
|
113
|
+
function shouldWriteScorecard({ reward } = {}) {
|
|
114
|
+
return reward !== 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// --- ghost / stale detection (pure) ---
|
|
118
|
+
|
|
119
|
+
// Pair started+finished by tick_index; any 'started' with no 'finished' partner
|
|
120
|
+
// is a tick that crashed mid-run — exactly the silent-runner-death failure mode.
|
|
121
|
+
function findOrphanStarts(receipts) {
|
|
122
|
+
if (!Array.isArray(receipts)) return [];
|
|
123
|
+
const finished = new Set();
|
|
124
|
+
for (const r of receipts) {
|
|
125
|
+
if (r && r.phase === 'finished' && r.tick_index != null) finished.add(r.tick_index);
|
|
126
|
+
}
|
|
127
|
+
const orphans = [];
|
|
128
|
+
for (const r of receipts) {
|
|
129
|
+
if (r && r.phase === 'started' && r.tick_index != null && !finished.has(r.tick_index)) {
|
|
130
|
+
orphans.push(r.tick_index);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return orphans;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Liveness reflects the LATEST tick only. A historical orphan (a crash that was
|
|
137
|
+
// later recovered by a finished tick) is surfaced in the feed via
|
|
138
|
+
// findOrphanStarts, but must NOT make a recovered loop read as dead forever.
|
|
139
|
+
function detectStaleTick(receipts, now = Date.now(), staleMs = LIVENESS_STALE_MS) {
|
|
140
|
+
if (!Array.isArray(receipts) || receipts.length === 0) {
|
|
141
|
+
return { stale: false, reason: 'no_receipts' };
|
|
142
|
+
}
|
|
143
|
+
const last = receipts[receipts.length - 1];
|
|
144
|
+
if (last && last.phase === 'started') {
|
|
145
|
+
// the most recent thing we did was start a tick that never finished
|
|
146
|
+
return { stale: true, reason: 'started_without_finish', tick_index: last.tick_index };
|
|
147
|
+
}
|
|
148
|
+
const lastMs = Date.parse(last && last.ts ? last.ts : '');
|
|
149
|
+
if (Number.isFinite(lastMs) && now - lastMs > staleMs) {
|
|
150
|
+
return { stale: true, reason: 'last_tick_too_old', age_ms: now - lastMs };
|
|
151
|
+
}
|
|
152
|
+
return { stale: false, reason: 'fresh' };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// --- summarize (pure) ---
|
|
156
|
+
|
|
157
|
+
function summarizePulse(receipts, now = Date.now()) {
|
|
158
|
+
const all = Array.isArray(receipts) ? receipts : [];
|
|
159
|
+
const finished = all.filter((r) => r && r.phase === 'finished');
|
|
160
|
+
const rewardSum = finished.reduce((a, r) => a + (Number(r.reward) || 0), 0);
|
|
161
|
+
const last = finished.length ? finished[finished.length - 1] : null;
|
|
162
|
+
return {
|
|
163
|
+
total_ticks: finished.length,
|
|
164
|
+
reward_sum: rewardSum,
|
|
165
|
+
verify_pass: finished.filter((r) => r.verify_passed === true).length,
|
|
166
|
+
verify_fail: finished.filter((r) => r.verify_passed === false).length,
|
|
167
|
+
last_tick_ts: last ? last.ts : null,
|
|
168
|
+
last_verify_passed: last ? last.verify_passed : null,
|
|
169
|
+
orphan_ticks: findOrphanStarts(all),
|
|
170
|
+
stale: detectStaleTick(all, now),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// --- IO helpers ---
|
|
175
|
+
|
|
176
|
+
function appendJsonl(file, row) {
|
|
177
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
178
|
+
fs.appendFileSync(file, `${JSON.stringify(row)}\n`, 'utf8');
|
|
179
|
+
return file;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function readJsonl(file) {
|
|
183
|
+
if (!fs.existsSync(file)) return [];
|
|
184
|
+
const out = [];
|
|
185
|
+
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
|
186
|
+
const trimmed = line.trim();
|
|
187
|
+
if (!trimmed) continue;
|
|
188
|
+
try {
|
|
189
|
+
out.push(JSON.parse(trimmed));
|
|
190
|
+
} catch {
|
|
191
|
+
// skip foreign / partial rows
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function shellSingleQuote(value) {
|
|
198
|
+
return `'${String(value || '').replace(/'/g, "'\\''")}'`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function runnerEnvAliasExport({ genericName, legacyName, value }) {
|
|
202
|
+
if (!value) return '';
|
|
203
|
+
return [
|
|
204
|
+
`if [ -z "\${${genericName}:-}" ]; then`,
|
|
205
|
+
` if [ -n "\${${legacyName}:-}" ]; then`,
|
|
206
|
+
` export ${genericName}="\${${legacyName}}"`,
|
|
207
|
+
' else',
|
|
208
|
+
` export ${genericName}=${shellSingleQuote(value)}`,
|
|
209
|
+
' fi',
|
|
210
|
+
'fi',
|
|
211
|
+
`[ -n "\${${legacyName}:-}" ] || export ${legacyName}="\${${genericName}}"`,
|
|
212
|
+
].join('\n');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function readPulseReceipts(root) {
|
|
216
|
+
return readJsonl(pulseReceiptsPath(root));
|
|
217
|
+
}
|
|
218
|
+
function appendPulseReceipt(root, receipt) {
|
|
219
|
+
return appendJsonl(pulseReceiptsPath(root), receipt);
|
|
220
|
+
}
|
|
221
|
+
function appendScorecard(root, row) {
|
|
222
|
+
return appendJsonl(scorecardsPath(root), row);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function nextTickIndex(root) {
|
|
226
|
+
const file = pulseCounterPath(root);
|
|
227
|
+
let n = 0;
|
|
228
|
+
try {
|
|
229
|
+
n = parseInt(fs.readFileSync(file, 'utf8').trim(), 10) || 0;
|
|
230
|
+
} catch {}
|
|
231
|
+
n += 1;
|
|
232
|
+
try {
|
|
233
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
234
|
+
fs.writeFileSync(file, String(n), 'utf8');
|
|
235
|
+
} catch {}
|
|
236
|
+
return n;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Lock prevents overlapping ticks. A lock older than staleMs is stolen (and the
|
|
240
|
+
// theft is reported so the orphaned tick surfaces instead of blocking forever).
|
|
241
|
+
function acquireLock(root, now = Date.now(), staleMs = STALE_TICK_MS) {
|
|
242
|
+
const dir = pulseLockDir(root);
|
|
243
|
+
// The lock dir itself is created non-recursively (atomic), but its parent
|
|
244
|
+
// (.atris/state) must exist first or a fresh workspace can never acquire it.
|
|
245
|
+
try { fs.mkdirSync(path.dirname(dir), { recursive: true }); } catch {}
|
|
246
|
+
try {
|
|
247
|
+
fs.mkdirSync(dir, { recursive: false });
|
|
248
|
+
try {
|
|
249
|
+
fs.writeFileSync(path.join(dir, 'pid'), String(process.pid), 'utf8');
|
|
250
|
+
} catch {}
|
|
251
|
+
return { acquired: true, stale: false };
|
|
252
|
+
} catch {
|
|
253
|
+
let ageMs = Infinity;
|
|
254
|
+
try {
|
|
255
|
+
ageMs = now - fs.statSync(dir).mtimeMs;
|
|
256
|
+
} catch {}
|
|
257
|
+
if (ageMs > staleMs) {
|
|
258
|
+
try {
|
|
259
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
260
|
+
fs.mkdirSync(dir, { recursive: false });
|
|
261
|
+
fs.writeFileSync(path.join(dir, 'pid'), String(process.pid), 'utf8');
|
|
262
|
+
return { acquired: true, stale: true, ageMs };
|
|
263
|
+
} catch {}
|
|
264
|
+
}
|
|
265
|
+
return { acquired: false, stale: false, ageMs };
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function releaseLock(root) {
|
|
270
|
+
try {
|
|
271
|
+
fs.rmSync(pulseLockDir(root), { recursive: true, force: true });
|
|
272
|
+
} catch {}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// --- cron tick script + crontab line (pure string generation) ---
|
|
276
|
+
|
|
277
|
+
// Minimal shell wrapper modeled on the proven commander tick.sh: deadline
|
|
278
|
+
// self-removal, then hand off to `atris pulse tick` (all real logic lives in JS,
|
|
279
|
+
// testable, not duplicated in shell). The wrapper only owns scheduling concerns.
|
|
280
|
+
function buildTickScript(opts = {}) {
|
|
281
|
+
const {
|
|
282
|
+
root,
|
|
283
|
+
atrisBin = 'atris',
|
|
284
|
+
stateHome,
|
|
285
|
+
deadlineEpoch,
|
|
286
|
+
marker = PULSE_MARKER,
|
|
287
|
+
model = 'opus',
|
|
288
|
+
runnerProfile = '',
|
|
289
|
+
runnerBin = '',
|
|
290
|
+
runnerCommandTemplate = '',
|
|
291
|
+
verifyCmd = 'npm test',
|
|
292
|
+
pathDirs = [],
|
|
293
|
+
} = opts;
|
|
294
|
+
if (!root) throw new Error('buildTickScript: root is required');
|
|
295
|
+
if (!stateHome) throw new Error('buildTickScript: stateHome is required');
|
|
296
|
+
if (!deadlineEpoch) throw new Error('buildTickScript: deadlineEpoch is required');
|
|
297
|
+
const safeVerify = String(verifyCmd).replace(/'/g, "'\\''");
|
|
298
|
+
const runnerModelExport = runnerEnvAliasExport({
|
|
299
|
+
genericName: 'ATRIS_RUNNER_MODEL',
|
|
300
|
+
legacyName: 'ATRIS_CLAUDE_MODEL',
|
|
301
|
+
value: model,
|
|
302
|
+
});
|
|
303
|
+
const runnerProfileExport = runnerProfile
|
|
304
|
+
? `[ -n "\${ATRIS_RUNNER_PROFILE:-}" ] || export ATRIS_RUNNER_PROFILE=${shellSingleQuote(runnerProfile)}`
|
|
305
|
+
: '';
|
|
306
|
+
const runnerBinExport = runnerEnvAliasExport({
|
|
307
|
+
genericName: 'ATRIS_RUNNER_BIN',
|
|
308
|
+
legacyName: 'ATRIS_CLAUDE_BIN',
|
|
309
|
+
value: runnerBin,
|
|
310
|
+
});
|
|
311
|
+
const runnerCommandTemplateExport = runnerEnvAliasExport({
|
|
312
|
+
genericName: 'ATRIS_RUNNER_COMMAND_TEMPLATE',
|
|
313
|
+
legacyName: 'ATRIS_CLAUDE_COMMAND_TEMPLATE',
|
|
314
|
+
value: runnerCommandTemplate,
|
|
315
|
+
});
|
|
316
|
+
// Cron runs with a minimal PATH. The engine spawns `claude`/`node`/`git` by
|
|
317
|
+
// bare name, so we must prepend their real locations or every tick silently
|
|
318
|
+
// fails to spawn the worker (looks alive, never improves). pathDirs are the
|
|
319
|
+
// resolved bin dirs (claude, node, atris, homebrew) discovered at install.
|
|
320
|
+
const dirs = Array.from(new Set([
|
|
321
|
+
...pathDirs.filter(Boolean),
|
|
322
|
+
'/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin',
|
|
323
|
+
]));
|
|
324
|
+
const pathExport = `export PATH="${dirs.join(':')}:$PATH"`;
|
|
325
|
+
return `#!/bin/zsh
|
|
326
|
+
set -u
|
|
327
|
+
|
|
328
|
+
ROOT="${root}"
|
|
329
|
+
ATRIS="${atrisBin}"
|
|
330
|
+
STATE="${stateHome}"
|
|
331
|
+
LOG_DIR="$STATE/logs"
|
|
332
|
+
DEADLINE_EPOCH="${deadlineEpoch}"
|
|
333
|
+
MARKER="${marker}"
|
|
334
|
+
|
|
335
|
+
# Cron has a minimal PATH; restore the dirs the engine's bare-name spawns need.
|
|
336
|
+
${pathExport}
|
|
337
|
+
|
|
338
|
+
mkdir -p "$LOG_DIR"
|
|
339
|
+
|
|
340
|
+
now="$(date +%s)"
|
|
341
|
+
if [ "$now" -ge "$DEADLINE_EPOCH" ]; then
|
|
342
|
+
crontab -l 2>/dev/null | grep -v "$MARKER" | crontab - 2>/dev/null || true
|
|
343
|
+
echo "$(date -Iseconds) pulse expired; removed cron" >> "$LOG_DIR/control.log"
|
|
344
|
+
exit 0
|
|
345
|
+
fi
|
|
346
|
+
|
|
347
|
+
stamp="$(date +"%Y%m%d-%H%M%S")"
|
|
348
|
+
log="$LOG_DIR/$stamp.log"
|
|
349
|
+
|
|
350
|
+
cd "$ROOT" || { echo "$(date -Iseconds) ROOT missing" >> "$LOG_DIR/control.log"; exit 1; }
|
|
351
|
+
|
|
352
|
+
# Autonomous ticks must target a live model alias, never a versioned id that can
|
|
353
|
+
# retire out from under the loop (lesson: retired-model-kills-loop-silently).
|
|
354
|
+
${runnerModelExport}
|
|
355
|
+
${runnerProfileExport}
|
|
356
|
+
${runnerBinExport}
|
|
357
|
+
${runnerCommandTemplateExport}
|
|
358
|
+
export ATRIS_SKIP_UPDATE_CHECK=1
|
|
359
|
+
|
|
360
|
+
"$ATRIS" pulse tick --json --verify '${safeVerify}' >> "$log" 2>&1
|
|
361
|
+
echo "done: $(date -Iseconds) exit=$?" >> "$log"
|
|
362
|
+
`;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function buildCrontabLine(opts = {}) {
|
|
366
|
+
const { cron = DEFAULT_CADENCE_CRON, scriptPath, marker = PULSE_MARKER } = opts;
|
|
367
|
+
if (!scriptPath) throw new Error('buildCrontabLine: scriptPath is required');
|
|
368
|
+
return `${cron} ${scriptPath} # ${marker}`;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
module.exports = {
|
|
372
|
+
PULSE_RECEIPT_SCHEMA,
|
|
373
|
+
SCORECARD_SCHEMA,
|
|
374
|
+
PULSE_MARKER,
|
|
375
|
+
DEFAULT_CADENCE_CRON,
|
|
376
|
+
STALE_TICK_MS,
|
|
377
|
+
LIVENESS_STALE_MS,
|
|
378
|
+
stateDir,
|
|
379
|
+
pulseReceiptsPath,
|
|
380
|
+
scorecardsPath,
|
|
381
|
+
pulseCounterPath,
|
|
382
|
+
pulseLockDir,
|
|
383
|
+
buildPulseReceipt,
|
|
384
|
+
buildPulseScorecardRow,
|
|
385
|
+
scoreTick,
|
|
386
|
+
shouldWriteScorecard,
|
|
387
|
+
shouldFallbackToAutopilot,
|
|
388
|
+
findOrphanStarts,
|
|
389
|
+
detectStaleTick,
|
|
390
|
+
summarizePulse,
|
|
391
|
+
appendJsonl,
|
|
392
|
+
readJsonl,
|
|
393
|
+
readPulseReceipts,
|
|
394
|
+
appendPulseReceipt,
|
|
395
|
+
appendScorecard,
|
|
396
|
+
nextTickIndex,
|
|
397
|
+
acquireLock,
|
|
398
|
+
releaseLock,
|
|
399
|
+
buildTickScript,
|
|
400
|
+
buildCrontabLine,
|
|
401
|
+
};
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Shared worker-spawn builder for the autonomous loops (missions, autopilot, run).
|
|
4
|
+
//
|
|
5
|
+
// Autonomous ticks must target a LIVE model. Inheriting the CLI's persisted
|
|
6
|
+
// selection is fragile: a *versioned* id (e.g. claude-fable-5) silently dies
|
|
7
|
+
// when that version is retired, and every tick then errors as a generic
|
|
8
|
+
// 'claude-error' with no clue why (lesson: retired-model-kills-loop-silently,
|
|
9
|
+
// CLI-245). Precedence: explicit model -> ATRIS_RUNNER_MODEL env ->
|
|
10
|
+
// ATRIS_RUNNER_PROFILE -> legacy ATRIS_CLAUDE_MODEL env -> 'opus' alias. The
|
|
11
|
+
// CLI resolves aliases to the latest live model, so an alias never retires out
|
|
12
|
+
// from under the loop.
|
|
13
|
+
const DEFAULT_CLAUDE_RUNNER_MODEL = 'opus';
|
|
14
|
+
const DEFAULT_CLAUDE_RUNNER_BIN = 'claude';
|
|
15
|
+
const RUNNER_PROFILES = Object.freeze({
|
|
16
|
+
'atris-fast': Object.freeze({
|
|
17
|
+
bin: 'ax',
|
|
18
|
+
model: 'atris:fast',
|
|
19
|
+
commandTemplate: '{bin} --fast {prompt}',
|
|
20
|
+
}),
|
|
21
|
+
'atris2-fast': Object.freeze({
|
|
22
|
+
bin: 'ax',
|
|
23
|
+
model: 'atris:fast',
|
|
24
|
+
commandTemplate: '{bin} --fast {prompt}',
|
|
25
|
+
}),
|
|
26
|
+
'atris-2-fast': Object.freeze({
|
|
27
|
+
bin: 'ax',
|
|
28
|
+
model: 'atris:fast',
|
|
29
|
+
commandTemplate: '{bin} --fast {prompt}',
|
|
30
|
+
}),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
function shellWord(value) {
|
|
34
|
+
const s = String(value || '');
|
|
35
|
+
if (/^[A-Za-z0-9_./:-]+$/.test(s)) return s;
|
|
36
|
+
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function firstConfiguredEnv(names) {
|
|
40
|
+
for (const name of names) {
|
|
41
|
+
const value = String(process.env[name] || '').trim();
|
|
42
|
+
if (value) return value;
|
|
43
|
+
}
|
|
44
|
+
return '';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function resolveRunnerProfileName() {
|
|
48
|
+
return String(process.env.ATRIS_RUNNER_PROFILE || '').trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function resolveRunnerProfile() {
|
|
52
|
+
const name = resolveRunnerProfileName();
|
|
53
|
+
if (!name) return null;
|
|
54
|
+
const profile = RUNNER_PROFILES[name];
|
|
55
|
+
if (!profile) {
|
|
56
|
+
throw new Error(`Unknown ATRIS_RUNNER_PROFILE "${name}". Known profiles: ${Object.keys(RUNNER_PROFILES).join(', ')}`);
|
|
57
|
+
}
|
|
58
|
+
return profile;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function runnerProfileValue(key) {
|
|
62
|
+
const profile = resolveRunnerProfile();
|
|
63
|
+
return profile && profile[key] ? profile[key] : '';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function resolveClaudeRunnerModel(mission) {
|
|
67
|
+
const explicit = mission && mission.model != null ? String(mission.model).trim() : '';
|
|
68
|
+
if (explicit) return explicit;
|
|
69
|
+
const env = firstConfiguredEnv(['ATRIS_RUNNER_MODEL']);
|
|
70
|
+
if (env) return env;
|
|
71
|
+
const profileModel = runnerProfileValue('model');
|
|
72
|
+
if (profileModel) return profileModel;
|
|
73
|
+
const legacyEnv = firstConfiguredEnv(['ATRIS_CLAUDE_MODEL']);
|
|
74
|
+
if (legacyEnv) return legacyEnv;
|
|
75
|
+
return DEFAULT_CLAUDE_RUNNER_MODEL;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function resolveClaudeRunnerBin() {
|
|
79
|
+
const env = firstConfiguredEnv(['ATRIS_RUNNER_BIN']);
|
|
80
|
+
if (env) return env;
|
|
81
|
+
const profileBin = runnerProfileValue('bin');
|
|
82
|
+
if (profileBin) return profileBin;
|
|
83
|
+
const legacyEnv = firstConfiguredEnv(['ATRIS_CLAUDE_BIN']);
|
|
84
|
+
if (legacyEnv) return legacyEnv;
|
|
85
|
+
return DEFAULT_CLAUDE_RUNNER_BIN;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function resolveClaudeRunnerCommandTemplate() {
|
|
89
|
+
const env = firstConfiguredEnv(['ATRIS_RUNNER_COMMAND_TEMPLATE']);
|
|
90
|
+
if (env) return env;
|
|
91
|
+
const profileTemplate = runnerProfileValue('commandTemplate');
|
|
92
|
+
if (profileTemplate) return profileTemplate;
|
|
93
|
+
return firstConfiguredEnv(['ATRIS_CLAUDE_COMMAND_TEMPLATE']);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function buildRunnerAvailabilityCommand() {
|
|
97
|
+
return `command -v ${shellWord(resolveClaudeRunnerBin())}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function renderRunnerCommandTemplate(template, { promptFile, allowedTools, model }) {
|
|
101
|
+
const allowedToolsFlag = allowedTools ? `--allowedTools ${shellWord(allowedTools)}` : '';
|
|
102
|
+
const promptFileWord = shellWord(promptFile);
|
|
103
|
+
const values = {
|
|
104
|
+
bin: shellWord(resolveClaudeRunnerBin()),
|
|
105
|
+
promptFile: promptFileWord,
|
|
106
|
+
prompt: `"$(cat ${promptFileWord})"`,
|
|
107
|
+
model: shellWord(model),
|
|
108
|
+
modelFlag: `--model ${shellWord(model)}`,
|
|
109
|
+
allowedTools: allowedTools ? shellWord(allowedTools) : '',
|
|
110
|
+
allowedToolsFlag,
|
|
111
|
+
};
|
|
112
|
+
return template.replace(/\{([A-Za-z][A-Za-z0-9_]*)\}/g, (match, key) => {
|
|
113
|
+
if (Object.prototype.hasOwnProperty.call(values, key)) return values[key];
|
|
114
|
+
return match;
|
|
115
|
+
}).trim();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Build the shell command that spawns one headless worker tick. `--model` is
|
|
119
|
+
// ALWAYS injected (resolved via resolveClaudeRunnerModel) so no spawn path can
|
|
120
|
+
// fall back to the CLI's mutable persisted selection. The default command shape
|
|
121
|
+
// remains Claude-compatible, but ATRIS_RUNNER_COMMAND_TEMPLATE can replace it
|
|
122
|
+
// for GLM/OpenAI/other local runners. The old ATRIS_CLAUDE_* env vars remain
|
|
123
|
+
// aliases for existing installs. allowedTools is optional: some call sites
|
|
124
|
+
// (e.g. horizon proposal) run without a tool allowlist.
|
|
125
|
+
function buildRunnerCommand({ promptFile, allowedTools, model } = {}) {
|
|
126
|
+
if (!promptFile) {
|
|
127
|
+
throw new Error('buildRunnerCommand: promptFile is required');
|
|
128
|
+
}
|
|
129
|
+
const resolved = resolveClaudeRunnerModel({ model });
|
|
130
|
+
const template = resolveClaudeRunnerCommandTemplate();
|
|
131
|
+
if (template) {
|
|
132
|
+
return renderRunnerCommandTemplate(template, { promptFile, allowedTools, model: resolved });
|
|
133
|
+
}
|
|
134
|
+
const safePath = String(promptFile).replace(/'/g, "'\\''");
|
|
135
|
+
let cmd = `${shellWord(resolveClaudeRunnerBin())} -p "$(cat '${safePath}')" --model ${shellWord(resolved)}`;
|
|
136
|
+
if (allowedTools) {
|
|
137
|
+
cmd += ` --allowedTools ${shellWord(allowedTools)}`;
|
|
138
|
+
}
|
|
139
|
+
return cmd;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = {
|
|
143
|
+
DEFAULT_CLAUDE_RUNNER_MODEL,
|
|
144
|
+
DEFAULT_CLAUDE_RUNNER_BIN,
|
|
145
|
+
RUNNER_PROFILES,
|
|
146
|
+
resolveRunnerProfileName,
|
|
147
|
+
resolveRunnerProfile,
|
|
148
|
+
resolveRunnerModel: resolveClaudeRunnerModel,
|
|
149
|
+
resolveRunnerBin: resolveClaudeRunnerBin,
|
|
150
|
+
resolveRunnerCommandTemplate: resolveClaudeRunnerCommandTemplate,
|
|
151
|
+
resolveClaudeRunnerModel,
|
|
152
|
+
resolveClaudeRunnerBin,
|
|
153
|
+
resolveClaudeRunnerCommandTemplate,
|
|
154
|
+
buildRunnerAvailabilityCommand,
|
|
155
|
+
buildRunnerCommand,
|
|
156
|
+
};
|