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.
- package/AGENTS.md +0 -2
- package/FOR_AGENTS.md +5 -3
- package/atris/skills/engines/SKILL.md +16 -7
- package/atris/skills/render-cli/SKILL.md +88 -0
- package/atris.md +19 -0
- package/ax +475 -17
- package/bin/atris.js +206 -44
- package/commands/aeo.js +52 -0
- package/commands/autoland.js +313 -19
- package/commands/business.js +91 -8
- package/commands/codex-goal.js +26 -2
- package/commands/drive.js +187 -0
- package/commands/engine.js +73 -2
- package/commands/feed.js +202 -0
- package/commands/github.js +38 -0
- package/commands/gm.js +262 -3
- package/commands/init.js +13 -1
- package/commands/integrations.js +39 -11
- package/commands/interview.js +143 -0
- package/commands/land.js +114 -13
- package/commands/lesson.js +112 -1
- package/commands/linear.js +38 -0
- package/commands/loops.js +212 -0
- package/commands/member.js +398 -42
- package/commands/mission.js +598 -64
- package/commands/now.js +25 -1
- package/commands/radar.js +259 -14
- package/commands/serve.js +54 -0
- package/commands/status.js +50 -5
- package/commands/stripe.js +38 -0
- package/commands/supabase.js +39 -0
- package/commands/task.js +935 -71
- package/commands/truth.js +29 -3
- package/commands/unknowns.js +627 -0
- package/commands/update.js +44 -0
- package/commands/vercel.js +38 -0
- package/commands/worktree.js +68 -10
- package/commands/write.js +399 -0
- package/lib/auto-accept-certified.js +70 -19
- package/lib/autoland.js +39 -3
- package/lib/fleet.js +256 -13
- package/lib/memory-view.js +14 -5
- package/lib/mission-runtime-loop.js +7 -0
- package/lib/official-cli-integration.js +174 -0
- package/lib/outbound-send-gate.js +165 -0
- package/lib/permission-grants.js +293 -0
- package/lib/review-integrity.js +147 -0
- package/lib/runner-command.js +23 -0
- package/lib/task-db.js +220 -7
- package/lib/task-proof.js +20 -0
- package/lib/task-receipt.js +93 -0
- package/package.json +1 -1
- package/utils/update-check.js +27 -6
- 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
|
|
152
|
-
|
|
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
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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)
|
|
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}`);
|
package/commands/lesson.js
CHANGED
|
@@ -1,9 +1,112 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
|
-
const {
|
|
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 };
|