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.
Files changed (58) 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 +400 -30
  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 +135 -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 +55 -25
  34. package/commands/run.js +615 -22
  35. package/commands/slop.js +173 -0
  36. package/commands/spaceship.js +39 -0
  37. package/commands/sync.js +0 -2
  38. package/commands/task.js +429 -37
  39. package/commands/verify.js +7 -3
  40. package/lib/activity-stream.js +166 -0
  41. package/lib/auto-accept-certified.js +23 -1
  42. package/lib/context-gatherer.js +170 -0
  43. package/lib/escape-regexp.js +13 -0
  44. package/lib/file-ops.js +6 -3
  45. package/lib/journal.js +1 -1
  46. package/lib/lesson-contradiction.js +113 -0
  47. package/lib/policy-lessons.js +3 -2
  48. package/lib/pulse.js +401 -0
  49. package/lib/runner-command.js +156 -0
  50. package/lib/slides-deck.js +236 -0
  51. package/lib/state-detection.js +1 -4
  52. package/lib/task-db.js +101 -4
  53. package/lib/task-proof.js +1 -1
  54. package/lib/todo-fallback.js +2 -1
  55. package/lib/todo-sections.js +33 -0
  56. package/package.json +1 -2
  57. package/utils/api.js +14 -2
  58. 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 || '').replace(/\s+/g, ' ').trim();
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 an agent work every finished task lands here with proof.',
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} change${data.shipped.length === 1 ? '' : 's'} shipped`);
114
- if (data.waiting.length) headline.push(`${data.waiting.length} finished and waiting for your sign-off`);
115
- if (data.inProgress.length) headline.push(`${data.inProgress.length} in progress`);
116
- lines.push(headline.length ? `Your AI team: ${headline.join(' · ')}.` : 'Quiet window — no movement in this period.');
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(`SHIPPED (accepted by a human) — ${data.shipped.length}`);
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
- if (t.proof) lines.push(` proof: ${shortProof(t.proof)}`);
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, all with proof on file`);
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(`FINISHED, WAITING FOR YOUR SIGN-OFF — ${data.waiting.length}`);
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(' approve or send back: atris task reviews');
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(`IN PROGRESS — ${data.inProgress.length}`);
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(`Proof attached: ${data.proof_attached}/${data.proof_total} finished items.`);
149
- lines.push('Paste-ready summary for Slack or email: atris recap --share');
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 the AI team did on ${data.workspace} in the last ${data.days} day${data.days === 1 ? '' : 's'}:`);
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} change${data.shipped.length === 1 ? '' : 's'} shipped, each verified before a human accepted it`);
159
- if (data.waiting.length) lines.push(`- ${data.waiting.length} more finished with proof attached, waiting for human sign-off`);
160
- if (data.inProgress.length) lines.push(`- ${data.inProgress.length} task${data.inProgress.length === 1 ? '' : 's'} in progress`);
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)} (proof: ${shortProof(t.proof, 60)})`);
196
+ lines.push(`- ${shortTitle(t.title, 80)} (${plainCheck(t.proof, 60)})`);
167
197
  }
168
198
  }
169
199
  lines.push('');
170
- lines.push('Every item above is backed by a receipt the exact commands run and their results — not a status update someone typed.');
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 your AI team actually did, in plain English
206
+ atris recap - what got done, in plain English
177
207
 
178
- atris recap Last 7 days: shipped, waiting on you, in progress
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
- Reads the workspace task records and their proof. No jargon, no guesses:
184
- if it is listed as finished, the receipt is on file.
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