atris 3.33.3 → 3.35.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 (54) hide show
  1. package/AGENTS.md +0 -2
  2. package/FOR_AGENTS.md +5 -3
  3. package/atris/skills/engines/SKILL.md +16 -7
  4. package/atris/skills/render-cli/SKILL.md +88 -0
  5. package/atris.md +19 -0
  6. package/ax +475 -17
  7. package/bin/atris.js +206 -44
  8. package/commands/aeo.js +52 -0
  9. package/commands/autoland.js +313 -19
  10. package/commands/business.js +91 -8
  11. package/commands/codex-goal.js +26 -2
  12. package/commands/drive.js +187 -0
  13. package/commands/engine.js +73 -2
  14. package/commands/feed.js +202 -0
  15. package/commands/github.js +38 -0
  16. package/commands/gm.js +262 -3
  17. package/commands/init.js +13 -1
  18. package/commands/integrations.js +39 -11
  19. package/commands/interview.js +143 -0
  20. package/commands/land.js +114 -13
  21. package/commands/lesson.js +112 -1
  22. package/commands/linear.js +38 -0
  23. package/commands/loops.js +212 -0
  24. package/commands/member.js +398 -42
  25. package/commands/mission.js +598 -64
  26. package/commands/now.js +25 -1
  27. package/commands/radar.js +259 -14
  28. package/commands/serve.js +54 -0
  29. package/commands/status.js +50 -5
  30. package/commands/stripe.js +38 -0
  31. package/commands/supabase.js +39 -0
  32. package/commands/task.js +935 -71
  33. package/commands/truth.js +29 -3
  34. package/commands/unknowns.js +627 -0
  35. package/commands/update.js +44 -0
  36. package/commands/vercel.js +38 -0
  37. package/commands/worktree.js +68 -10
  38. package/commands/write.js +399 -0
  39. package/lib/auto-accept-certified.js +70 -19
  40. package/lib/autoland.js +39 -3
  41. package/lib/fleet.js +256 -13
  42. package/lib/memory-view.js +14 -5
  43. package/lib/mission-runtime-loop.js +7 -0
  44. package/lib/official-cli-integration.js +174 -0
  45. package/lib/outbound-send-gate.js +165 -0
  46. package/lib/permission-grants.js +293 -0
  47. package/lib/review-integrity.js +147 -0
  48. package/lib/runner-command.js +23 -0
  49. package/lib/task-db.js +220 -7
  50. package/lib/task-proof.js +20 -0
  51. package/lib/task-receipt.js +93 -0
  52. package/package.json +1 -1
  53. package/utils/update-check.js +27 -6
  54. package/atris/learnings.jsonl +0 -1
package/commands/land.js CHANGED
@@ -5,6 +5,7 @@ const { spawnSync } = require('child_process');
5
5
  const { listWorktrees, statusCounts } = require('./worktree');
6
6
 
7
7
  const DEFAULT_TTL_DAYS = 7;
8
+ const WORKTREE_REAP_GRACE_MS = 60 * 60 * 1000;
8
9
  const PROTECTED_BRANCHES = new Set(['main', 'master']);
9
10
 
