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
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// `atris pulse` — the durable overnight self-improvement heartbeat for atris-cli.
|
|
4
|
+
//
|
|
5
|
+
// atris pulse tick run ONE self-improvement tick (what the cron calls)
|
|
6
|
+
// atris pulse status liveness, reward sum, ghost-tick detection
|
|
7
|
+
// atris pulse install write the OS-cron tick script + install the crontab line
|
|
8
|
+
// atris pulse uninstall remove the crontab line
|
|
9
|
+
// atris pulse run run N ticks in the foreground (manual / testing)
|
|
10
|
+
//
|
|
11
|
+
// One tick: lock -> 'started' receipt -> run mission engine -> verify -> write
|
|
12
|
+
// 'finished' receipt (pulse_agi_loop_receipts.jsonl) + reward scorecard
|
|
13
|
+
// (scorecards.jsonl, gated) -> release lock. The whole point is that this fires
|
|
14
|
+
// from an OS cron, so it self-improves overnight without Claude Code open.
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const os = require('os');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const { spawnSync } = require('child_process');
|
|
20
|
+
const pulse = require('../lib/pulse');
|
|
21
|
+
|
|
22
|
+
function hasFlag(args, name) {
|
|
23
|
+
return args.includes(name);
|
|
24
|
+
}
|
|
25
|
+
function readFlag(args, name, fallback = null) {
|
|
26
|
+
const i = args.indexOf(name);
|
|
27
|
+
if (i === -1 || i === args.length - 1) return fallback;
|
|
28
|
+
return args[i + 1];
|
|
29
|
+
}
|
|
30
|
+
function wantsJson(args) {
|
|
31
|
+
return hasFlag(args, '--json');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function helpText() {
|
|
35
|
+
return `
|
|
36
|
+
Usage: atris pulse <tick|status|install|uninstall|run> [options]
|
|
37
|
+
|
|
38
|
+
Durable overnight self-improvement heartbeat.
|
|
39
|
+
|
|
40
|
+
Commands:
|
|
41
|
+
tick Run one heartbeat tick
|
|
42
|
+
status Show liveness, reward, and ghost-tick detection
|
|
43
|
+
install Install the OS cron heartbeat
|
|
44
|
+
uninstall Remove the OS cron heartbeat
|
|
45
|
+
run Run bounded ticks in the foreground
|
|
46
|
+
|
|
47
|
+
Options:
|
|
48
|
+
--json Print machine-readable output
|
|
49
|
+
--no-claude Do not spawn Claude-backed mission work
|
|
50
|
+
--no-verify Skip verifier command
|
|
51
|
+
--verify "<cmd>" Verifier for changed-work ticks (default: npm test)
|
|
52
|
+
--cadence "<cron>" Cron cadence for install
|
|
53
|
+
--days <n> Auto-expire installed heartbeat after n days
|
|
54
|
+
--model <id> Runner model alias/id for installed heartbeat
|
|
55
|
+
--runner-profile <n> Runner profile for installed heartbeat (e.g. atris-fast)
|
|
56
|
+
--runner-bin <path> Runner binary for installed heartbeat
|
|
57
|
+
--runner-template <s> Runner command template for installed heartbeat
|
|
58
|
+
--max-ticks <n> Number of foreground ticks for run
|
|
59
|
+
--help, -h Show this help
|
|
60
|
+
`.trim();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function showHelp() {
|
|
64
|
+
process.stdout.write(`${helpText()}\n`);
|
|
65
|
+
return { ok: true, action: 'pulse_help' };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function emit(obj, asJson) {
|
|
69
|
+
if (asJson) {
|
|
70
|
+
process.stdout.write(`${JSON.stringify(obj, null, 2)}\n`);
|
|
71
|
+
}
|
|
72
|
+
return obj;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const STATE_HOME = path.join(os.homedir(), '.atris', 'overnight', 'atris-cli-self-improve');
|
|
76
|
+
|
|
77
|
+
// --- engine: run one mission-run-due tick via the real CLI ---
|
|
78
|
+
|
|
79
|
+
function runMissionEngine(root, { noClaude = false, timeoutMs = 600000 } = {}) {
|
|
80
|
+
const cliPath = path.join(__dirname, '..', 'bin', 'atris.js');
|
|
81
|
+
const args = ['mission', 'run', '--due', '--max-ticks', '1', '--complete-on-pass', '--json'];
|
|
82
|
+
if (noClaude) args.push('--no-claude');
|
|
83
|
+
const result = spawnSync(process.execPath, [cliPath, ...args], {
|
|
84
|
+
cwd: root,
|
|
85
|
+
encoding: 'utf8',
|
|
86
|
+
timeout: timeoutMs,
|
|
87
|
+
env: { ...process.env, ATRIS_SKIP_UPDATE_CHECK: '1' },
|
|
88
|
+
});
|
|
89
|
+
let payload = null;
|
|
90
|
+
try {
|
|
91
|
+
payload = JSON.parse(result.stdout || '{}');
|
|
92
|
+
} catch {}
|
|
93
|
+
// Map the mission-run result to a normalized actor outcome.
|
|
94
|
+
const reason = payload && payload.reason ? payload.reason
|
|
95
|
+
: (result.status === 0 ? 'completed' : 'error');
|
|
96
|
+
return {
|
|
97
|
+
actor: 'mission_run_due',
|
|
98
|
+
ok: result.status === 0,
|
|
99
|
+
reason, // 'completed' | 'no_due_mission' | 'error' | ...
|
|
100
|
+
status: result.status,
|
|
101
|
+
payload,
|
|
102
|
+
stdout: String(result.stdout || '').slice(-2000),
|
|
103
|
+
stderr: String(result.stderr || '').slice(-2000),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Fallback worker: one headless autopilot tick. This is the path that reaches
|
|
108
|
+
// proposeCandidateHorizons — i.e. where the member AUTHORS a new goal when no
|
|
109
|
+
// mission is due, instead of idling.
|
|
110
|
+
function runAutopilotTick(root, { timeoutMs = 600000 } = {}) {
|
|
111
|
+
const cliPath = path.join(__dirname, '..', 'bin', 'atris.js');
|
|
112
|
+
const args = ['autopilot', '--auto', '--iterations=1'];
|
|
113
|
+
const result = spawnSync(process.execPath, [cliPath, ...args], {
|
|
114
|
+
cwd: root,
|
|
115
|
+
encoding: 'utf8',
|
|
116
|
+
timeout: timeoutMs,
|
|
117
|
+
env: { ...process.env, ATRIS_SKIP_UPDATE_CHECK: '1' },
|
|
118
|
+
});
|
|
119
|
+
return {
|
|
120
|
+
actor: 'autopilot',
|
|
121
|
+
ok: result.status === 0,
|
|
122
|
+
reason: result.status === 0 ? 'completed' : 'error',
|
|
123
|
+
status: result.status,
|
|
124
|
+
stdout: String(result.stdout || '').slice(-2000),
|
|
125
|
+
stderr: String(result.stderr || '').slice(-2000),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The full heartbeat composition: continue a due mission, else author+pursue a
|
|
130
|
+
// new goal via autopilot. This is the /loop skill's logic, encoded for OS cron.
|
|
131
|
+
function runEngine(root, { noClaude = false, autopilotFallback = true, timeoutMs = 600000 } = {}) {
|
|
132
|
+
const mission = runMissionEngine(root, { noClaude, timeoutMs });
|
|
133
|
+
if (pulse.shouldFallbackToAutopilot({ missionReason: mission.reason, autopilotFallback, noClaude })) {
|
|
134
|
+
const ap = runAutopilotTick(root, { timeoutMs });
|
|
135
|
+
return { ...ap, fell_back_from: 'no_due_mission' };
|
|
136
|
+
}
|
|
137
|
+
return mission;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function gitChangedFiles(root) {
|
|
141
|
+
try {
|
|
142
|
+
const r = spawnSync('git', ['-C', root, 'status', '--porcelain'], { encoding: 'utf8', timeout: 15000 });
|
|
143
|
+
if (r.status !== 0) return [];
|
|
144
|
+
return String(r.stdout || '')
|
|
145
|
+
.split('\n')
|
|
146
|
+
.map((l) => l.slice(3).trim())
|
|
147
|
+
.filter(Boolean);
|
|
148
|
+
} catch {
|
|
149
|
+
return [];
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// A cheap snapshot of the working tree: current HEAD + the set of dirty files.
|
|
154
|
+
// Comparing two snapshots gives a tick's ACTUAL contribution (a new commit, or
|
|
155
|
+
// files it newly dirtied) — never the whole pre-existing dirty tree.
|
|
156
|
+
function gitSnapshot(root) {
|
|
157
|
+
let head = null;
|
|
158
|
+
try {
|
|
159
|
+
const r = spawnSync('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8', timeout: 15000 });
|
|
160
|
+
if (r.status === 0) head = String(r.stdout || '').trim();
|
|
161
|
+
} catch {}
|
|
162
|
+
return { head, dirty: new Set(gitChangedFiles(root)) };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function runVerify(root, verifyCmd, timeoutMs = 600000) {
|
|
166
|
+
if (!verifyCmd) return { passed: null, cmd: null };
|
|
167
|
+
const r = spawnSync(verifyCmd, {
|
|
168
|
+
cwd: root,
|
|
169
|
+
shell: true,
|
|
170
|
+
encoding: 'utf8',
|
|
171
|
+
timeout: timeoutMs,
|
|
172
|
+
env: { ...process.env, ATRIS_SKIP_UPDATE_CHECK: '1', ATRIS_AGENT_PROOF_ONLY: '0' },
|
|
173
|
+
});
|
|
174
|
+
return { passed: r.status === 0, cmd: verifyCmd, status: r.status };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// --- atris pulse tick ---
|
|
178
|
+
|
|
179
|
+
function tickCommand(args, root = process.cwd()) {
|
|
180
|
+
const asJson = wantsJson(args);
|
|
181
|
+
const noClaude = hasFlag(args, '--no-claude');
|
|
182
|
+
const noVerify = hasFlag(args, '--no-verify');
|
|
183
|
+
const verifyCmd = noVerify ? null : readFlag(args, '--verify', 'npm test');
|
|
184
|
+
const startedAt = Date.now();
|
|
185
|
+
|
|
186
|
+
// Detect a previous tick that died mid-run before we take the lock.
|
|
187
|
+
const priorReceipts = pulse.readPulseReceipts(root);
|
|
188
|
+
const priorStale = pulse.detectStaleTick(priorReceipts);
|
|
189
|
+
|
|
190
|
+
const lock = pulse.acquireLock(root);
|
|
191
|
+
if (!lock.acquired) {
|
|
192
|
+
const out = { ok: false, action: 'pulse_tick', skipped: true, reason: 'locked', age_ms: lock.ageMs };
|
|
193
|
+
if (!asJson) process.stdout.write('pulse: previous tick still running; skipped.\n');
|
|
194
|
+
return emit(out, asJson);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const tickIndex = pulse.nextTickIndex(root);
|
|
198
|
+
|
|
199
|
+
// 'started' receipt — if the tick dies after this, the orphan surfaces as a ghost.
|
|
200
|
+
pulse.appendPulseReceipt(root, pulse.buildPulseReceipt({
|
|
201
|
+
tickIndex,
|
|
202
|
+
phase: 'started',
|
|
203
|
+
prevTickStale: priorStale.stale,
|
|
204
|
+
}));
|
|
205
|
+
|
|
206
|
+
let engine;
|
|
207
|
+
let verify = { passed: null, cmd: verifyCmd };
|
|
208
|
+
try {
|
|
209
|
+
const before = gitSnapshot(root);
|
|
210
|
+
engine = runEngine(root, { noClaude, autopilotFallback: !hasFlag(args, '--no-autopilot') });
|
|
211
|
+
const after = gitSnapshot(root);
|
|
212
|
+
// This tick's ACTUAL contribution: files it newly dirtied, or a new commit.
|
|
213
|
+
// Pre-existing dirt is excluded so reward isn't re-credited every tick.
|
|
214
|
+
const changedFiles = [...after.dirty].filter((f) => !before.dirty.has(f));
|
|
215
|
+
const committed = Boolean(before.head && after.head && before.head !== after.head);
|
|
216
|
+
const producedWork = committed || changedFiles.length > 0;
|
|
217
|
+
|
|
218
|
+
// Verify only matters when the tick produced work; a no-op tick skips it.
|
|
219
|
+
if (producedWork && verifyCmd) {
|
|
220
|
+
verify = runVerify(root, verifyCmd);
|
|
221
|
+
} else if (verifyCmd) {
|
|
222
|
+
verify = { passed: null, cmd: verifyCmd, skipped: true };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const elapsedMs = Date.now() - startedAt;
|
|
226
|
+
const reward = pulse.scoreTick({ verifyPassed: verify.passed, producedWork });
|
|
227
|
+
const changedTail = committed
|
|
228
|
+
? ' — committed'
|
|
229
|
+
: (changedFiles.length ? ` — ${changedFiles.length} file(s) changed` : '');
|
|
230
|
+
const what = engine.actor === 'autopilot'
|
|
231
|
+
? `autopilot authored/advanced a goal${changedTail}`
|
|
232
|
+
: engine.reason === 'no_due_mission'
|
|
233
|
+
? 'no due mission; heartbeat alive (no-op)'
|
|
234
|
+
: `mission ${engine.reason}${changedTail}`;
|
|
235
|
+
|
|
236
|
+
const receipt = pulse.buildPulseReceipt({
|
|
237
|
+
tickIndex,
|
|
238
|
+
phase: 'finished',
|
|
239
|
+
actor: engine.actor,
|
|
240
|
+
actorOk: engine.ok,
|
|
241
|
+
actorReason: engine.reason,
|
|
242
|
+
verifyCmd: verify.cmd,
|
|
243
|
+
verifyPassed: verify.passed,
|
|
244
|
+
changedFiles,
|
|
245
|
+
what,
|
|
246
|
+
elapsedMs,
|
|
247
|
+
prevTickStale: priorStale.stale,
|
|
248
|
+
reward,
|
|
249
|
+
});
|
|
250
|
+
pulse.appendPulseReceipt(root, receipt);
|
|
251
|
+
|
|
252
|
+
// Revive the reward channel — but only when there is signal (gate noise).
|
|
253
|
+
let scorecardWritten = false;
|
|
254
|
+
if (pulse.shouldWriteScorecard({ reward })) {
|
|
255
|
+
pulse.appendScorecard(root, pulse.buildPulseScorecardRow({
|
|
256
|
+
reward,
|
|
257
|
+
verifyPassed: verify.passed,
|
|
258
|
+
what,
|
|
259
|
+
changedFiles,
|
|
260
|
+
elapsedMs,
|
|
261
|
+
model: process.env.ATRIS_RUNNER_MODEL || process.env.ATRIS_CLAUDE_MODEL || 'opus',
|
|
262
|
+
}));
|
|
263
|
+
scorecardWritten = true;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const out = {
|
|
267
|
+
ok: true,
|
|
268
|
+
action: 'pulse_tick',
|
|
269
|
+
tick_index: tickIndex,
|
|
270
|
+
actor: engine.actor,
|
|
271
|
+
actor_reason: engine.reason,
|
|
272
|
+
verify_passed: verify.passed,
|
|
273
|
+
reward,
|
|
274
|
+
scorecard_written: scorecardWritten,
|
|
275
|
+
changed_files: changedFiles,
|
|
276
|
+
prev_tick_stale: priorStale.stale,
|
|
277
|
+
elapsed_ms: elapsedMs,
|
|
278
|
+
receipts_path: pulse.pulseReceiptsPath(root),
|
|
279
|
+
};
|
|
280
|
+
if (!asJson) {
|
|
281
|
+
const ghost = priorStale.stale ? ` (recovered ghost tick #${priorStale.tick_index || '?'})` : '';
|
|
282
|
+
const r = reward > 0 ? `+${reward}` : String(reward);
|
|
283
|
+
process.stdout.write(`pulse tick #${tickIndex}: ${what} — verify ${verify.passed === null ? 'n/a' : verify.passed ? 'pass' : 'FAIL'} — reward ${r}${ghost}\n`);
|
|
284
|
+
}
|
|
285
|
+
return emit(out, asJson);
|
|
286
|
+
} catch (err) {
|
|
287
|
+
// Surface the death instead of leaving a silent orphan 'started' receipt.
|
|
288
|
+
pulse.appendPulseReceipt(root, pulse.buildPulseReceipt({
|
|
289
|
+
tickIndex,
|
|
290
|
+
phase: 'finished',
|
|
291
|
+
actor: 'mission_run_due',
|
|
292
|
+
actorOk: false,
|
|
293
|
+
actorReason: 'error',
|
|
294
|
+
what: `tick crashed: ${err && err.message ? err.message : String(err)}`,
|
|
295
|
+
elapsedMs: Date.now() - startedAt,
|
|
296
|
+
reward: -1,
|
|
297
|
+
}));
|
|
298
|
+
const out = { ok: false, action: 'pulse_tick', tick_index: tickIndex, error: err && err.message ? err.message : String(err) };
|
|
299
|
+
if (!asJson) process.stdout.write(`pulse tick #${tickIndex} crashed: ${out.error}\n`);
|
|
300
|
+
return emit(out, asJson);
|
|
301
|
+
} finally {
|
|
302
|
+
pulse.releaseLock(root);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// --- atris pulse status ---
|
|
307
|
+
|
|
308
|
+
function cronInstalled(marker = pulse.PULSE_MARKER) {
|
|
309
|
+
try {
|
|
310
|
+
const r = spawnSync('crontab', ['-l'], { encoding: 'utf8', timeout: 10000 });
|
|
311
|
+
if (r.status !== 0) return false;
|
|
312
|
+
return String(r.stdout || '').includes(marker);
|
|
313
|
+
} catch {
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function statusCommand(args, root = process.cwd()) {
|
|
319
|
+
const asJson = wantsJson(args);
|
|
320
|
+
const receipts = pulse.readPulseReceipts(root);
|
|
321
|
+
const summary = pulse.summarizePulse(receipts);
|
|
322
|
+
const installed = cronInstalled();
|
|
323
|
+
const out = {
|
|
324
|
+
ok: true,
|
|
325
|
+
action: 'pulse_status',
|
|
326
|
+
cron_installed: installed,
|
|
327
|
+
...summary,
|
|
328
|
+
};
|
|
329
|
+
if (!asJson) {
|
|
330
|
+
process.stdout.write([
|
|
331
|
+
`pulse: ${installed ? 'cron INSTALLED' : 'cron NOT installed (run: atris pulse install)'}`,
|
|
332
|
+
`ticks: ${summary.total_ticks} | reward: ${summary.reward_sum} | verify pass/fail: ${summary.verify_pass}/${summary.verify_fail}`,
|
|
333
|
+
`last tick: ${summary.last_tick_ts || 'never'} (verify ${summary.last_verify_passed === null ? 'n/a' : summary.last_verify_passed ? 'pass' : 'FAIL'})`,
|
|
334
|
+
summary.stale.stale ? `⚠ STALE: ${summary.stale.reason}${summary.stale.tick_index ? ` (ghost tick #${summary.stale.tick_index})` : ''}` : 'liveness: fresh',
|
|
335
|
+
summary.orphan_ticks.length ? `⚠ orphan (crashed) ticks: ${summary.orphan_ticks.join(', ')}` : '',
|
|
336
|
+
].filter(Boolean).join('\n') + '\n');
|
|
337
|
+
}
|
|
338
|
+
return emit(out, asJson);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// --- atris pulse install / uninstall ---
|
|
342
|
+
|
|
343
|
+
function resolveAtrisBin() {
|
|
344
|
+
// Prefer a globally linked `atris`; fall back to this checkout's bin.
|
|
345
|
+
const which = spawnSync('which', ['atris'], { encoding: 'utf8', timeout: 8000 });
|
|
346
|
+
if (which.status === 0 && which.stdout.trim()) return which.stdout.trim();
|
|
347
|
+
return `${process.execPath} ${path.join(__dirname, '..', 'bin', 'atris.js')}`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// The dirs holding the binaries the engine spawns by bare name (claude, node,
|
|
351
|
+
// git, atris). Baked into the cron script's PATH so a minimal cron environment
|
|
352
|
+
// can still find them. Missing tools just contribute nothing.
|
|
353
|
+
function resolveEngineBinDirs(extraBins = []) {
|
|
354
|
+
const dirs = new Set([path.dirname(process.execPath)]); // node
|
|
355
|
+
for (const tool of ['ax', 'claude', 'git', 'atris']) {
|
|
356
|
+
const r = spawnSync('which', [tool], { encoding: 'utf8', timeout: 8000 });
|
|
357
|
+
if (r.status === 0 && r.stdout.trim()) dirs.add(path.dirname(r.stdout.trim()));
|
|
358
|
+
}
|
|
359
|
+
for (const bin of [process.env.ATRIS_RUNNER_BIN, process.env.ATRIS_CLAUDE_BIN, ...extraBins]) {
|
|
360
|
+
const configured = String(bin || '').trim();
|
|
361
|
+
if (configured && configured.includes(path.sep)) dirs.add(path.dirname(configured));
|
|
362
|
+
if (configured && !configured.includes(path.sep)) {
|
|
363
|
+
const r = spawnSync('which', [configured], { encoding: 'utf8', timeout: 8000 });
|
|
364
|
+
if (r.status === 0 && r.stdout.trim()) dirs.add(path.dirname(r.stdout.trim()));
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
dirs.add(path.join(os.homedir(), '.local', 'bin')); // common claude location
|
|
368
|
+
return Array.from(dirs);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function installCommand(args, root = process.cwd()) {
|
|
372
|
+
const asJson = wantsJson(args);
|
|
373
|
+
const cron = readFlag(args, '--cadence', pulse.DEFAULT_CADENCE_CRON);
|
|
374
|
+
const days = Math.max(1, Number(readFlag(args, '--days', '7')) || 7);
|
|
375
|
+
const verifyCmd = readFlag(args, '--verify', 'npm test');
|
|
376
|
+
const model = readFlag(args, '--model', process.env.ATRIS_RUNNER_MODEL || process.env.ATRIS_CLAUDE_MODEL || 'opus');
|
|
377
|
+
const runnerProfile = readFlag(args, '--runner-profile', process.env.ATRIS_RUNNER_PROFILE || '');
|
|
378
|
+
const runnerBin = readFlag(args, '--runner-bin', process.env.ATRIS_RUNNER_BIN || process.env.ATRIS_CLAUDE_BIN || '');
|
|
379
|
+
const runnerCommandTemplate = readFlag(args, '--runner-template', process.env.ATRIS_RUNNER_COMMAND_TEMPLATE || process.env.ATRIS_CLAUDE_COMMAND_TEMPLATE || '');
|
|
380
|
+
const deadlineEpoch = Math.floor(Date.now() / 1000) + days * 86400;
|
|
381
|
+
|
|
382
|
+
fs.mkdirSync(STATE_HOME, { recursive: true });
|
|
383
|
+
const scriptPath = path.join(STATE_HOME, 'tick.sh');
|
|
384
|
+
// Resolve the real bin dirs the engine spawns by bare name, so cron's minimal
|
|
385
|
+
// PATH doesn't silently break the worker spawn (claude lives in ~/.local/bin).
|
|
386
|
+
const pathDirs = resolveEngineBinDirs([runnerBin]);
|
|
387
|
+
const script = pulse.buildTickScript({
|
|
388
|
+
root,
|
|
389
|
+
atrisBin: resolveAtrisBin(),
|
|
390
|
+
stateHome: STATE_HOME,
|
|
391
|
+
deadlineEpoch,
|
|
392
|
+
model,
|
|
393
|
+
runnerProfile,
|
|
394
|
+
runnerBin,
|
|
395
|
+
runnerCommandTemplate,
|
|
396
|
+
verifyCmd,
|
|
397
|
+
pathDirs,
|
|
398
|
+
});
|
|
399
|
+
fs.writeFileSync(scriptPath, script, { mode: 0o755 });
|
|
400
|
+
|
|
401
|
+
const line = pulse.buildCrontabLine({ cron, scriptPath });
|
|
402
|
+
// Append our line to the existing crontab (idempotent: strip any prior marker first).
|
|
403
|
+
const existing = spawnSync('crontab', ['-l'], { encoding: 'utf8', timeout: 10000 });
|
|
404
|
+
const prior = existing.status === 0 ? String(existing.stdout || '') : '';
|
|
405
|
+
const cleaned = prior.split('\n').filter((l) => l && !l.includes(pulse.PULSE_MARKER)).join('\n');
|
|
406
|
+
const next = `${cleaned ? cleaned + '\n' : ''}${line}\n`;
|
|
407
|
+
const apply = spawnSync('crontab', ['-'], { input: next, encoding: 'utf8', timeout: 10000 });
|
|
408
|
+
|
|
409
|
+
const out = {
|
|
410
|
+
ok: apply.status === 0,
|
|
411
|
+
action: 'pulse_install',
|
|
412
|
+
script_path: scriptPath,
|
|
413
|
+
crontab_line: line,
|
|
414
|
+
cadence: cron,
|
|
415
|
+
expires_in_days: days,
|
|
416
|
+
deadline_epoch: deadlineEpoch,
|
|
417
|
+
runner_profile: runnerProfile || null,
|
|
418
|
+
runner_bin: runnerBin || null,
|
|
419
|
+
runner_template_configured: Boolean(runnerCommandTemplate),
|
|
420
|
+
};
|
|
421
|
+
if (!asJson) {
|
|
422
|
+
if (apply.status === 0) {
|
|
423
|
+
process.stdout.write([
|
|
424
|
+
`pulse installed. heartbeat fires '${cron}' against ${root}.`,
|
|
425
|
+
`script: ${scriptPath}`,
|
|
426
|
+
`auto-expires in ${days} days. stop early: atris pulse uninstall`,
|
|
427
|
+
].join('\n') + '\n');
|
|
428
|
+
} else {
|
|
429
|
+
process.stdout.write(`pulse install failed to write crontab: ${apply.stderr || apply.status}\nscript written to ${scriptPath}; add this line to your crontab manually:\n${line}\n`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return emit(out, asJson);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function uninstallCommand(args) {
|
|
436
|
+
const asJson = wantsJson(args);
|
|
437
|
+
const existing = spawnSync('crontab', ['-l'], { encoding: 'utf8', timeout: 10000 });
|
|
438
|
+
if (existing.status !== 0) {
|
|
439
|
+
const out = { ok: true, action: 'pulse_uninstall', removed: false, reason: 'no_crontab' };
|
|
440
|
+
if (!asJson) process.stdout.write('pulse: no crontab to clean.\n');
|
|
441
|
+
return emit(out, asJson);
|
|
442
|
+
}
|
|
443
|
+
const prior = String(existing.stdout || '');
|
|
444
|
+
const had = prior.includes(pulse.PULSE_MARKER);
|
|
445
|
+
const cleaned = prior.split('\n').filter((l) => l && !l.includes(pulse.PULSE_MARKER)).join('\n');
|
|
446
|
+
const apply = spawnSync('crontab', ['-'], { input: cleaned ? cleaned + '\n' : '', encoding: 'utf8', timeout: 10000 });
|
|
447
|
+
const out = { ok: apply.status === 0, action: 'pulse_uninstall', removed: had };
|
|
448
|
+
if (!asJson) process.stdout.write(had ? 'pulse uninstalled (crontab line removed).\n' : 'pulse: no heartbeat line found.\n');
|
|
449
|
+
return emit(out, asJson);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// --- atris pulse run (foreground N ticks) ---
|
|
453
|
+
|
|
454
|
+
function runCommand(args, root = process.cwd()) {
|
|
455
|
+
const asJson = wantsJson(args);
|
|
456
|
+
const maxTicks = Math.max(1, Number(readFlag(args, '--max-ticks', '1')) || 1);
|
|
457
|
+
const passthrough = args.filter((a) => a !== '--max-ticks' && a !== String(maxTicks));
|
|
458
|
+
const results = [];
|
|
459
|
+
for (let i = 0; i < maxTicks; i++) {
|
|
460
|
+
results.push(tickCommand(passthrough.concat(['--json-silent']).filter((a) => a !== '--json'), root));
|
|
461
|
+
}
|
|
462
|
+
const out = { ok: true, action: 'pulse_run', ticks: results.length, results };
|
|
463
|
+
return emit(out, asJson);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function pulseCommand(argv = []) {
|
|
467
|
+
const sub = argv[0];
|
|
468
|
+
const rest = argv.slice(1);
|
|
469
|
+
if (sub === '--help' || sub === '-h' || sub === 'help') return showHelp();
|
|
470
|
+
switch (sub) {
|
|
471
|
+
case 'tick':
|
|
472
|
+
return tickCommand(rest);
|
|
473
|
+
case 'status':
|
|
474
|
+
case undefined:
|
|
475
|
+
return statusCommand(rest);
|
|
476
|
+
case 'install':
|
|
477
|
+
return installCommand(rest);
|
|
478
|
+
case 'uninstall':
|
|
479
|
+
return uninstallCommand(rest);
|
|
480
|
+
case 'run':
|
|
481
|
+
return runCommand(rest);
|
|
482
|
+
default: {
|
|
483
|
+
const asJson = wantsJson(rest);
|
|
484
|
+
const out = { ok: false, action: 'pulse', error: `unknown subcommand: ${sub}`, usage: 'atris pulse tick|status|install|uninstall|run' };
|
|
485
|
+
if (!asJson) process.stdout.write(`${out.error}\nUsage: ${out.usage}\n`);
|
|
486
|
+
return emit(out, asJson);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
module.exports = {
|
|
492
|
+
pulseCommand,
|
|
493
|
+
tickCommand,
|
|
494
|
+
statusCommand,
|
|
495
|
+
installCommand,
|
|
496
|
+
uninstallCommand,
|
|
497
|
+
runCommand,
|
|
498
|
+
runMissionEngine,
|
|
499
|
+
runAutopilotTick,
|
|
500
|
+
runEngine,
|
|
501
|
+
gitChangedFiles,
|
|
502
|
+
runVerify,
|
|
503
|
+
STATE_HOME,
|
|
504
|
+
};
|
package/commands/radar.js
CHANGED
|
@@ -54,6 +54,7 @@ function agentTypeForCommand(command) {
|
|
|
54
54
|
if (/(^|\s|\/)claude(\s|$)/.test(cmd) && !/Claude\.app/.test(cmd)) return 'claude';
|
|
55
55
|
if (/(^|\s|\/)opencode(\s|$)/.test(cmd)) return 'opencode';
|
|
56
56
|
if (/(^|\s|\/)devin(\s|$)/.test(cmd)) return 'devin';
|
|
57
|
+
if (/(^|\s|\/)droid(\s|$)/.test(cmd)) return 'droid';
|
|
57
58
|
return null;
|
|
58
59
|
}
|
|
59
60
|
|