atris 3.30.12 → 3.32.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 +6 -4
- package/atris/CLAUDE.md +8 -0
- 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 +15 -0
- package/ax +189 -7
- package/bin/atris.js +273 -225
- package/commands/autoland.js +379 -0
- package/commands/autopilot-front.js +273 -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 +22 -1
- package/commands/land.js +442 -0
- package/commands/loop-front.js +122 -4
- package/commands/member.js +551 -19
- package/commands/mission.js +3674 -278
- package/commands/play.js +1 -1
- package/commands/pulse.js +65 -7
- package/commands/run-front.js +144 -0
- package/commands/run.js +10 -7
- package/commands/sign.js +90 -0
- package/commands/strings.js +301 -0
- package/commands/task.js +782 -108
- package/commands/truth.js +171 -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 +391 -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/operator-next.js +7 -0
- package/lib/pulse.js +78 -4
- package/lib/runner-command.js +51 -20
- package/lib/runs-prune.js +242 -0
- package/lib/task-db.js +19 -2
- package/lib/task-proof.js +1 -1
- package/package.json +4 -4
- 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/autoland.js
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Autoland: the owner approves the policy once, then certified work lands
|
|
4
|
+
// itself. Humans keep the genuinely irreversible lanes (the denied tags in
|
|
5
|
+
// lib/auto-accept-certified.js); everything else is verified, reversible,
|
|
6
|
+
// and receipted, so waiting for a human click adds latency, not safety.
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const os = require('os');
|
|
11
|
+
const { spawnSync } = require('child_process');
|
|
12
|
+
|
|
13
|
+
const DEFAULT_DIGEST_HOUR = 9;
|
|
14
|
+
const DEFAULT_ALARM_HOURS = 24;
|
|
15
|
+
const OPERATOR_WHY = /\b(sav(?:e|es|ing)|burn(?:s|ing)?|wast(?:e|es|ing)|cost(?:s|ing)?|prevent\w*|stop(?:s|ping)?|break(?:s|ing)?|fail(?:s|ing)?|slow(?:s|er)?|faster|cheaper|easier|clearer|safer|simpler|trust|revenue|users?|customers?|so that|because)\b/i;
|
|
16
|
+
const AGENT_JARGON = /[a-z0-9]_[a-z0-9]|\b[a-z]+[A-Z]\w*|--[a-z]|\bCLI-\d+/;
|
|
17
|
+
|
|
18
|
+
function policyPath(root) {
|
|
19
|
+
return path.join(root, '.atris', 'policy', 'autoland.json');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function statePath(root) {
|
|
23
|
+
return path.join(root, '.atris', 'state', 'autoland.json');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function readJson(file, fallback) {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
29
|
+
} catch (err) {
|
|
30
|
+
return fallback;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function writeJson(file, value) {
|
|
35
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
36
|
+
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readPolicy(root) {
|
|
40
|
+
const policy = readJson(policyPath(root), null);
|
|
41
|
+
if (!policy || typeof policy !== 'object') return null;
|
|
42
|
+
return policy;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function writePolicy(root, policy) {
|
|
46
|
+
writeJson(policyPath(root), policy);
|
|
47
|
+
return policy;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readState(root) {
|
|
51
|
+
const state = readJson(statePath(root), {});
|
|
52
|
+
if (!state.alerts || typeof state.alerts !== 'object') state.alerts = {};
|
|
53
|
+
return state;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function writeState(root, state) {
|
|
57
|
+
writeJson(statePath(root), state);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The standing authorization cmdAutoAcceptCertified consults: live accepts
|
|
61
|
+
// are allowed without a per-run human confirmation only when the owner has
|
|
62
|
+
// flipped the policy on for this workspace.
|
|
63
|
+
function liveAcceptAuthorization(root = process.cwd()) {
|
|
64
|
+
const policy = readPolicy(root);
|
|
65
|
+
if (!policy || policy.enabled !== true) return { ok: false, reason: 'autoland_policy_off' };
|
|
66
|
+
const actor = String(policy.enabled_by || '').trim();
|
|
67
|
+
if (!actor) return { ok: false, reason: 'autoland_policy_missing_owner' };
|
|
68
|
+
return { ok: true, actor, policy: 'autoland', strictVerify: policy.strict_verify !== false };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function cronMarker(root) {
|
|
72
|
+
const slug = path.basename(root).replace(/[^a-zA-Z0-9]+/g, '_').toUpperCase();
|
|
73
|
+
return `ATRIS_AUTOLAND_${slug}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function buildCronLine(root) {
|
|
77
|
+
const node = process.execPath;
|
|
78
|
+
const bin = path.join(root, 'bin', 'atris.js');
|
|
79
|
+
const cliBin = fs.existsSync(bin) ? bin : 'atris';
|
|
80
|
+
const logDir = path.join(os.homedir(), '.atris', 'overnight');
|
|
81
|
+
const logFile = path.join(logDir, `autoland-${path.basename(root)}.log`);
|
|
82
|
+
const invoke = cliBin.endsWith('.js') ? `${node} ${cliBin}` : cliBin;
|
|
83
|
+
return `14 * * * * cd ${root} && ${invoke} autoland tick >> ${logFile} 2>&1 # ${cronMarker(root)}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function readCrontab() {
|
|
87
|
+
const result = spawnSync('crontab', ['-l'], { encoding: 'utf8', timeout: 10000 });
|
|
88
|
+
return result.status === 0 ? String(result.stdout || '') : '';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function applyCrontab(content) {
|
|
92
|
+
const result = spawnSync('crontab', ['-'], { input: content, encoding: 'utf8', timeout: 10000 });
|
|
93
|
+
return result.status === 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function cronInstalled(root) {
|
|
97
|
+
return readCrontab().includes(cronMarker(root));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function installCron(root) {
|
|
101
|
+
fs.mkdirSync(path.join(os.homedir(), '.atris', 'overnight'), { recursive: true });
|
|
102
|
+
const marker = cronMarker(root);
|
|
103
|
+
const kept = readCrontab().split(/\r?\n/).filter((line) => line.trim() && !line.includes(marker));
|
|
104
|
+
const next = `${kept.join('\n')}${kept.length ? '\n' : ''}${buildCronLine(root)}\n`;
|
|
105
|
+
return applyCrontab(next);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function uninstallCron(root) {
|
|
109
|
+
const marker = cronMarker(root);
|
|
110
|
+
const kept = readCrontab().split(/\r?\n/).filter((line) => line.trim() && !line.includes(marker));
|
|
111
|
+
return applyCrontab(kept.length ? `${kept.join('\n')}\n` : '');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function certifiedAtMs(task) {
|
|
115
|
+
const iso = task.metadata?.agent_certified_at;
|
|
116
|
+
if (iso) {
|
|
117
|
+
const ms = Date.parse(iso);
|
|
118
|
+
if (Number.isFinite(ms)) return ms;
|
|
119
|
+
}
|
|
120
|
+
return Number(task.updated_at) || Date.now();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function waitingHours(task, now = Date.now()) {
|
|
124
|
+
return Math.floor((now - certifiedAtMs(task)) / 3600000);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Tasks that are certified and pending — i.e. finished work sitting on the
|
|
128
|
+
// human side of the fence, whatever the reason.
|
|
129
|
+
function waitingOnHuman(tasks, now = Date.now()) {
|
|
130
|
+
return (tasks || [])
|
|
131
|
+
.filter((t) => t.status === 'review')
|
|
132
|
+
.filter((t) => String(t.review?.approval_status || t.metadata?.approval_status || 'pending') === 'pending')
|
|
133
|
+
.filter((t) => t.review?.agent_certified === true || t.metadata?.agent_certified === true)
|
|
134
|
+
.map((t) => ({
|
|
135
|
+
ref: t.display_id || t.legacy_ref || t.id,
|
|
136
|
+
title: String(t.title || '').slice(0, 200),
|
|
137
|
+
tag: String(t.tag || ''),
|
|
138
|
+
hours: waitingHours(t, now),
|
|
139
|
+
}))
|
|
140
|
+
.sort((a, b) => b.hours - a.hours);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function acceptedInLastDay(tasks, now = Date.now()) {
|
|
144
|
+
const auto = [];
|
|
145
|
+
const human = [];
|
|
146
|
+
for (const t of tasks || []) {
|
|
147
|
+
const acceptedAt = Date.parse(t.metadata?.accepted_at || '') || 0;
|
|
148
|
+
if (!acceptedAt || now - acceptedAt > 24 * 3600000) continue;
|
|
149
|
+
const entry = {
|
|
150
|
+
ref: t.display_id || t.legacy_ref || t.id,
|
|
151
|
+
title: String(t.title || '').slice(0, 120),
|
|
152
|
+
happened: String(t.review?.landing?.happened || t.metadata?.landing_happened || '').slice(0, 300),
|
|
153
|
+
member: String(t.claimed_by || t.assigned_to || '').trim(),
|
|
154
|
+
};
|
|
155
|
+
if (t.metadata?.auto_accepted_at) auto.push(entry);
|
|
156
|
+
else human.push(entry);
|
|
157
|
+
}
|
|
158
|
+
return { auto, human };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function plur(n, word) {
|
|
162
|
+
return `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function cutAtWord(text, max) {
|
|
166
|
+
const clean = String(text || '').replace(/\s+/g, ' ').trim();
|
|
167
|
+
if (clean.length <= max) return clean;
|
|
168
|
+
const cut = clean.slice(0, max);
|
|
169
|
+
const lastSpace = cut.lastIndexOf(' ');
|
|
170
|
+
return `${cut.slice(0, lastSpace > max * 0.6 ? lastSpace : max)}...`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// A line ends where a thought ends. Long lines close at their last complete
|
|
174
|
+
// clause inside the budget and read whole: no ellipsis, nothing dangling.
|
|
175
|
+
// Only when a line has no clause boundary at all does a word cut remain.
|
|
176
|
+
function finishThought(text, max = 160) {
|
|
177
|
+
const clean = String(text || '').replace(/\s+/g, ' ').trim().replace(/[,;:\s]+$/, '');
|
|
178
|
+
if (clean.length <= max) return clean;
|
|
179
|
+
const cut = clean.slice(0, max);
|
|
180
|
+
const sentence = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf('! '), cut.lastIndexOf('? '));
|
|
181
|
+
if (sentence > max * 0.3) return cut.slice(0, sentence + 1);
|
|
182
|
+
const clause = Math.max(cut.lastIndexOf(', '), cut.lastIndexOf('; '), cut.lastIndexOf(' ('), cut.lastIndexOf(': '));
|
|
183
|
+
if (clause > max * 0.45) return cut.slice(0, clause);
|
|
184
|
+
const space = cut.lastIndexOf(' ');
|
|
185
|
+
return `${cut.slice(0, space > max * 0.6 ? space : max)}...`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function operatorReady(text) {
|
|
189
|
+
const value = String(text || '');
|
|
190
|
+
return OPERATOR_WHY.test(value) && !AGENT_JARGON.test(value);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function hasAgentJargon(text) {
|
|
194
|
+
return AGENT_JARGON.test(String(text || ''));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// One line per piece, in the work's own words: the landing sentence beats
|
|
198
|
+
// the task title, because titles are written for the queue, not for a text.
|
|
199
|
+
// The cut is generous on purpose: a specific sentence the reader trusts
|
|
200
|
+
// (what happened + how big + how we know) beats a short one they must chase.
|
|
201
|
+
function digestLine(item) {
|
|
202
|
+
const happened = String(item.happened || '').replace(/^completed:\s*/i, '').split(/(?<=[.!?])\s+/)[0] || '';
|
|
203
|
+
const title = String(item.title || '').replace(/^Mission XP:\s*/i, '');
|
|
204
|
+
const source = happened.length >= 12 ? happened : title;
|
|
205
|
+
const line = finishThought(source.replace(/[.\s]+$/, ''), 160);
|
|
206
|
+
return line.charAt(0).toLowerCase() + line.slice(1);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// A rough sentence still beats a count: when a landing sentence carries agent
|
|
210
|
+
// vocabulary, strip what a reader can't use (flag dashes, ids, underscores)
|
|
211
|
+
// and show it anyway. Content always ships; the gate only changes how it reads.
|
|
212
|
+
function dejargon(line) {
|
|
213
|
+
return String(line || '')
|
|
214
|
+
.replace(/--([a-z][a-z-]*)/g, '$1')
|
|
215
|
+
.replace(/\bCLI-\d+\b/g, '')
|
|
216
|
+
.replace(/([a-z0-9])_([a-z0-9])/gi, '$1 $2')
|
|
217
|
+
.replace(/\s+/g, ' ')
|
|
218
|
+
.trim();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// The mission loop files a check-off every time it stops instead of
|
|
222
|
+
// inventing work; those all say the same thing, so they collapse into one line.
|
|
223
|
+
function isCleanStop(item) {
|
|
224
|
+
return /no concrete follow-up|did not start fake work|stopped with reason/i.test(String(item.happened || ''))
|
|
225
|
+
|| /^(Mission XP:\s*)?Decide and start the next useful mission/i.test(String(item.title || ''));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Short enough for one iMessage, plain enough to read walking to the car.
|
|
229
|
+
// Every line answers: what happened, how big, how we know. It names the work
|
|
230
|
+
// because "7 things landed" tells the score, not the story — and it always
|
|
231
|
+
// ends with what's waiting and what to do next, because those are the only
|
|
232
|
+
// lines that ask anything of the reader.
|
|
233
|
+
function composeDigest({ accepted, waiting, landed, project, nextMoves = [] }) {
|
|
234
|
+
const lines = [];
|
|
235
|
+
lines.push(`atris ${project}: yesterday`);
|
|
236
|
+
const autoCount = accepted.auto.length;
|
|
237
|
+
const humanCount = accepted.human.length;
|
|
238
|
+
if (autoCount > 0) {
|
|
239
|
+
// Three results, air between them, the rest on ask: the whole message
|
|
240
|
+
// fits a laptop screen with no scrolling. Reading it is one glance.
|
|
241
|
+
const stops = accepted.auto.filter(isCleanStop);
|
|
242
|
+
const work = accepted.auto
|
|
243
|
+
.filter((item) => !isCleanStop(item))
|
|
244
|
+
.map((item) => ({ item, line: digestLine(item) }));
|
|
245
|
+
// Operator-ready sentences lead; rough ones get de-jargoned and still
|
|
246
|
+
// shown. An empty report is the one thing a report must never be.
|
|
247
|
+
const ready = work.filter(({ line }) => operatorReady(line));
|
|
248
|
+
const rough = work.filter(({ line }) => !operatorReady(line))
|
|
249
|
+
.map(({ item, line }) => ({ item, line: dejargon(line) }));
|
|
250
|
+
const visibleWork = [...ready, ...rough].slice(0, 3);
|
|
251
|
+
const held = Math.max(0, work.length - visibleWork.length) + (stops.length > 0 ? 1 : 0);
|
|
252
|
+
if (visibleWork.length > 0) {
|
|
253
|
+
lines.push('landed on their own (verified twice, proof on file):');
|
|
254
|
+
for (const { item, line } of visibleWork) {
|
|
255
|
+
lines.push('');
|
|
256
|
+
lines.push(`- ${line || item.ref}${item.member ? ` (${item.member})` : ''}`);
|
|
257
|
+
}
|
|
258
|
+
lines.push('');
|
|
259
|
+
if (held > 0) lines.push(`${plur(held, 'more result')} when you want them: atris autoland digest`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (autoCount + humanCount === 0) lines.push('nothing finished in the last day');
|
|
263
|
+
if (waiting.length > 0) {
|
|
264
|
+
// The only lines that ask the reader to act, so they name what's asking.
|
|
265
|
+
lines.push(`waiting on you (approve or bounce: atris task reviews):`);
|
|
266
|
+
for (const item of waiting.slice(0, 2)) {
|
|
267
|
+
lines.push(`- ${finishThought(dejargon(item.title), 140)}${item.hours >= 12 ? ` (${item.hours}h)` : ''}`);
|
|
268
|
+
}
|
|
269
|
+
if (waiting.length > 2) lines.push(`- and ${waiting.length - 2} more`);
|
|
270
|
+
} else {
|
|
271
|
+
lines.push('waiting on you: nothing');
|
|
272
|
+
}
|
|
273
|
+
if (landed && landed.branches > 0) {
|
|
274
|
+
lines.push(`in the air: ${plur(landed.branches, 'piece')}${landed.due > 0 ? `, ${landed.due} overdue` : ', all fresh'} (atris land)`);
|
|
275
|
+
}
|
|
276
|
+
// nextMoves: array of {title, owner}, or {moves, unexplained} where
|
|
277
|
+
// unexplained counts queue items whose sentence carries no operator why.
|
|
278
|
+
// Those are counted, not shown — a raw title the reader can't act on is
|
|
279
|
+
// noise here and a writing bug at its source.
|
|
280
|
+
const nextInfo = Array.isArray(nextMoves)
|
|
281
|
+
? { moves: nextMoves, unexplained: 0 }
|
|
282
|
+
: (nextMoves || { moves: [], unexplained: 0 });
|
|
283
|
+
const moves = (nextInfo.moves || []).filter((move) => move && move.title).slice(0, 3);
|
|
284
|
+
const unexplained = Number(nextInfo.unexplained || 0);
|
|
285
|
+
if (moves.length > 0) {
|
|
286
|
+
lines.push('next, if you agree:');
|
|
287
|
+
for (const move of moves) {
|
|
288
|
+
lines.push(`- ${finishThought(move.title, 140)}${move.owner ? ` (best fit: ${move.owner})` : ''}`);
|
|
289
|
+
}
|
|
290
|
+
if (unexplained > 0) lines.push(`- ${plur(unexplained, 'more idea')} that can't explain themselves yet (atris now)`);
|
|
291
|
+
} else if (unexplained > 0) {
|
|
292
|
+
lines.push(`${plur(unexplained, 'idea')} in the queue can't explain themselves yet (atris now)`);
|
|
293
|
+
}
|
|
294
|
+
if (!lines.some((line) => line.includes('atris autoland digest'))) {
|
|
295
|
+
lines.push('the full story: atris autoland digest');
|
|
296
|
+
}
|
|
297
|
+
return lines.join('\n');
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// The moment something lands, the operator hears it in one glance: the
|
|
301
|
+
// landing sentence each piece wrote for a day-one PM at finish time, its
|
|
302
|
+
// author, nothing else. Rough sentences get de-jargoned, never hidden.
|
|
303
|
+
function composeLiveUpdate({ landedRefs, tasks, project }) {
|
|
304
|
+
const byRef = new Map((tasks || []).map((t) => [String(t.display_id || t.legacy_ref || t.id), t]));
|
|
305
|
+
const items = (landedRefs || []).map((ref) => {
|
|
306
|
+
const t = byRef.get(String(ref));
|
|
307
|
+
if (!t) return { title: String(ref) };
|
|
308
|
+
return {
|
|
309
|
+
ref: String(ref),
|
|
310
|
+
title: String(t.title || '').slice(0, 200),
|
|
311
|
+
happened: String(t.review?.landing?.happened || t.metadata?.landing_happened || '').slice(0, 300),
|
|
312
|
+
member: String(t.claimed_by || t.assigned_to || '').trim(),
|
|
313
|
+
};
|
|
314
|
+
}).filter((item) => !isCleanStop(item));
|
|
315
|
+
if (!items.length) return '';
|
|
316
|
+
const lines = [`atris ${project}: just landed`];
|
|
317
|
+
for (const item of items.slice(0, 3)) {
|
|
318
|
+
const raw = digestLine(item);
|
|
319
|
+
const line = operatorReady(raw) ? raw : dejargon(raw);
|
|
320
|
+
lines.push('');
|
|
321
|
+
lines.push(`- ${finishThought(line, 160)}${item.member ? ` (${item.member})` : ''}`);
|
|
322
|
+
}
|
|
323
|
+
if (items.length > 3) {
|
|
324
|
+
lines.push('');
|
|
325
|
+
lines.push(`and ${plur(items.length - 3, 'more piece')}: atris autoland digest`);
|
|
326
|
+
}
|
|
327
|
+
return lines.join('\n');
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function composeAlarm({ waiting, project, alarmHours }) {
|
|
331
|
+
const worst = waiting[0];
|
|
332
|
+
return [
|
|
333
|
+
`atris ${project}: ${plur(waiting.length, 'piece')} of finished work ${waiting.length === 1 ? 'has' : 'have'} been waiting on you for over ${alarmHours}h`,
|
|
334
|
+
`oldest: ${worst.ref} (${worst.hours}h) — ${worst.title}`,
|
|
335
|
+
'approve or bounce: atris task reviews',
|
|
336
|
+
].join('\n');
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Alarm dedupe: a task pings at most once per alarm window.
|
|
340
|
+
function dueForAlarm(waiting, state, { alarmHours = DEFAULT_ALARM_HOURS, now = Date.now() } = {}) {
|
|
341
|
+
const due = [];
|
|
342
|
+
for (const item of waiting) {
|
|
343
|
+
if (item.hours < alarmHours) continue;
|
|
344
|
+
const lastAlert = Date.parse(state.alerts[item.ref] || '') || 0;
|
|
345
|
+
if (now - lastAlert < alarmHours * 3600000) continue;
|
|
346
|
+
due.push(item);
|
|
347
|
+
}
|
|
348
|
+
return due;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function markAlerted(state, items, now = Date.now()) {
|
|
352
|
+
for (const item of items) state.alerts[item.ref] = new Date(now).toISOString();
|
|
353
|
+
return state;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function sendImessage(root, to, text) {
|
|
357
|
+
const bin = path.join(root, 'bin', 'atris.js');
|
|
358
|
+
const argv = fs.existsSync(bin)
|
|
359
|
+
? [process.execPath, [bin, 'imessage', 'send', '--to', to, '--text', text, '--approved']]
|
|
360
|
+
: ['atris', ['imessage', 'send', '--to', to, '--text', text, '--approved']];
|
|
361
|
+
const result = spawnSync(argv[0], argv[1], { cwd: root, encoding: 'utf8', timeout: 30000 });
|
|
362
|
+
return { ok: result.status === 0, output: String(result.stdout || result.stderr || '').trim().slice(0, 300) };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
module.exports = {
|
|
366
|
+
DEFAULT_ALARM_HOURS,
|
|
367
|
+
DEFAULT_DIGEST_HOUR,
|
|
368
|
+
acceptedInLastDay,
|
|
369
|
+
buildCronLine,
|
|
370
|
+
composeAlarm,
|
|
371
|
+
composeDigest,
|
|
372
|
+
composeLiveUpdate,
|
|
373
|
+
cronInstalled,
|
|
374
|
+
cronMarker,
|
|
375
|
+
dueForAlarm,
|
|
376
|
+
installCron,
|
|
377
|
+
liveAcceptAuthorization,
|
|
378
|
+
markAlerted,
|
|
379
|
+
operatorReady,
|
|
380
|
+
hasAgentJargon,
|
|
381
|
+
policyPath,
|
|
382
|
+
readPolicy,
|
|
383
|
+
readState,
|
|
384
|
+
sendImessage,
|
|
385
|
+
statePath,
|
|
386
|
+
uninstallCron,
|
|
387
|
+
waitingHours,
|
|
388
|
+
waitingOnHuman,
|
|
389
|
+
writePolicy,
|
|
390
|
+
writeState,
|
|
391
|
+
};
|
package/lib/context-gatherer.js
CHANGED
|
@@ -25,13 +25,6 @@ function hasContextProfile(root = process.cwd()) {
|
|
|
25
25
|
return Boolean(profile && String(profile.first_answer || '').trim());
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
function isAtrisMetaQuestion(value) {
|
|
29
|
-
const text = String(value || '').trim().toLowerCase().replace(/[?.!]+$/g, '');
|
|
30
|
-
if (!text) return false;
|
|
31
|
-
return /^(what is|what's|whats|who is|who's|whos|explain|tell me about)\s+atris\b/.test(text)
|
|
32
|
-
|| /^atris\s+(help|overview|what is this|what are you)$/i.test(text);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
28
|
function compactText(value, max = 160) {
|
|
36
29
|
const text = String(value || '').replace(/\s+/g, ' ').trim();
|
|
37
30
|
if (!text) return '';
|
|
@@ -177,7 +170,6 @@ module.exports = {
|
|
|
177
170
|
saveContextProfile,
|
|
178
171
|
createStarterTask,
|
|
179
172
|
shouldGatherContext,
|
|
180
|
-
isAtrisMetaQuestion,
|
|
181
173
|
renderPrompt,
|
|
182
174
|
starterTaskTitle,
|
|
183
175
|
inferDomain,
|