10
11
  function runGit(args, { cwd = process.cwd(), check = true } = {}) {
@@ -36,6 +37,19 @@ function ageDays(unixSeconds, now = Date.now()) {
36
37
  return Math.floor((now / 1000 - Number(unixSeconds)) / 86400);
37
38
  }
38
39
 
40
+ function worktreeMtimeMs(worktreePath) {
41
+ try {
42
+ return fs.statSync(worktreePath).mtimeMs;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ function worktreeWithinReapGrace(worktreePath, now = Date.now()) {
49
+ const mtime = worktreeMtimeMs(worktreePath);
50
+ return typeof mtime === 'number' && now - mtime < WORKTREE_REAP_GRACE_MS;
51
+ }
52
+
39
53
  function listBranches(root) {
40
54
  const result = runGit(
41
55
  ['for-each-ref', 'refs/heads', '--format=%(refname:short)%09%(committerdate:unix)'],
@@ -146,16 +160,67 @@ function remoteHeads(root) {
146
160
  return heads;
147
161
  }
148
162
 
163
+ // Everything a force-remove would destroy: staged + unstaged tracked changes
164
+ // (git diff HEAD) and untracked files (copied verbatim). Returns false if any
165
+ // piece could not be saved — the caller then keeps the worktree.
166
+ function salvageWorktree(w, dir, receipt) {
167
+ try {
168
+ const diff = runGit(['diff', 'HEAD'], { cwd: w.path, check: false });
169
+ if (diff.status !== 0) return false;
170
+ if (diff.stdout.trim()) {
171
+ const patchPath = path.join(dir, `${path.basename(w.path)}.dirty.patch`);
172
+ fs.writeFileSync(patchPath, diff.stdout);
173
+ receipt.patches.push(patchPath);
174
+ }
175
+ const untracked = runGit(['ls-files', '--others', '--exclude-standard', '-z'], { cwd: w.path, check: false });
176
+ if (untracked.status !== 0) return false;
177
+ const files = untracked.stdout.split('\0').filter(Boolean);
178
+ if (files.length > 0) {
179
+ const destRoot = path.join(dir, `${path.basename(w.path)}.untracked`);
180
+ for (const rel of files) {
181
+ const dest = path.join(destRoot, rel);
182
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
183
+ fs.copyFileSync(path.join(w.path, rel), dest);
184
+ }
185
+ receipt.untracked.push(destRoot);
186
+ }
187
+ return true;
188
+ } catch (err) {
189
+ return false;
190
+ }
191
+ }
192
+
149
193
  // Reap: salvage then delete everything landed (residue) or past TTL.
150
194
  // Salvage first, always: unlanded commits go into a git bundle, dirty
151
- // worktrees into patch files, so a reap is never a loss of work.
152
- function reap(root, { ttlDays = DEFAULT_TTL_DAYS, base: baseOverride = '', dryRun = false, remote = true } = {}) {
195
+ // worktrees into patch files + untracked-file copies, so a reap is never
196
+ // a loss of work and when a backup cannot be written, the work stays.
197
+ // includeDetached: detached-HEAD worktrees have no branch, so the salvage
198
+ // bundle cannot cover their commits — unattended reaps (autoland) pass false
199
+ // and leave them for a human reap.
200
+ function reap(root, { ttlDays = DEFAULT_TTL_DAYS, base: baseOverride = '', dryRun = false, remote = true, includeDetached = true } = {}) {
153
201
  const board = collectBoard(root, { ttlDays, base: baseOverride });
154
202
  const targets = board.branches.filter((b) => b.state === 'landed' || b.state === 'due');
155
203
  const targetNames = new Set(targets.map((b) => b.name));
156
- const worktreeTargets = board.worktrees.filter(
157
- (w) => targetNames.has(w.branch) || w.state === 'detached' || (typeof w.ageDays === 'number' && w.ageDays > ttlDays)
204
+ const candidateWorktrees = board.worktrees.filter(
205
+ (w) => targetNames.has(w.branch) || (includeDetached && w.state === 'detached') || (typeof w.ageDays === 'number' && w.ageDays > ttlDays)
158
206
  );
207
+ const protectedWorktrees = [];
208
+ const worktreeTargets = [];
209
+ for (const w of candidateWorktrees) {
210
+ // Only the fresh-worktree grace protects here. Dirty worktrees are NOT
211
+ // skipped: reap's salvage (bundle + patches + untracked copies below)
212
+ // exists precisely so dirty residue can be cleared loss-free. Blanket
213
+ // dirty protection lives in the janitor's cleanupWorktrees, which has
214
+ // no salvage machinery.
215
+ if (worktreeWithinReapGrace(w.path)) {
216
+ protectedWorktrees.push({ ...w, reason: 'fresh_worktree_grace' });
217
+ continue;
218
+ }
219
+ worktreeTargets.push(w);
220
+ }
221
+ for (const w of protectedWorktrees) {
222
+ if (w.branch) targetNames.delete(w.branch);
223
+ }
159
224
  for (const w of worktreeTargets) {
160
225
  if (w.branch && !targetNames.has(w.branch)) targetNames.add(w.branch);
161
226
  }
@@ -165,7 +230,11 @@ function reap(root, { ttlDays = DEFAULT_TTL_DAYS, base: baseOverride = '', dryRu
165
230
  ttlDays,
166
231
  dryRun,
167
232
  bundle: null,
233
+ bundleError: null,
168
234
  patches: [],
235
+ untracked: [],
236
+ keptWorktrees: protectedWorktrees.map((w) => `${w.path} (${w.reason})`),
237
+ keptMovedBranches: [],
169
238
  removedWorktrees: [],
170
239
  deletedBranches: [],
171
240
  deletedRemote: [],
@@ -190,22 +259,50 @@ function reap(root, { ttlDays = DEFAULT_TTL_DAYS, base: baseOverride = '', dryRu
190
259
  check: false,
191
260
  });
192
261
  if (result.status === 0) receipt.bundle = bundlePath;
262
+ else {
263
+ // Salvage-first is a hard promise: no bundle, no deletion of unlanded
264
+ // work. These branches (and their worktrees) survive until a reap can
265
+ // actually back them up.
266
+ receipt.bundleError = String(result.stderr || result.stdout || 'bundle failed').trim().slice(0, 200);
267
+ for (const name of withCommits) targetNames.delete(name);
268
+ }
193
269
  }
194
270
 
195
- for (const w of worktreeTargets) {
196
- if (w.dirty > 0) {
197
- const diff = runGit(['diff'], { cwd: w.path, check: false });
198
- if (diff.status === 0 && diff.stdout.trim()) {
199
- const patchPath = path.join(dir, `${path.basename(w.path)}.dirty.patch`);
200
- fs.writeFileSync(patchPath, diff.stdout);
201
- receipt.patches.push(patchPath);
202
- }
271
+ const survivingWorktreeTargets = worktreeTargets.filter(
272
+ (w) => targetNames.has(w.branch) || (includeDetached && w.state === 'detached')
273
+ );
274
+ for (const w of survivingWorktreeTargets) {
275
+ // Salvage-then-remove, never keep-because-dirty: patches + untracked
276
+ // copies bank everything force-remove would destroy. The fresh-worktree
277
+ // grace was already applied when candidates were selected.
278
+ if (w.dirty > 0 && !salvageWorktree(w, dir, receipt)) {
279
+ // could not fully back up what force-remove would destroy — keep it
280
+ receipt.keptWorktrees.push(w.path);
281
+ if (w.branch) targetNames.delete(w.branch);
282
+ continue;
203
283
  }
204
284
  const removed = runGit(['worktree', 'remove', '--force', w.path], { cwd: root, check: false });
205
- if (removed.status === 0) receipt.removedWorktrees.push(w.path);
285
+ if (removed.status === 0) {
286
+ receipt.removedWorktrees.push(w.path);
287
+ } else {
288
+ // A locked worktree fails here and, unreported, reap re-salvages the
289
+ // same patches every pass forever. Say what blocked it so a human can
290
+ // unlock (or a dead lock can be challenged) instead of looping.
291
+ const reason = String(removed.stderr || removed.stdout || 'remove failed').trim().slice(0, 160);
292
+ receipt.keptWorktrees.push(`${w.path} (${reason})`);
293
+ if (w.branch) targetNames.delete(w.branch);
294
+ }
206
295
  }
207
296
 
208
297
  for (const name of targetNames) {
298
+ const entry = board.branches.find((b) => b.name === name);
299
+ // the board is a snapshot; an agent may have committed since it was
300
+ // taken. A branch that moved is left alone — the next reap sees the
301
+ // new truth and bundles it before touching it.
302
+ if (!entry || aheadCount(root, board.base, name) !== entry.ahead) {
303
+ receipt.keptMovedBranches.push(name);
304
+ continue;
305
+ }
209
306
  const deleted = runGit(['branch', '-D', name], { cwd: root, check: false });
210
307
  if (deleted.status === 0) receipt.deletedBranches.push(name);
211
308
  }
@@ -359,7 +456,11 @@ function printReceipt(receipt) {
359
456
  } else {
360
457
  console.log(`landing cleanup done — ${receipt.deletedBranches.length} pieces cleared, ${receipt.removedWorktrees.length} side copies removed`);
361
458
  if (receipt.bundle) console.log(` backed up first, nothing lost: ${receipt.bundle}`);
459
+ if (receipt.bundleError) console.log(` backup failed — unlanded work left in place: ${receipt.bundleError}`);
362
460
  for (const p of receipt.patches) console.log(` unsaved edits saved: ${p}`);
461
+ for (const u of receipt.untracked || []) console.log(` new files saved: ${u}`);
462
+ for (const k of receipt.keptWorktrees || []) console.log(` ✋ kept, needs a human: ${k}`);
463
+ for (const m of receipt.keptMovedBranches || []) console.log(` moved since scan, left alone: ${m}`);
363
464
  if (receipt.deletedRemote.length > 0) console.log(` also cleared on github: ${receipt.deletedRemote.length}`);
364
465
  }
365
466
  console.log(` still flying (recent work, left alone): ${receipt.kept.length}`);
@@ -1,9 +1,112 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
- const { writeLesson } = require('./autopilot');
3
+ const {
4
+ writeLesson,
5
+ parseLessons,
6
+ loadLessonMetadata,
7
+ runLessonDetector,
8
+ } = require('./autopilot');
4
9
  const { detectLessonContradictions } = require('../lib/lesson-contradiction');
5
10
  const taskDb = require('../lib/task-db');
6
11
 
12
+ /**
13
+ * Tag a lesson's line in atris/lessons.md with `[resolved]` (idempotent).
14
+ * Inserts the marker right after the verdict separator so both the autopilot
15
+ * parser (`resolvedTag`) and the memory view treat the lesson as retired.
16
+ * @returns {boolean} true if the file was changed.
17
+ */
18
+ function tagLessonResolvedInMd(cwd, slug) {
19
+ const lessonsPath = path.join(cwd, 'atris', 'lessons.md');
20
+ if (!fs.existsSync(lessonsPath)) return false;
21
+ const lines = fs.readFileSync(lessonsPath, 'utf8').split('\n');
22
+ let changed = false;
23
+ for (let i = 0; i < lines.length; i++) {
24
+ const m = lines[i].match(/\*\*\[\d{4}-\d{2}-\d{2}\]\s+([\w-]+)\*\*/);
25
+ if (!m || m[1] !== slug) continue;
26
+ if (/\[resolved\]/i.test(lines[i])) continue; // already tagged
27
+ lines[i] = lines[i].replace(
28
+ /(\*\*\[\d{4}-\d{2}-\d{2}\]\s+[\w-]+\*\*\s*[—-]\s*(?:pass|fail)?\s*[—-]?\s*)/,
29
+ '$1[resolved] '
30
+ );
31
+ changed = true;
32
+ }
33
+ if (changed) fs.writeFileSync(lessonsPath, lines.join('\n'));
34
+ return changed;
35
+ }
36
+
37
+ /**
38
+ * Auto-resolve detector-backed fail lessons whose detector now passes.
39
+ *
40
+ * A `fail` lesson records a bug; its detector exits 0 once the bug is gone.
41
+ * When that happens we retire the lesson: stamp `status: resolved` +
42
+ * `resolved_at` in the atris/lessons.json sidecar and tag `[resolved]` in
43
+ * atris/lessons.md. Retired lessons stop being re-picked by the self-heal
44
+ * loop and drop out of the active memory view, keeping the lesson file small
45
+ * and trustworthy.
46
+ *
47
+ * Only detector-backed `fail` lessons self-retire. Prose-only lessons (no
48
+ * detector) and `observed` process rules are never auto-resolved — they have
49
+ * no falsifiable pass state.
50
+ *
51
+ * @param {string} cwd
52
+ * @param {object} [options] - { dryRun, detectorTimeout }
53
+ * @returns {{ checked: string[], resolved: string[], dryRun: boolean }}
54
+ */
55
+ function autoResolveLessons(cwd, options = {}) {
56
+ const dryRun = !!options.dryRun;
57
+ const lessons = parseLessons(cwd);
58
+ const metadata = loadLessonMetadata(cwd);
59
+ const today = new Date().toISOString().slice(0, 10);
60
+ const checked = [];
61
+ const resolved = [];
62
+
63
+ for (const lesson of lessons) {
64
+ const meta = lesson.meta;
65
+ if (!meta || !meta.detector) continue; // only detector-backed lessons self-retire
66
+ if (lesson.verdict !== 'fail') continue; // only bug lessons carry a resolve semantic
67
+ if (meta.status === 'resolved') continue; // already retired
68
+ if (meta.status === 'observed') continue; // process rule, never auto-resolve
69
+ checked.push(lesson.id);
70
+ if (!runLessonDetector(meta.detector, cwd, options.detectorTimeout)) continue;
71
+ resolved.push(lesson.id);
72
+ if (!dryRun) {
73
+ metadata[lesson.id] = { ...meta, status: 'resolved', resolved_at: today };
74
+ tagLessonResolvedInMd(cwd, lesson.id);
75
+ }
76
+ }
77
+
78
+ if (!dryRun && resolved.length) {
79
+ const metaPath = path.join(cwd, 'atris', 'lessons.json');
80
+ fs.writeFileSync(metaPath, JSON.stringify(metadata, null, 2) + '\n');
81
+ }
82
+
83
+ return { checked, resolved, dryRun };
84
+ }
85
+
86
+ function resolveLessons(args) {
87
+ const cwd = process.cwd();
88
+ const json = args.includes('--json');
89
+ const dryRun = args.includes('--dry-run');
90
+ const { checked, resolved } = autoResolveLessons(cwd, { dryRun });
91
+
92
+ if (json) {
93
+ console.log(JSON.stringify({
94
+ ok: true,
95
+ action: 'lesson_resolve',
96
+ dry_run: dryRun,
97
+ detectors_checked: checked.length,
98
+ resolved_count: resolved.length,
99
+ resolved,
100
+ }, null, 2));
101
+ return;
102
+ }
103
+
104
+ const verb = dryRun ? 'would auto-resolve' : 'auto-resolved';
105
+ const tail = resolved.length ? `: ${resolved.join(', ')}` : '';
106
+ console.log(`ran ${checked.length} detector(s); ${verb} ${resolved.length} lesson(s)${tail}`);
107
+ if (dryRun) console.log('(dry-run: nothing written)');
108
+ }
109
+
7
110
  function sweepLessons(args) {
8
111
  const cwd = process.cwd();
9
112
  const json = args.includes('--json');
@@ -127,11 +230,17 @@ function lessonAtris(subcommand, ...args) {
127
230
  return;
128
231
  }
129
232
 
233
+ if (subcommand === 'resolve') {
234
+ resolveLessons(args);
235
+ return;
236
+ }
237
+
130
238
  if (subcommand !== 'add') {
131
239
  console.log('');
132
240
  console.log(' Usage: atris lesson add <slug> <pass|fail> "<text>"');
133
241
  console.log(' atris lesson mine [--json] [--dry-run]');
134
242
  console.log(' atris lesson sweep [--json] [--dry-run]');
243
+ console.log(' atris lesson resolve [--json] [--dry-run]');
135
244
  console.log('');
136
245
  process.exit(subcommand ? 1 : 0);
137
246
  }
@@ -159,3 +268,5 @@ function lessonAtris(subcommand, ...args) {
159
268
  }
160
269
 
161
270
  module.exports = lessonAtris;
271
+ module.exports.autoResolveLessons = autoResolveLessons;
272
+ module.exports.tagLessonResolvedInMd = tagLessonResolvedInMd;
@@ -0,0 +1,38 @@
1
+ const { createOfficialCliCommand } = require('../lib/official-cli-integration');
2
+
3
+ const linearCommand = createOfficialCliCommand({
4
+ name: 'linear',
5
+ binary: 'linear',
6
+ versionArgs: ['--version'],
7
+ authArgs: ['auth', 'status'],
8
+ installHint: 'install the linear cli and ensure `linear` is on PATH',
9
+ loginHint: 'linear auth login',
10
+ commands: [
11
+ {
12
+ usage: 'issue list',
13
+ match: ['issue', 'list'],
14
+ forward: ['issue', 'list'],
15
+ description: 'list issues',
16
+ },
17
+ {
18
+ usage: 'issue create',
19
+ match: ['issue', 'create'],
20
+ forward: ['issue', 'create'],
21
+ description: 'create an issue',
22
+ },
23
+ {
24
+ usage: 'issue view',
25
+ match: ['issue', 'view'],
26
+ forward: ['issue', 'view'],
27
+ description: 'show issue details',
28
+ },
29
+ {
30
+ usage: 'issue update',
31
+ match: ['issue', 'update'],
32
+ forward: ['issue', 'update'],
33
+ description: 'update an issue',
34
+ },
35
+ ],
36
+ });
37
+
38
+ module.exports = { linearCommand };
@@ -0,0 +1,212 @@
1
+ /**
2
+ * atris loops - one view of every background loop: what runs, what died,
3
+ * what you can start or stop.
4
+ *
5
+ * atris loops - the board (registry jobs, launchd agents, missions)
6
+ * atris loops stop <id> - disable a registry job or boot out a launchd agent
7
+ * atris loops start <id> - enable a registry job or kickstart a launchd agent
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const os = require('os');
12
+ const path = require('path');
13
+ const { execSync, spawnSync } = require('child_process');
14
+
15
+ function heartbeatDir() {
16
+ return process.env.ATRIS_HEARTBEAT_DIR || path.join(os.homedir(), '.atris', 'heartbeat');
17
+ }
18
+
19
+ function readJson(filePath, fallback) {
20
+ try {
21
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
22
+ } catch {
23
+ return fallback;
24
+ }
25
+ }
26
+
27
+ function registryPath() {
28
+ return path.join(heartbeatDir(), 'registry.json');
29
+ }
30
+
31
+ function loadRegistry() {
32
+ const registry = readJson(registryPath(), null);
33
+ return registry && Array.isArray(registry.jobs) ? registry : { jobs: [] };
34
+ }
35
+
36
+ function loadRunState() {
37
+ return readJson(path.join(heartbeatDir(), 'state.json'), {});
38
+ }
39
+
40
+ function agoText(iso) {
41
+ const then = Date.parse(iso || '');
42
+ if (!Number.isFinite(then)) return 'never';
43
+ const mins = Math.max(0, Math.round((Date.now() - then) / 60000));
44
+ if (mins < 60) return `${mins}m ago`;
45
+ const hours = Math.floor(mins / 60);
46
+ if (hours < 48) return `${hours}h ago`;
47
+ return `${Math.floor(hours / 24)}d ago`;
48
+ }
49
+
50
+ function listLaunchdAgents() {
51
+ let raw = '';
52
+ try {
53
+ raw = execSync('launchctl list', { encoding: 'utf8', timeout: 10000 });
54
+ } catch {
55
+ return [];
56
+ }
57
+ return raw
58
+ .split('\n')
59
+ .filter(line => line.includes('com.atris.'))
60
+ .map(line => {
61
+ const [pid, exitCode, label] = line.trim().split(/\s+/);
62
+ return {
63
+ label,
64
+ running: pid !== '-',
65
+ pid: pid !== '-' ? pid : null,
66
+ lastExit: exitCode,
67
+ };
68
+ })
69
+ .filter(agent => agent.label);
70
+ }
71
+
72
+ function workspaceMission(cwd) {
73
+ const goalState = readJson(path.join(cwd, '.atris', 'state', 'atris_goal.json'), null);
74
+ const goal = goalState && goalState.goal;
75
+ if (!goal || !goal.objective) return null;
76
+ const started = Date.parse(goal.created_at || '');
77
+ const elapsedMins = Number.isFinite(started) ? Math.round((Date.now() - started) / 60000) : null;
78
+ return {
79
+ id: goal.mission_id || '?',
80
+ status: goal.mission_status || '?',
81
+ objective: String(goal.objective),
82
+ elapsedMins,
83
+ };
84
+ }
85
+
86
+ function showBoard(cwd) {
87
+ const registry = loadRegistry();
88
+ const state = loadRunState();
89
+
90
+ console.log('background loops');
91
+ console.log('');
92
+
93
+ const enabled = registry.jobs.filter(job => job.enabled !== false && job.disabled !== true);
94
+ const disabledCount = registry.jobs.length - enabled.length;
95
+ console.log(`heartbeat registry (${enabled.length} live, ${disabledCount} retired)`);
96
+ if (!enabled.length) {
97
+ console.log(' none declared');
98
+ }
99
+ for (const job of enabled) {
100
+ const run = state[job.id] || {};
101
+ const fails = Number(run.consecutive_fails || 0);
102
+ const health = fails > 2 ? `${fails} consecutive fails` : fails > 0 ? `${fails} recent fail${fails === 1 ? '' : 's'}` : 'healthy';
103
+ const cadence = job.cadence_minutes >= 60 ? `${Math.round(job.cadence_minutes / 60)}h` : `${job.cadence_minutes}m`;
104
+ console.log(` ${String(job.id).padEnd(22)} every ${String(cadence).padEnd(5)} last ${agoText(run.last_run).padEnd(9)} ${health}`);
105
+ console.log(` ${''.padEnd(22)} ${String(job.purpose || '').slice(0, 84)}`);
106
+ }
107
+ console.log('');
108
+
109
+ const agents = listLaunchdAgents();
110
+ console.log(`launchd agents (${agents.length})`);
111
+ for (const agent of agents) {
112
+ const status = agent.running ? `running pid ${agent.pid}` : `loaded, last exit ${agent.lastExit}`;
113
+ console.log(` ${agent.label.padEnd(44)} ${status}`);
114
+ }
115
+ if (!agents.length) console.log(' none');
116
+ console.log('');
117
+
118
+ const mission = workspaceMission(cwd);
119
+ console.log('mission (this workspace)');
120
+ if (mission) {
121
+ const elapsed = mission.elapsedMins === null ? '' : ` · ${mission.elapsedMins}m in`;
122
+ console.log(` ${mission.id}`);
123
+ console.log(` ${mission.status}${elapsed} · ${mission.objective.slice(0, 80)}`);
124
+ } else {
125
+ console.log(' none active');
126
+ }
127
+ console.log('');
128
+ console.log('control: atris loops stop <id> · atris loops start <id>');
129
+ console.log('ids: registry job id, or a com.atris.* launchd label');
130
+ }
131
+
132
+ function saveRegistry(registry) {
133
+ const target = registryPath();
134
+ fs.copyFileSync(target, `${target}.bak`);
135
+ fs.writeFileSync(target, `${JSON.stringify(registry, null, 2)}\n`);
136
+ }
137
+
138
+ function setRegistryJob(id, enabled) {
139
+ const registry = loadRegistry();
140
+ const job = registry.jobs.find(row => row.id === id);
141
+ if (!job) return false;
142
+ job.enabled = enabled;
143
+ if (enabled) {
144
+ delete job.disabled;
145
+ delete job.disabled_reason;
146
+ } else {
147
+ job.disabled = true;
148
+ job.disabled_reason = `stopped via atris loops ${new Date().toISOString().slice(0, 10)}`;
149
+ }
150
+ saveRegistry(registry);
151
+ return true;
152
+ }
153
+
154
+ function launchdControl(label, action) {
155
+ const uid = process.getuid();
156
+ if (action === 'stop') {
157
+ const result = spawnSync('launchctl', ['bootout', `gui/${uid}/${label}`], { encoding: 'utf8' });
158
+ return result.status === 0;
159
+ }
160
+ const plist = path.join(os.homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
161
+ if (!fs.existsSync(plist)) return false;
162
+ const bootstrap = spawnSync('launchctl', ['bootstrap', `gui/${uid}`, plist], { encoding: 'utf8' });
163
+ if (bootstrap.status === 0) return true;
164
+ const kick = spawnSync('launchctl', ['kickstart', '-k', `gui/${uid}/${label}`], { encoding: 'utf8' });
165
+ return kick.status === 0;
166
+ }
167
+
168
+ function controlLoop(id, action) {
169
+ const target = String(id || '').trim();
170
+ if (!target) {
171
+ console.error(`Usage: atris loops ${action} <id>`);
172
+ process.exit(1);
173
+ }
174
+ if (target.startsWith('com.atris.')) {
175
+ const ok = launchdControl(target, action);
176
+ if (!ok) {
177
+ console.error(`Could not ${action} ${target}. Check: launchctl print gui/$(id -u)/${target}`);
178
+ process.exit(1);
179
+ }
180
+ console.log(`${action === 'stop' ? 'Stopped' : 'Started'} ${target}.`);
181
+ return;
182
+ }
183
+ const ok = setRegistryJob(target, action === 'start');
184
+ if (!ok) {
185
+ console.error(`No registry job named "${target}". Run: atris loops`);
186
+ process.exit(1);
187
+ }
188
+ console.log(`${action === 'stop' ? 'Disabled' : 'Enabled'} ${target} in the heartbeat registry (backup written).`);
189
+ }
190
+
191
+ function loopsCommand(subcommand, ...args) {
192
+ switch (subcommand) {
193
+ case undefined:
194
+ case 'board':
195
+ case 'list':
196
+ showBoard(process.cwd());
197
+ break;
198
+ case 'stop':
199
+ controlLoop(args[0], 'stop');
200
+ break;
201
+ case 'start':
202
+ controlLoop(args[0], 'start');
203
+ break;
204
+ default:
205
+ console.log('Loop commands:');
206
+ console.log(' atris loops - board: registry jobs, launchd agents, mission');
207
+ console.log(' atris loops stop <id> - disable a registry job or stop a launchd agent');
208
+ console.log(' atris loops start <id> - enable a registry job or start a launchd agent');
209
+ }
210
+ }
211
+
212
+ module.exports = { loopsCommand, showBoard };