atris 3.30.12 → 3.31.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 +16 -3
- package/FOR_AGENTS.md +81 -0
- package/README.md +3 -1
- package/atris/atris.md +51 -19
- package/atris/skills/README.md +1 -0
- package/atris/skills/blocks/SKILL.md +134 -0
- package/atris/skills/clawhub/philosophy-of-work/SKILL.md +56 -0
- package/atris/skills/youtube/SKILL.md +31 -11
- package/atris.md +7 -0
- package/ax +95 -3
- package/bin/atris.js +126 -153
- package/commands/autoland.js +319 -0
- package/commands/autopilot.js +94 -4
- package/commands/business.js +1 -1
- package/commands/clean.js +72 -9
- package/commands/codex-goal.js +72 -22
- package/commands/computer.js +48 -3
- package/commands/gm.js +1 -1
- package/commands/harvest.js +179 -0
- package/commands/init.js +1 -1
- package/commands/land.js +442 -0
- package/commands/loop-front.js +122 -4
- package/commands/member.js +519 -19
- package/commands/mission.js +3330 -290
- package/commands/play.js +1 -1
- package/commands/pulse.js +65 -7
- package/commands/run.js +3 -3
- package/commands/strings.js +301 -0
- package/commands/task.js +575 -102
- package/commands/truth.js +170 -0
- package/commands/xp.js +32 -8
- package/commands/youtube.js +72 -5
- package/decks/README.md +6 -12
- package/lib/auto-accept-certified.js +10 -0
- package/lib/autoland.js +283 -0
- package/lib/context-gatherer.js +0 -8
- package/lib/mission-artifact.js +504 -0
- package/lib/mission-room.js +846 -0
- package/lib/next-moves.js +212 -6
- package/lib/pulse.js +74 -1
- package/lib/runner-command.js +20 -8
- package/lib/runs-prune.js +242 -0
- package/lib/task-proof.js +1 -1
- package/package.json +3 -3
- package/decks/atris-seed-pitch-v3.json +0 -118
- package/decks/atris-seed-pitch-v4-skeleton.json +0 -106
- package/decks/atris-seed-pitch-v5.json +0 -109
- package/decks/atris-seed-pitch-v6.json +0 -137
- package/decks/atris-seed-pitch-v7.json +0 -133
- package/decks/mark-pincus-narrative.json +0 -102
- package/decks/mark-pincus-sourcery.json +0 -94
- package/decks/yash-applied-compute-detailed.json +0 -150
- package/decks/yash-applied-compute-generalist.json +0 -82
- package/decks/yash-applied-compute-narrative.json +0 -54
- package/lib/ax-chat-input.js +0 -164
- package/lib/ax-goal.js +0 -307
- package/lib/ax-prefs.js +0 -63
- package/lib/ax-shimmer.js +0 -63
package/lib/next-moves.js
CHANGED
|
@@ -21,6 +21,27 @@ function norm(title) {
|
|
|
21
21
|
return String(title == null ? '' : title).trim().toLowerCase();
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
function titleKey(title) {
|
|
25
|
+
return norm(String(title == null ? '' : title).replace(/\s+/g, ' ').replace(/^title\s*:\s*/i, ''));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isGenericInboxPlaceholder(title) {
|
|
29
|
+
const key = titleKey(title);
|
|
30
|
+
return /^(dogfood tick|autopilot tick|mission tick|run (one )?tick|tick|test idea|sample idea|placeholder idea|idea|todo|tbd)$/.test(key);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function cleanInboxMoveTitle(title) {
|
|
34
|
+
const raw = String(title == null ? '' : title).trim();
|
|
35
|
+
if (!raw) return null;
|
|
36
|
+
const titleLine = raw.match(/^title\s*:\s*(.+)$/i);
|
|
37
|
+
if (/^(task|status|action|tag|member|actor|proof|verify|checked|saved|changed|result|files?|commands?)\s*:/i.test(raw)) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const cleaned = titleLine ? titleLine[1].trim() : raw;
|
|
41
|
+
if (isGenericInboxPlaceholder(cleaned)) return null;
|
|
42
|
+
return cleaned;
|
|
43
|
+
}
|
|
44
|
+
|
|
24
45
|
// Short, stable id so a kill on Tuesday still suppresses the same move on
|
|
25
46
|
// Wednesday. Pure function of (source, title).
|
|
26
47
|
function moveId(source, title) {
|
|
@@ -33,7 +54,8 @@ function moveId(source, title) {
|
|
|
33
54
|
return `m_${(h >>> 0).toString(36)}`;
|
|
34
55
|
}
|
|
35
56
|
|
|
36
|
-
const WEIGHT = { roadmap: 100, task: 60, inbox: 40 };
|
|
57
|
+
const WEIGHT = { roadmap: 100, mission: 90, endgame: 80, task: 60, inbox: 40 };
|
|
58
|
+
const SELF_IMPROVEMENT_SEED_TITLE = 'Create the next proof-backed self-improvement task';
|
|
37
59
|
|
|
38
60
|
// One section-boundary shape for every reader and writer: tolerate a header
|
|
39
61
|
// suffix (e.g. "(priority)"), stop at a sibling/parent heading (## or #) or a
|
|
@@ -93,17 +115,119 @@ function readActiveTasks(root) {
|
|
|
93
115
|
const tasks = Array.isArray(proj && proj.tasks) ? proj.tasks : [];
|
|
94
116
|
return tasks
|
|
95
117
|
.filter((t) => t && t.title && ['open', 'claimed'].includes(String(t.status || '').toLowerCase()))
|
|
118
|
+
.filter((t) => !isInternalNextMoveTask(t))
|
|
96
119
|
.map((t) => ({
|
|
97
120
|
title: String(t.title).trim(),
|
|
98
121
|
why: `task in flight (${t.status}${t.claimed_by ? `, ${t.claimed_by}` : ''})`,
|
|
99
122
|
source: 'task',
|
|
123
|
+
task_id: t.id || null,
|
|
100
124
|
ref: t.display_id || t.id || null,
|
|
101
125
|
weight: WEIGHT.task,
|
|
102
126
|
}));
|
|
103
127
|
}
|
|
104
128
|
|
|
129
|
+
function isInternalNextMoveTask(task) {
|
|
130
|
+
const title = String(task?.title || '').trim();
|
|
131
|
+
const tags = [task?.tag, ...(Array.isArray(task?.tags) ? task.tags : [])]
|
|
132
|
+
.filter(Boolean)
|
|
133
|
+
.map((tag) => String(tag).toLowerCase());
|
|
134
|
+
return tags.includes('agent-xp') || /^mission xp\s*:/i.test(title);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function readHandledTaskTitles(root) {
|
|
138
|
+
const text = safeRead(path.join(root, '.atris', 'state', 'tasks.projection.json'));
|
|
139
|
+
if (!text) return [];
|
|
140
|
+
let proj;
|
|
141
|
+
try { proj = JSON.parse(text); } catch { return []; }
|
|
142
|
+
const tasks = Array.isArray(proj && proj.tasks) ? proj.tasks : [];
|
|
143
|
+
return tasks
|
|
144
|
+
.filter((t) => t && t.title && ['review', 'done'].includes(String(t.status || '').toLowerCase()))
|
|
145
|
+
.map((t) => String(t.title).trim())
|
|
146
|
+
.filter(Boolean);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function readJsonLines(file) {
|
|
150
|
+
const text = safeRead(file);
|
|
151
|
+
if (!text) return [];
|
|
152
|
+
return text.split(/\r?\n/)
|
|
153
|
+
.map((line) => line.trim())
|
|
154
|
+
.filter(Boolean)
|
|
155
|
+
.map((line) => {
|
|
156
|
+
try { return JSON.parse(line); } catch { return null; }
|
|
157
|
+
})
|
|
158
|
+
.filter(Boolean);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function readActiveMissions(root) {
|
|
162
|
+
const rows = readJsonLines(path.join(root, '.atris', 'state', 'missions.jsonl'));
|
|
163
|
+
const latest = new Map();
|
|
164
|
+
for (const row of rows) {
|
|
165
|
+
if (row && row.id) latest.set(row.id, row);
|
|
166
|
+
}
|
|
167
|
+
return Array.from(latest.values())
|
|
168
|
+
.filter((m) => m && m.objective && !['complete', 'stopped', 'paused'].includes(String(m.status || '').toLowerCase()))
|
|
169
|
+
.map((m) => ({
|
|
170
|
+
title: String(m.objective).trim(),
|
|
171
|
+
why: `active mission (${m.status || 'unknown'}${m.owner ? `, ${m.owner}` : ''})`,
|
|
172
|
+
source: 'mission',
|
|
173
|
+
ref: m.id || null,
|
|
174
|
+
weight: m.status === 'blocked' ? WEIGHT.mission + 5 : WEIGHT.mission,
|
|
175
|
+
}));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function activeEndgameTaskCount(root) {
|
|
179
|
+
const text = safeRead(path.join(root, '.atris', 'state', 'tasks.projection.json'));
|
|
180
|
+
if (!text) return 0;
|
|
181
|
+
let proj;
|
|
182
|
+
try { proj = JSON.parse(text); } catch { return 0; }
|
|
183
|
+
const tasks = Array.isArray(proj && proj.tasks) ? proj.tasks : [];
|
|
184
|
+
return tasks.filter((t) => {
|
|
185
|
+
if (!t) return false;
|
|
186
|
+
const status = String(t.status || '').toLowerCase();
|
|
187
|
+
const tags = [t.tag, ...(Array.isArray(t.tags) ? t.tags : [])].filter(Boolean).map((tag) => String(tag).toLowerCase());
|
|
188
|
+
return tags.includes('endgame') && ['open', 'claimed', 'review'].includes(status);
|
|
189
|
+
}).length;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function todoKeepsEndgameActive(root) {
|
|
193
|
+
const text = safeRead(path.join(root, 'atris', 'TODO.md'));
|
|
194
|
+
if (!text) return true;
|
|
195
|
+
if (activeEndgameTaskCount(root) > 0) return true;
|
|
196
|
+
|
|
197
|
+
let sawEndgameHeader = false;
|
|
198
|
+
let section = '';
|
|
199
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
200
|
+
const heading = raw.match(/^##\s+(.+?)\s*$/);
|
|
201
|
+
if (heading) {
|
|
202
|
+
section = heading[1].trim().toLowerCase();
|
|
203
|
+
if (section === 'endgame') sawEndgameHeader = true;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (!/\[endgame\]/i.test(raw)) continue;
|
|
207
|
+
if (['backlog', 'in progress', 'review'].includes(section)) return true;
|
|
208
|
+
}
|
|
209
|
+
return !sawEndgameHeader;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function readEndgameMove(root) {
|
|
213
|
+
if (!todoKeepsEndgameActive(root)) return [];
|
|
214
|
+
let state = null;
|
|
215
|
+
try { state = JSON.parse(safeRead(path.join(root, 'atris', 'brain', 'state.json'))); } catch { state = null; }
|
|
216
|
+
const horizon = String(state?.endgame?.horizon || '').trim();
|
|
217
|
+
if (!horizon) return [];
|
|
218
|
+
return [{
|
|
219
|
+
title: horizon,
|
|
220
|
+
why: state?.endgame?.source ? `endgame: ${state.endgame.source}` : 'endgame horizon from compiled brain',
|
|
221
|
+
source: 'endgame',
|
|
222
|
+
weight: WEIGHT.endgame,
|
|
223
|
+
}];
|
|
224
|
+
}
|
|
225
|
+
|
|
105
226
|
function inboxItemsFrom(text) {
|
|
106
|
-
return parseInboxTitles(text)
|
|
227
|
+
return parseInboxTitles(text)
|
|
228
|
+
.map(cleanInboxMoveTitle)
|
|
229
|
+
.filter(Boolean)
|
|
230
|
+
.map((title) => ({ title, why: 'fresh idea in today\'s inbox', source: 'inbox', weight: WEIGHT.inbox }));
|
|
107
231
|
}
|
|
108
232
|
|
|
109
233
|
// Most recent journal under atris/logs/YYYY/, parsed for ## Inbox items. Used to
|
|
@@ -132,6 +256,8 @@ function todayInboxItems(root) {
|
|
|
132
256
|
function gatherCandidates(root = process.cwd()) {
|
|
133
257
|
return [
|
|
134
258
|
...readRoadmapOpenItems(root),
|
|
259
|
+
...readActiveMissions(root),
|
|
260
|
+
...readEndgameMove(root),
|
|
135
261
|
...readActiveTasks(root),
|
|
136
262
|
...latestInboxItems(root),
|
|
137
263
|
];
|
|
@@ -144,16 +270,25 @@ function gatherCandidates(root = process.cwd()) {
|
|
|
144
270
|
// only suppressed for IDEAS (roadmap/inbox), never for a real `task`, so killing
|
|
145
271
|
// or approving an idea can't hide a genuine in-flight task that happens to share
|
|
146
272
|
// the title.
|
|
147
|
-
function pickNextMoves(candidates, {
|
|
273
|
+
function pickNextMoves(candidates, {
|
|
274
|
+
limit = 3,
|
|
275
|
+
killedIds = [],
|
|
276
|
+
killedTitles = [],
|
|
277
|
+
approvedIds = [],
|
|
278
|
+
approvedTitles = [],
|
|
279
|
+
handledTaskTitles = [],
|
|
280
|
+
} = {}) {
|
|
148
281
|
const blockedIds = new Set([...killedIds, ...approvedIds]);
|
|
149
|
-
const blockedTitles = new Set([...killedTitles, ...approvedTitles].map(
|
|
282
|
+
const blockedTitles = new Set([...killedTitles, ...approvedTitles].map(titleKey));
|
|
283
|
+
const handledTitles = new Set(handledTaskTitles.map(titleKey));
|
|
150
284
|
const seen = new Set();
|
|
151
285
|
return candidates
|
|
152
|
-
.map((c) => ({ ...c, id: moveId(c.source, c.title), _key:
|
|
286
|
+
.map((c) => ({ ...c, id: moveId(c.source, c.title), _key: titleKey(c.title) }))
|
|
153
287
|
.filter((c) => {
|
|
154
288
|
if (!c._key) return false;
|
|
155
289
|
if (blockedIds.has(c.id)) return false;
|
|
156
290
|
if (c.source !== 'task' && blockedTitles.has(c._key)) return false;
|
|
291
|
+
if (c.source !== 'task' && handledTitles.has(c._key)) return false;
|
|
157
292
|
return true;
|
|
158
293
|
})
|
|
159
294
|
.sort((a, b) => (b.weight || 0) - (a.weight || 0))
|
|
@@ -166,6 +301,63 @@ function pickNextMoves(candidates, { limit = 3, killedIds = [], killedTitles = [
|
|
|
166
301
|
.slice(0, limit);
|
|
167
302
|
}
|
|
168
303
|
|
|
304
|
+
function cleanSuggestedTargetTitle(text) {
|
|
305
|
+
const clean = String(text || '')
|
|
306
|
+
.replace(/[`*_#>]+/g, '')
|
|
307
|
+
.replace(/\s+/g, ' ')
|
|
308
|
+
.trim()
|
|
309
|
+
.replace(/[.!?:;,]+$/g, '');
|
|
310
|
+
if (!clean) return '';
|
|
311
|
+
return clean.charAt(0).toUpperCase() + clean.slice(1);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function suggestedTargetFromReportText(text) {
|
|
315
|
+
const lines = String(text || '').split(/\r?\n/);
|
|
316
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
317
|
+
const match = lines[index].match(/^\s*Suggested target:\s*(.+?)\s*$/i);
|
|
318
|
+
if (match) return cleanSuggestedTargetTitle(match[1]);
|
|
319
|
+
}
|
|
320
|
+
return '';
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function latestSuggestedTarget(root = process.cwd()) {
|
|
324
|
+
const reportsDir = path.join(root, 'atris', 'reports');
|
|
325
|
+
let files;
|
|
326
|
+
try {
|
|
327
|
+
files = fs.readdirSync(reportsDir).filter((file) => /\.md$/i.test(file)).sort().reverse();
|
|
328
|
+
} catch {
|
|
329
|
+
return '';
|
|
330
|
+
}
|
|
331
|
+
for (const file of files) {
|
|
332
|
+
const target = suggestedTargetFromReportText(safeRead(path.join(reportsDir, file)));
|
|
333
|
+
if (target) return target;
|
|
334
|
+
}
|
|
335
|
+
return '';
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function addMissionOnlyFallback(moves, limit = 3, root = process.cwd()) {
|
|
339
|
+
if (!Array.isArray(moves) || moves.length !== 1 || limit <= 1) return moves;
|
|
340
|
+
const mission = moves[0];
|
|
341
|
+
if (!mission || mission.source !== 'mission') return moves;
|
|
342
|
+
const suggested = latestSuggestedTarget(root);
|
|
343
|
+
const target = suggested && !isGenericInboxPlaceholder(suggested)
|
|
344
|
+
? suggested
|
|
345
|
+
: SELF_IMPROVEMENT_SEED_TITLE;
|
|
346
|
+
return [
|
|
347
|
+
mission,
|
|
348
|
+
{
|
|
349
|
+
title: target,
|
|
350
|
+
why: target === SELF_IMPROVEMENT_SEED_TITLE
|
|
351
|
+
? 'active mission has no concrete task queued'
|
|
352
|
+
: 'latest proof timeline suggested this self-improvement target',
|
|
353
|
+
source: 'mission',
|
|
354
|
+
ref: mission.ref || null,
|
|
355
|
+
weight: WEIGHT.mission - 35,
|
|
356
|
+
id: moveId('mission', target),
|
|
357
|
+
},
|
|
358
|
+
].slice(0, limit);
|
|
359
|
+
}
|
|
360
|
+
|
|
169
361
|
const DECISIONS_FILE = ['.atris', 'state', 'moves.decisions.jsonl'];
|
|
170
362
|
|
|
171
363
|
function decisionsPath(root) {
|
|
@@ -336,20 +528,34 @@ function pickRoadmapSeed(root = process.cwd()) {
|
|
|
336
528
|
// so the ranking inputs live in one place.
|
|
337
529
|
function nextMoves(root = process.cwd(), limit = 3) {
|
|
338
530
|
const { killedIds, killedTitles, approvedIds, approvedTitles } = readDecisions(root);
|
|
339
|
-
|
|
531
|
+
const handledTaskTitles = readHandledTaskTitles(root);
|
|
532
|
+
const picked = pickNextMoves(gatherCandidates(root), { limit, killedIds, killedTitles, approvedIds, approvedTitles, handledTaskTitles });
|
|
533
|
+
return addMissionOnlyFallback(picked, limit, root);
|
|
340
534
|
}
|
|
341
535
|
|
|
342
536
|
module.exports = {
|
|
343
537
|
moveId,
|
|
344
538
|
norm,
|
|
539
|
+
titleKey,
|
|
540
|
+
isGenericInboxPlaceholder,
|
|
345
541
|
WEIGHT,
|
|
542
|
+
SELF_IMPROVEMENT_SEED_TITLE,
|
|
543
|
+
cleanSuggestedTargetTitle,
|
|
544
|
+
suggestedTargetFromReportText,
|
|
545
|
+
latestSuggestedTarget,
|
|
346
546
|
parseInboxTitles,
|
|
547
|
+
cleanInboxMoveTitle,
|
|
347
548
|
readRoadmapOpenItems,
|
|
549
|
+
readActiveMissions,
|
|
550
|
+
readEndgameMove,
|
|
348
551
|
readActiveTasks,
|
|
552
|
+
isInternalNextMoveTask,
|
|
553
|
+
readHandledTaskTitles,
|
|
349
554
|
latestInboxItems,
|
|
350
555
|
todayInboxItems,
|
|
351
556
|
gatherCandidates,
|
|
352
557
|
pickNextMoves,
|
|
558
|
+
addMissionOnlyFallback,
|
|
353
559
|
nextMoves,
|
|
354
560
|
readDecisions,
|
|
355
561
|
recordDecision,
|
package/lib/pulse.js
CHANGED
|
@@ -85,6 +85,21 @@ function buildPulseScorecardRow(input = {}) {
|
|
|
85
85
|
};
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
function buildInterruptedPulseReceipt(input = {}) {
|
|
89
|
+
const signal = input.signal || 'signal';
|
|
90
|
+
return buildPulseReceipt({
|
|
91
|
+
tickIndex: input.tickIndex,
|
|
92
|
+
phase: 'finished',
|
|
93
|
+
actor: 'pulse_signal',
|
|
94
|
+
actorOk: false,
|
|
95
|
+
actorReason: String(signal).toLowerCase(),
|
|
96
|
+
what: `tick interrupted by ${signal}`,
|
|
97
|
+
elapsedMs: input.startedAt ? Date.now() - input.startedAt : input.elapsedMs,
|
|
98
|
+
prevTickStale: input.prevTickStale,
|
|
99
|
+
reward: -1,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
88
103
|
// The heartbeat's full composition (mirrors the /loop skill): run the due
|
|
89
104
|
// mission to continue an existing goal; if none is due, fall back to an
|
|
90
105
|
// autopilot tick — that path is where proposeCandidateHorizons AUTHORS a new
|
|
@@ -198,6 +213,61 @@ function shellSingleQuote(value) {
|
|
|
198
213
|
return `'${String(value || '').replace(/'/g, "'\\''")}'`;
|
|
199
214
|
}
|
|
200
215
|
|
|
216
|
+
function normalizeCronCadence(value = DEFAULT_CADENCE_CRON) {
|
|
217
|
+
const raw = String(value || DEFAULT_CADENCE_CRON).trim();
|
|
218
|
+
if (!raw) return DEFAULT_CADENCE_CRON;
|
|
219
|
+
if (raw.toLowerCase() === 'hourly') return DEFAULT_CADENCE_CRON;
|
|
220
|
+
if (raw.toLowerCase() === 'daily') return '23 2 * * *';
|
|
221
|
+
if (raw.split(/\s+/).length === 5) return raw;
|
|
222
|
+
|
|
223
|
+
const minutes = raw.match(/^(\d+)\s*(m|min|mins|minute|minutes)$/i);
|
|
224
|
+
if (minutes) {
|
|
225
|
+
const n = Number(minutes[1]);
|
|
226
|
+
if (Number.isInteger(n) && n >= 1 && n <= 59) return `*/${n} * * * *`;
|
|
227
|
+
throw new Error(`invalid cadence "${raw}": minute cadence must be 1m-59m or a 5-field cron`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const hours = raw.match(/^(\d+)\s*(h|hr|hrs|hour|hours)$/i);
|
|
231
|
+
if (hours) {
|
|
232
|
+
const n = Number(hours[1]);
|
|
233
|
+
if (Number.isInteger(n) && n >= 1 && n <= 23) return `23 */${n} * * *`;
|
|
234
|
+
if (n === 24) return '23 0 * * *';
|
|
235
|
+
throw new Error(`invalid cadence "${raw}": hour cadence must be 1h-24h or a 5-field cron`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
throw new Error(`invalid cadence "${raw}": use 13m, 2h, hourly, daily, or a 5-field cron`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function normalizeExpiryDuration(input = {}) {
|
|
242
|
+
const hasHours = input.hours !== undefined && input.hours !== null && String(input.hours).trim() !== '';
|
|
243
|
+
if (hasHours) {
|
|
244
|
+
const hours = Number(input.hours);
|
|
245
|
+
if (Number.isFinite(hours) && hours > 0) {
|
|
246
|
+
return {
|
|
247
|
+
source: 'hours',
|
|
248
|
+
hours,
|
|
249
|
+
days: null,
|
|
250
|
+
seconds: Math.ceil(hours * 60 * 60),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
throw new Error(`invalid hours "${input.hours}": use a positive number of hours`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const rawDays = input.days === undefined || input.days === null || String(input.days).trim() === ''
|
|
257
|
+
? 7
|
|
258
|
+
: input.days;
|
|
259
|
+
const days = Number(rawDays);
|
|
260
|
+
if (Number.isFinite(days) && days > 0) {
|
|
261
|
+
return {
|
|
262
|
+
source: 'days',
|
|
263
|
+
hours: null,
|
|
264
|
+
days,
|
|
265
|
+
seconds: Math.ceil(days * 24 * 60 * 60),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
throw new Error(`invalid days "${rawDays}": use a positive number of days`);
|
|
269
|
+
}
|
|
270
|
+
|
|
201
271
|
function runnerEnvAliasExport({ genericName, legacyName, value }) {
|
|
202
272
|
if (!value) return '';
|
|
203
273
|
return [
|
|
@@ -365,7 +435,7 @@ echo "done: $(date -Iseconds) exit=$?" >> "$log"
|
|
|
365
435
|
function buildCrontabLine(opts = {}) {
|
|
366
436
|
const { cron = DEFAULT_CADENCE_CRON, scriptPath, marker = PULSE_MARKER } = opts;
|
|
367
437
|
if (!scriptPath) throw new Error('buildCrontabLine: scriptPath is required');
|
|
368
|
-
return `${cron} ${scriptPath} # ${marker}`;
|
|
438
|
+
return `${normalizeCronCadence(cron)} ${scriptPath} # ${marker}`;
|
|
369
439
|
}
|
|
370
440
|
|
|
371
441
|
module.exports = {
|
|
@@ -382,7 +452,9 @@ module.exports = {
|
|
|
382
452
|
pulseLockDir,
|
|
383
453
|
buildPulseReceipt,
|
|
384
454
|
buildPulseScorecardRow,
|
|
455
|
+
buildInterruptedPulseReceipt,
|
|
385
456
|
scoreTick,
|
|
457
|
+
normalizeExpiryDuration,
|
|
386
458
|
shouldWriteScorecard,
|
|
387
459
|
shouldFallbackToAutopilot,
|
|
388
460
|
findOrphanStarts,
|
|
@@ -398,4 +470,5 @@ module.exports = {
|
|
|
398
470
|
releaseLock,
|
|
399
471
|
buildTickScript,
|
|
400
472
|
buildCrontabLine,
|
|
473
|
+
normalizeCronCadence,
|
|
401
474
|
};
|
package/lib/runner-command.js
CHANGED
|
@@ -3,14 +3,12 @@
|
|
|
3
3
|
// Shared worker-spawn builder for the autonomous loops (missions, autopilot, run).
|
|
4
4
|
//
|
|
5
5
|
// Autonomous ticks must target a LIVE model. Inheriting the CLI's persisted
|
|
6
|
-
// selection is fragile:
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// ATRIS_RUNNER_PROFILE -> legacy ATRIS_CLAUDE_MODEL env ->
|
|
11
|
-
|
|
12
|
-
// from under the loop.
|
|
13
|
-
const DEFAULT_CLAUDE_RUNNER_MODEL = 'opus';
|
|
6
|
+
// selection is fragile: the local Claude Code `opus` alias can resolve to
|
|
7
|
+
// different Opus releases across machines and account rollouts. Pin the default
|
|
8
|
+
// so autonomous runs are reproducible, while keeping explicit per-run/env knobs
|
|
9
|
+
// first in precedence. Precedence: explicit model -> ATRIS_RUNNER_MODEL env ->
|
|
10
|
+
// ATRIS_RUNNER_PROFILE -> legacy ATRIS_CLAUDE_MODEL env -> pinned default.
|
|
11
|
+
const DEFAULT_CLAUDE_RUNNER_MODEL = 'claude-opus-4-8';
|
|
14
12
|
const DEFAULT_CLAUDE_RUNNER_BIN = 'claude';
|
|
15
13
|
const RUNNER_PROFILES = Object.freeze({
|
|
16
14
|
'atris-fast': Object.freeze({
|
|
@@ -97,6 +95,19 @@ function buildRunnerAvailabilityCommand() {
|
|
|
97
95
|
return `command -v ${shellWord(resolveClaudeRunnerBin())}`;
|
|
98
96
|
}
|
|
99
97
|
|
|
98
|
+
function runnerAvailabilityFailureMessage(error) {
|
|
99
|
+
const message = error && error.message ? String(error.message).trim() : '';
|
|
100
|
+
if (message.startsWith('Unknown ATRIS_RUNNER_PROFILE')) {
|
|
101
|
+
return `${message}. Set ATRIS_RUNNER_PROFILE to one of: ${Object.keys(RUNNER_PROFILES).join(', ')}.`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let runnerBin = 'configured runner';
|
|
105
|
+
try {
|
|
106
|
+
runnerBin = resolveClaudeRunnerBin();
|
|
107
|
+
} catch {}
|
|
108
|
+
return `${runnerBin} CLI not found. Set ATRIS_RUNNER_BIN (or legacy ATRIS_CLAUDE_BIN), or install the configured runner first.`;
|
|
109
|
+
}
|
|
110
|
+
|
|
100
111
|
function renderRunnerCommandTemplate(template, { promptFile, allowedTools, model }) {
|
|
101
112
|
const allowedToolsFlag = allowedTools ? `--allowedTools ${shellWord(allowedTools)}` : '';
|
|
102
113
|
const promptFileWord = shellWord(promptFile);
|
|
@@ -152,5 +163,6 @@ module.exports = {
|
|
|
152
163
|
resolveClaudeRunnerBin,
|
|
153
164
|
resolveClaudeRunnerCommandTemplate,
|
|
154
165
|
buildRunnerAvailabilityCommand,
|
|
166
|
+
runnerAvailabilityFailureMessage,
|
|
155
167
|
buildRunnerCommand,
|
|
156
168
|
};
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
function stampIso() {
|
|
7
|
+
return new Date().toISOString();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function toPosixPath(value) {
|
|
11
|
+
return String(value || '').split(path.sep).join('/');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function fileSize(filePath) {
|
|
15
|
+
try {
|
|
16
|
+
return fs.statSync(filePath).size;
|
|
17
|
+
} catch {
|
|
18
|
+
return 0;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function listFiles(dir) {
|
|
23
|
+
const out = [];
|
|
24
|
+
function visit(current) {
|
|
25
|
+
let entries = [];
|
|
26
|
+
try {
|
|
27
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
28
|
+
} catch {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
for (const entry of entries) {
|
|
32
|
+
const fullPath = path.join(current, entry.name);
|
|
33
|
+
if (entry.isDirectory()) {
|
|
34
|
+
if (entry.name === '_archive') continue;
|
|
35
|
+
visit(fullPath);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (entry.isFile()) out.push(fullPath);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
visit(dir);
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function runPathReferences(root) {
|
|
46
|
+
const stateDir = path.join(root, '.atris', 'state');
|
|
47
|
+
const refs = new Set();
|
|
48
|
+
const pattern = /atris\/runs\/[A-Za-z0-9._~:/@%+=,-]+(?:\/[A-Za-z0-9._~:@%+=,-]+)*/g;
|
|
49
|
+
const scan = (filePath) => {
|
|
50
|
+
let text = '';
|
|
51
|
+
try {
|
|
52
|
+
text = fs.readFileSync(filePath, 'utf8');
|
|
53
|
+
} catch {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
for (const match of text.matchAll(pattern)) {
|
|
57
|
+
refs.add(match[0].replace(/[)"'`.,;]+$/g, ''));
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
if (fs.existsSync(stateDir)) {
|
|
61
|
+
for (const filePath of listFiles(stateDir)) scan(filePath);
|
|
62
|
+
}
|
|
63
|
+
return refs;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function referencedDirs(refs) {
|
|
67
|
+
const dirs = new Set();
|
|
68
|
+
for (const ref of refs) {
|
|
69
|
+
const dir = path.posix.dirname(ref);
|
|
70
|
+
if (dir && dir !== '.' && dir !== 'atris/runs') dirs.add(dir);
|
|
71
|
+
}
|
|
72
|
+
return dirs;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function runEntry(root, filePath, refs, refDirs, nowMs) {
|
|
76
|
+
const rel = toPosixPath(path.relative(root, filePath));
|
|
77
|
+
let stat = null;
|
|
78
|
+
try {
|
|
79
|
+
stat = fs.statSync(filePath);
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const dir = path.posix.dirname(rel);
|
|
84
|
+
const referenced = refs.has(rel) || refDirs.has(dir);
|
|
85
|
+
return {
|
|
86
|
+
path: rel,
|
|
87
|
+
absolute_path: filePath,
|
|
88
|
+
bytes: stat.size,
|
|
89
|
+
mtime_ms: stat.mtimeMs,
|
|
90
|
+
age_days: Math.max(0, Math.floor((nowMs - stat.mtimeMs) / 86400000)),
|
|
91
|
+
referenced,
|
|
92
|
+
kind: path.extname(filePath).toLowerCase().replace(/^\./, '') || 'file',
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function compactJsonReceipt(filePath) {
|
|
97
|
+
let parsed = null;
|
|
98
|
+
try {
|
|
99
|
+
parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
schema: parsed.schema || null,
|
|
105
|
+
mission_id: parsed.mission_id || parsed.mission?.id || null,
|
|
106
|
+
objective: parsed.objective || parsed.mission?.objective || null,
|
|
107
|
+
owner: parsed.owner || parsed.mission?.owner || null,
|
|
108
|
+
at: parsed.at || parsed.generated_at || parsed.created_at || null,
|
|
109
|
+
verifier: parsed.verifier || parsed.result?.verifier_result?.command || null,
|
|
110
|
+
passed: parsed.result?.passed === true || parsed.result?.verifier_result?.passed === true || null,
|
|
111
|
+
kind: parsed.result?.kind || parsed.action || null,
|
|
112
|
+
summary: parsed.result?.tick?.summary || parsed.result?.summary || parsed.landing?.happened || null,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function removeEmptyDirs(dir, stopDir) {
|
|
117
|
+
let current = dir;
|
|
118
|
+
while (current && current.startsWith(stopDir) && current !== stopDir) {
|
|
119
|
+
try {
|
|
120
|
+
const entries = fs.readdirSync(current);
|
|
121
|
+
if (entries.length) break;
|
|
122
|
+
fs.rmdirSync(current);
|
|
123
|
+
} catch {
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
current = path.dirname(current);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function pruneRuns(root = process.cwd(), options = {}) {
|
|
131
|
+
const runsDir = options.runsDir || path.join(root, 'atris', 'runs');
|
|
132
|
+
const apply = options.apply === true;
|
|
133
|
+
const archive = options.archive !== false;
|
|
134
|
+
const keepNewest = Number.isFinite(options.keepNewest) ? Math.max(0, Math.floor(options.keepNewest)) : 200;
|
|
135
|
+
const keepDays = Number.isFinite(options.keepDays) ? Math.max(0, Math.floor(options.keepDays)) : 14;
|
|
136
|
+
const nowMs = options.nowMs || Date.now();
|
|
137
|
+
const cutoffMs = nowMs - keepDays * 86400000;
|
|
138
|
+
const refs = runPathReferences(root);
|
|
139
|
+
const refDirs = referencedDirs(refs);
|
|
140
|
+
const files = fs.existsSync(runsDir)
|
|
141
|
+
? listFiles(runsDir).map((filePath) => runEntry(root, filePath, refs, refDirs, nowMs)).filter(Boolean)
|
|
142
|
+
: [];
|
|
143
|
+
const newest = new Set(
|
|
144
|
+
files
|
|
145
|
+
.slice()
|
|
146
|
+
.sort((a, b) => b.mtime_ms - a.mtime_ms || a.path.localeCompare(b.path))
|
|
147
|
+
.slice(0, keepNewest)
|
|
148
|
+
.map((entry) => entry.path),
|
|
149
|
+
);
|
|
150
|
+
const totalBytes = files.reduce((sum, entry) => sum + entry.bytes, 0);
|
|
151
|
+
const decisions = files.map((entry) => {
|
|
152
|
+
const reasons = [];
|
|
153
|
+
if (entry.referenced) reasons.push('referenced');
|
|
154
|
+
if (newest.has(entry.path)) reasons.push('newest');
|
|
155
|
+
if (entry.mtime_ms >= cutoffMs) reasons.push('recent');
|
|
156
|
+
const prune = reasons.length === 0;
|
|
157
|
+
return { ...entry, prune, keep_reasons: reasons };
|
|
158
|
+
});
|
|
159
|
+
const candidates = decisions.filter((entry) => entry.prune);
|
|
160
|
+
const archiveEntries = archive
|
|
161
|
+
? candidates.map((entry) => ({
|
|
162
|
+
path: entry.path,
|
|
163
|
+
bytes: entry.bytes,
|
|
164
|
+
age_days: entry.age_days,
|
|
165
|
+
compact: entry.kind === 'json' ? compactJsonReceipt(entry.absolute_path) : null,
|
|
166
|
+
}))
|
|
167
|
+
: [];
|
|
168
|
+
let manifestPath = null;
|
|
169
|
+
const deleted = [];
|
|
170
|
+
const errors = [];
|
|
171
|
+
if (apply && candidates.length) {
|
|
172
|
+
const archiveDir = path.join(runsDir, '_archive');
|
|
173
|
+
if (archive) {
|
|
174
|
+
fs.mkdirSync(archiveDir, { recursive: true });
|
|
175
|
+
manifestPath = path.join(archiveDir, `prune-${stampIso().replace(/[:.]/g, '-')}.json`);
|
|
176
|
+
fs.writeFileSync(manifestPath, JSON.stringify({
|
|
177
|
+
schema: 'atris.runs_prune_manifest.v1',
|
|
178
|
+
generated_at: stampIso(),
|
|
179
|
+
policy: { keep_newest: keepNewest, keep_days: keepDays },
|
|
180
|
+
pruned_count: candidates.length,
|
|
181
|
+
pruned_bytes: candidates.reduce((sum, entry) => sum + entry.bytes, 0),
|
|
182
|
+
entries: archiveEntries,
|
|
183
|
+
}, null, 2) + '\n', 'utf8');
|
|
184
|
+
}
|
|
185
|
+
for (const entry of candidates) {
|
|
186
|
+
try {
|
|
187
|
+
fs.unlinkSync(entry.absolute_path);
|
|
188
|
+
deleted.push(entry.path);
|
|
189
|
+
removeEmptyDirs(path.dirname(entry.absolute_path), runsDir);
|
|
190
|
+
} catch (error) {
|
|
191
|
+
errors.push({ path: entry.path, error: error.message || String(error) });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
schema: 'atris.runs_prune_preview.v1',
|
|
197
|
+
action: apply ? 'runs_pruned' : 'runs_prune_preview',
|
|
198
|
+
runs_dir: toPosixPath(path.relative(root, runsDir)) || 'atris/runs',
|
|
199
|
+
applied: apply,
|
|
200
|
+
policy: { keep_newest: keepNewest, keep_days: keepDays, archive },
|
|
201
|
+
total_files: files.length,
|
|
202
|
+
total_bytes: totalBytes,
|
|
203
|
+
referenced_files: decisions.filter((entry) => entry.keep_reasons.includes('referenced')).length,
|
|
204
|
+
recent_files: decisions.filter((entry) => entry.keep_reasons.includes('recent')).length,
|
|
205
|
+
newest_files: decisions.filter((entry) => entry.keep_reasons.includes('newest')).length,
|
|
206
|
+
prune_count: candidates.length,
|
|
207
|
+
prune_bytes: candidates.reduce((sum, entry) => sum + entry.bytes, 0),
|
|
208
|
+
deleted_count: deleted.length,
|
|
209
|
+
manifest_path: manifestPath ? toPosixPath(path.relative(root, manifestPath)) : null,
|
|
210
|
+
candidates: candidates.map((entry) => ({
|
|
211
|
+
path: entry.path,
|
|
212
|
+
bytes: entry.bytes,
|
|
213
|
+
age_days: entry.age_days,
|
|
214
|
+
kind: entry.kind,
|
|
215
|
+
})),
|
|
216
|
+
deleted,
|
|
217
|
+
errors,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function formatBytes(bytes) {
|
|
222
|
+
const value = Number(bytes || 0);
|
|
223
|
+
if (value < 1024) return `${value} B`;
|
|
224
|
+
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
|
|
225
|
+
return `${(value / (1024 * 1024)).toFixed(1)} MB`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function runsPruneLines(result) {
|
|
229
|
+
return [
|
|
230
|
+
'Landing:',
|
|
231
|
+
` Changed: ${result.applied ? `Pruned ${result.deleted_count} old run file(s).` : `Found ${result.prune_count} old run file(s) eligible for pruning.`}`,
|
|
232
|
+
` How I checked: Scanned ${result.total_files} files in ${result.runs_dir}; kept referenced, newest, and recent proof.`,
|
|
233
|
+
` Proof: ${result.applied ? (result.manifest_path ? `Manifest saved at ${result.manifest_path}.` : 'No manifest was needed.') : `Would reclaim ${formatBytes(result.prune_bytes)}.`}`,
|
|
234
|
+
` Next: ${result.applied ? 'Run this command on a heartbeat when the run folder grows again.' : 'Run again with --apply to delete the eligible old files.'}`,
|
|
235
|
+
];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
module.exports = {
|
|
239
|
+
pruneRuns,
|
|
240
|
+
runsPruneLines,
|
|
241
|
+
formatBytes,
|
|
242
|
+
};
|