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.
Files changed (65) hide show
  1. package/README.md +32 -7
  2. package/atris/skills/atris/SKILL.md +15 -2
  3. package/atris/skills/atris-feedback/SKILL.md +7 -0
  4. package/atris/skills/design/SKILL.md +29 -2
  5. package/atris/skills/engines/SKILL.md +44 -0
  6. package/atris/skills/flow/SKILL.md +1 -1
  7. package/atris/skills/wake/SKILL.md +37 -0
  8. package/atris/skills/youtube/SKILL.md +13 -39
  9. package/atris/team/validator/MEMBER.md +1 -0
  10. package/atris/wiki/concepts/agent-activation-contract.md +3 -3
  11. package/atris/wiki/concepts/workspace-initialization-contract.md +3 -3
  12. package/atris/wiki/index.md +1 -0
  13. package/atris.md +43 -19
  14. package/bin/atris.js +413 -31
  15. package/commands/agent-spawn.js +480 -0
  16. package/commands/analytics.js +6 -3
  17. package/commands/apps.js +11 -0
  18. package/commands/autopilot.js +42 -18
  19. package/commands/brain.js +74 -7
  20. package/commands/brainstorm.js +9 -58
  21. package/commands/clean.js +1 -4
  22. package/commands/compile.js +9 -4
  23. package/commands/console.js +8 -3
  24. package/commands/deck.js +184 -0
  25. package/commands/init.js +22 -11
  26. package/commands/lesson.js +76 -0
  27. package/commands/member.js +252 -48
  28. package/commands/mission.js +405 -13
  29. package/commands/now.js +4 -2
  30. package/commands/probe.js +105 -27
  31. package/commands/pulse.js +504 -0
  32. package/commands/radar.js +1 -0
  33. package/commands/recap.js +71 -25
  34. package/commands/run.js +615 -22
  35. package/commands/site.js +48 -0
  36. package/commands/slop.js +307 -0
  37. package/commands/spaceship.js +39 -0
  38. package/commands/sync.js +0 -2
  39. package/commands/task.js +429 -37
  40. package/commands/theme.js +217 -0
  41. package/commands/verify.js +7 -3
  42. package/lib/activity-stream.js +166 -0
  43. package/lib/auto-accept-certified.js +23 -1
  44. package/lib/context-gatherer.js +170 -0
  45. package/lib/deck-from-md.js +110 -0
  46. package/lib/escape-regexp.js +13 -0
  47. package/lib/file-ops.js +6 -3
  48. package/lib/html-render.js +257 -0
  49. package/lib/journal.js +1 -1
  50. package/lib/lesson-contradiction.js +113 -0
  51. package/lib/memory-view.js +95 -0
  52. package/lib/policy-lessons.js +3 -2
  53. package/lib/pulse.js +401 -0
  54. package/lib/runner-command.js +156 -0
  55. package/lib/site.js +114 -0
  56. package/lib/slides-deck.js +237 -0
  57. package/lib/state-detection.js +1 -4
  58. package/lib/task-db.js +101 -4
  59. package/lib/task-proof.js +1 -1
  60. package/lib/theme.js +264 -0
  61. package/lib/todo-fallback.js +2 -1
  62. package/lib/todo-sections.js +33 -0
  63. package/package.json +1 -2
  64. package/utils/api.js +14 -2
  65. 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