atris 3.16.1 → 3.17.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 +400 -30
- 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 +135 -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 +55 -25
- package/commands/run.js +615 -22
- package/commands/slop.js +173 -0
- package/commands/spaceship.js +39 -0
- package/commands/sync.js +0 -2
- package/commands/task.js +429 -37
- 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/escape-regexp.js +13 -0
- package/lib/file-ops.js +6 -3
- package/lib/journal.js +1 -1
- package/lib/lesson-contradiction.js +113 -0
- package/lib/policy-lessons.js +3 -2
- package/lib/pulse.js +401 -0
- package/lib/runner-command.js +156 -0
- package/lib/slides-deck.js +236 -0
- package/lib/state-detection.js +1 -4
- package/lib/task-db.js +101 -4
- package/lib/task-proof.js +1 -1
- 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
|
|
package/commands/recap.js
CHANGED
|
@@ -53,8 +53,35 @@ function shortProof(proof, width = 70) {
|
|
|
53
53
|
return flat.length <= width ? flat : `${flat.slice(0, width - 1)}…`;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
function plainCheck(proof, width = 70) {
|
|
57
|
+
if (!proof) return null;
|
|
58
|
+
const flat = proof.replace(/\s+/g, ' ').trim();
|
|
59
|
+
const checks = [];
|
|
60
|
+
const add = label => { if (!checks.includes(label)) checks.push(label); };
|
|
61
|
+
|
|
62
|
+
if (/\b(PR|pull request)\b.*\b(merged|MERGED)\b|\bmerged\b.*\b(PR|pull request)\b/i.test(flat)) add('merged');
|
|
63
|
+
if (/\b(node --test|npm test|npm run test|pytest|go test|cargo test|test\/|tests?)\b/i.test(flat)
|
|
64
|
+
&& /\b(pass|passed|green|ok|0 failures?|9\/9|12\/12)\b/i.test(flat)) add('tests passed');
|
|
65
|
+
if (/\b(node --check|git diff --check|git diff --exit-code|git diff --quiet|rg\b|grep\b|diff --brief|cmp -s)\b/i.test(flat)) add('code check passed');
|
|
66
|
+
if (/\b(bench|benchmark|measured|latency|speed)\b/i.test(flat)) add('measured improvement');
|
|
67
|
+
if (/\brepeated agent review\b|\bagent review\b/i.test(flat)) add('reviewed repeatedly');
|
|
68
|
+
if (/\batris\/runs\/[^\s]+\.json\b|receipt\b/i.test(flat)) add('record saved');
|
|
69
|
+
if (/\b(human approved|accepted by|accepted_at|reward)\b/i.test(flat)) add('human accepted');
|
|
70
|
+
|
|
71
|
+
if (checks.length) return checks.join(', ');
|
|
72
|
+
return shortProof(proof, width);
|
|
73
|
+
}
|
|
74
|
+
|
|
56
75
|
function shortTitle(title, width = 64) {
|
|
57
|
-
const flat = String(title || '')
|
|
76
|
+
const flat = String(title || '')
|
|
77
|
+
.replace(/\bproof\b/gi, 'checks')
|
|
78
|
+
.replace(/\breceipts?\b/gi, 'records')
|
|
79
|
+
.replace(/\bsign[- ]off\b/gi, 'approval')
|
|
80
|
+
.replace(/\bpolicy\b/gi, 'rules')
|
|
81
|
+
.replace(/\bAgentXP\b/g, 'reward')
|
|
82
|
+
.replace(/\bcertified\b/gi, 'checked')
|
|
83
|
+
.replace(/\s+/g, ' ')
|
|
84
|
+
.trim();
|
|
58
85
|
return flat.length <= width ? flat : `${flat.slice(0, width - 1)}…`;
|
|
59
86
|
}
|
|
60
87
|
|
|
@@ -103,85 +130,88 @@ function renderRecap(data) {
|
|
|
103
130
|
`RECAP — ${data.workspace}`,
|
|
104
131
|
'',
|
|
105
132
|
'No task history yet.',
|
|
106
|
-
'Run "atris init", then let
|
|
133
|
+
'Run "atris init", then let Atris do one small job. Finished work will show up here with the checks that passed.',
|
|
107
134
|
].join('\n');
|
|
108
135
|
}
|
|
109
136
|
const lines = [];
|
|
110
137
|
lines.push(`RECAP — ${data.workspace} — last ${data.days} day${data.days === 1 ? '' : 's'}`);
|
|
111
138
|
lines.push('');
|
|
139
|
+
lines.push('Plain English: what changed, how it was checked, and what still needs you.');
|
|
112
140
|
const headline = [];
|
|
113
|
-
if (data.shipped.length) headline.push(`${data.shipped.length}
|
|
114
|
-
if (data.waiting.length) headline.push(`${data.waiting.length}
|
|
115
|
-
if (data.inProgress.length) headline.push(`${data.inProgress.length}
|
|
116
|
-
lines.push(headline.length ?
|
|
117
|
-
lines.push('Every finished line below carries proof: the commands run and their results.');
|
|
141
|
+
if (data.shipped.length) headline.push(`${data.shipped.length} done`);
|
|
142
|
+
if (data.waiting.length) headline.push(`${data.waiting.length} needs you`);
|
|
143
|
+
if (data.inProgress.length) headline.push(`${data.inProgress.length} still working`);
|
|
144
|
+
lines.push(headline.length ? headline.join(' · ') : 'Quiet window — no movement in this period.');
|
|
118
145
|
|
|
119
146
|
if (data.shipped.length) {
|
|
120
147
|
lines.push('');
|
|
121
|
-
lines.push(`
|
|
148
|
+
lines.push(`DONE — ${data.shipped.length}`);
|
|
122
149
|
for (const t of data.shipped.slice(0, 12)) {
|
|
123
150
|
lines.push(` ${t.id} ${shortTitle(t.title)}`);
|
|
124
|
-
|
|
151
|
+
const check = plainCheck(t.proof);
|
|
152
|
+
if (check) lines.push(` checked: ${check}`);
|
|
125
153
|
}
|
|
126
|
-
if (data.shipped.length > 12) lines.push(` … and ${data.shipped.length - 12} more
|
|
154
|
+
if (data.shipped.length > 12) lines.push(` … and ${data.shipped.length - 12} more`);
|
|
127
155
|
}
|
|
128
156
|
|
|
129
157
|
if (data.waiting.length) {
|
|
130
158
|
lines.push('');
|
|
131
|
-
lines.push(`
|
|
159
|
+
lines.push(`NEEDS YOU — ${data.waiting.length}`);
|
|
132
160
|
for (const t of data.waiting.slice(0, 10)) {
|
|
133
161
|
lines.push(` ${t.id} ${shortTitle(t.title)}`);
|
|
162
|
+
const check = plainCheck(t.proof);
|
|
163
|
+
if (check) lines.push(` checked: ${check}`);
|
|
134
164
|
}
|
|
135
165
|
if (data.waiting.length > 10) lines.push(` … and ${data.waiting.length - 10} more`);
|
|
136
|
-
lines.push('
|
|
166
|
+
lines.push(' next: run atris task reviews');
|
|
137
167
|
}
|
|
138
168
|
|
|
139
169
|
if (data.inProgress.length) {
|
|
140
170
|
lines.push('');
|
|
141
|
-
lines.push(`
|
|
171
|
+
lines.push(`STILL WORKING — ${data.inProgress.length}`);
|
|
142
172
|
for (const t of data.inProgress) {
|
|
143
173
|
lines.push(` ${t.id} ${shortTitle(t.title)}${t.owner ? ` @${t.owner}` : ''}`);
|
|
144
174
|
}
|
|
145
175
|
}
|
|
146
176
|
|
|
147
177
|
lines.push('');
|
|
148
|
-
lines.push(`
|
|
149
|
-
lines.push('
|
|
178
|
+
lines.push(`Checked: ${data.proof_attached}/${data.proof_total} finished items.`);
|
|
179
|
+
lines.push('Share this: atris recap --share');
|
|
150
180
|
return lines.join('\n');
|
|
151
181
|
}
|
|
152
182
|
|
|
153
183
|
function renderShare(data) {
|
|
154
184
|
if (data.empty) return `Nothing to share yet on ${data.workspace} — no finished tasks on record.`;
|
|
155
185
|
const lines = [];
|
|
156
|
-
lines.push(`What
|
|
186
|
+
lines.push(`What got done on ${data.workspace} in the last ${data.days} day${data.days === 1 ? '' : 's'}:`);
|
|
157
187
|
lines.push('');
|
|
158
|
-
if (data.shipped.length) lines.push(`- ${data.shipped.length}
|
|
159
|
-
if (data.waiting.length) lines.push(`- ${data.waiting.length}
|
|
160
|
-
if (data.inProgress.length) lines.push(`- ${data.inProgress.length}
|
|
188
|
+
if (data.shipped.length) lines.push(`- ${data.shipped.length} done and accepted`);
|
|
189
|
+
if (data.waiting.length) lines.push(`- ${data.waiting.length} ready for you to approve or send back`);
|
|
190
|
+
if (data.inProgress.length) lines.push(`- ${data.inProgress.length} still being worked on`);
|
|
161
191
|
const highlights = [...data.shipped, ...data.waiting].filter(t => t.proof).slice(0, 5);
|
|
162
192
|
if (highlights.length) {
|
|
163
193
|
lines.push('');
|
|
164
194
|
lines.push('Highlights:');
|
|
165
195
|
for (const t of highlights) {
|
|
166
|
-
lines.push(`- ${shortTitle(t.title, 80)} (
|
|
196
|
+
lines.push(`- ${shortTitle(t.title, 80)} (${plainCheck(t.proof, 60)})`);
|
|
167
197
|
}
|
|
168
198
|
}
|
|
169
199
|
lines.push('');
|
|
170
|
-
lines.push('
|
|
200
|
+
lines.push('The finished items are backed by actual checks that ran, not a status update someone typed.');
|
|
171
201
|
return lines.join('\n');
|
|
172
202
|
}
|
|
173
203
|
|
|
174
204
|
function printRecapHelp() {
|
|
175
205
|
console.log(`
|
|
176
|
-
atris recap - what
|
|
206
|
+
atris recap - what got done, in plain English
|
|
177
207
|
|
|
178
|
-
atris recap Last 7 days:
|
|
208
|
+
atris recap Last 7 days: done, needs you, still working
|
|
179
209
|
atris recap --days 30 Widen the window
|
|
180
210
|
atris recap --share Paste-ready summary for Slack, email, or a customer
|
|
181
211
|
atris recap --json Structured output for agents and dashboards
|
|
182
212
|
|
|
183
|
-
|
|
184
|
-
|
|
213
|
+
Looks at Atris' saved work and explains it without internal jargon:
|
|
214
|
+
what changed, how it was checked, and what still needs you.
|
|
185
215
|
`);
|
|
186
216
|
}
|
|
187
217
|
|