atris 3.31.0 → 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/lib/autoland.js CHANGED
@@ -12,6 +12,8 @@ const { spawnSync } = require('child_process');
12
12
 
13
13
  const DEFAULT_DIGEST_HOUR = 9;
14
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+/;
15
17
 
16
18
  function policyPath(root) {
17
19
  return path.join(root, '.atris', 'policy', 'autoland.json');
@@ -131,7 +133,7 @@ function waitingOnHuman(tasks, now = Date.now()) {
131
133
  .filter((t) => t.review?.agent_certified === true || t.metadata?.agent_certified === true)
132
134
  .map((t) => ({
133
135
  ref: t.display_id || t.legacy_ref || t.id,
134
- title: String(t.title || '').slice(0, 90),
136
+ title: String(t.title || '').slice(0, 200),
135
137
  tag: String(t.tag || ''),
136
138
  hours: waitingHours(t, now),
137
139
  }))
@@ -168,16 +170,54 @@ function cutAtWord(text, max) {
168
170
  return `${cut.slice(0, lastSpace > max * 0.6 ? lastSpace : max)}...`;
169
171
  }
170
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
+
171
197
  // One line per piece, in the work's own words: the landing sentence beats
172
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.
173
201
  function digestLine(item) {
174
- const happened = String(item.happened || '').split(/(?<=[.!?])\s+/)[0] || '';
202
+ const happened = String(item.happened || '').replace(/^completed:\s*/i, '').split(/(?<=[.!?])\s+/)[0] || '';
175
203
  const title = String(item.title || '').replace(/^Mission XP:\s*/i, '');
176
204
  const source = happened.length >= 12 ? happened : title;
177
- const line = cutAtWord(source.replace(/[.\s]+$/, ''), 72);
205
+ const line = finishThought(source.replace(/[.\s]+$/, ''), 160);
178
206
  return line.charAt(0).toLowerCase() + line.slice(1);
179
207
  }
180
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
+
181
221
  // The mission loop files a check-off every time it stops instead of
182
222
  // inventing work; those all say the same thing, so they collapse into one line.
183
223
  function isCleanStop(item) {
@@ -185,40 +225,105 @@ function isCleanStop(item) {
185
225
  || /^(Mission XP:\s*)?Decide and start the next useful mission/i.test(String(item.title || ''));
186
226
  }
187
227
 
188
- // Short enough for one iMessage, plain enough to read over coffee and it
189
- // names the work, because "7 things landed" tells the score, not the story.
190
- function composeDigest({ accepted, waiting, landed, project }) {
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 = [] }) {
191
234
  const lines = [];
192
- lines.push(`atris ${project} daily`);
235
+ lines.push(`atris ${project}: yesterday`);
193
236
  const autoCount = accepted.auto.length;
194
237
  const humanCount = accepted.human.length;
195
238
  if (autoCount > 0) {
196
- lines.push('got their final sign-off on their own (checked twice, proof on file):');
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.
197
241
  const stops = accepted.auto.filter(isCleanStop);
198
- const work = accepted.auto.filter((item) => !isCleanStop(item));
199
- for (const item of work.slice(0, 4)) lines.push(`- ${digestLine(item) || item.ref}`);
200
- if (work.length > 4) lines.push(`- and ${work.length - 4} more`);
201
- if (stops.length > 0) lines.push(`- ${plur(stops.length, 'check')} that the loop stops cleanly instead of inventing work`);
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
+ }
202
261
  }
203
- if (humanCount > 0) lines.push(`you approved ${plur(humanCount, 'piece')} yourself`);
204
262
  if (autoCount + humanCount === 0) lines.push('nothing finished in the last day');
205
- const byMember = new Map();
206
- for (const item of [...accepted.auto, ...accepted.human]) {
207
- if (!item.member || isCleanStop(item)) continue;
208
- byMember.set(item.member, (byMember.get(item.member) || 0) + 1);
209
- }
210
- if (byMember.size > 0) {
211
- lines.push(`workers: ${Array.from(byMember, ([who, n]) => `${who} landed ${n}`).join(', ')}`);
212
- }
213
263
  if (waiting.length > 0) {
214
- lines.push(`waiting on you: ${waiting.length} (oldest ${waiting[0].hours}h) atris task reviews`);
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`);
215
270
  } else {
216
- lines.push('nothing is waiting on you');
271
+ lines.push('waiting on you: nothing');
217
272
  }
218
273
  if (landed && landed.branches > 0) {
219
- lines.push(`${plur(landed.branches, 'piece')} of work still in the air${landed.due > 0 ? `, ${landed.due} overdue` : ''} atris land`);
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`);
220
326
  }
221
- lines.push('the full story: atris autoland digest');
222
327
  return lines.join('\n');
223
328
  }
224
329
 
@@ -264,12 +369,15 @@ module.exports = {
264
369
  buildCronLine,
265
370
  composeAlarm,
266
371
  composeDigest,
372
+ composeLiveUpdate,
267
373
  cronInstalled,
268
374
  cronMarker,
269
375
  dueForAlarm,
270
376
  installCron,
271
377
  liveAcceptAuthorization,
272
378
  markAlerted,
379
+ operatorReady,
380
+ hasAgentJargon,
273
381
  policyPath,
274
382
  readPolicy,
275
383
  readState,
@@ -0,0 +1,7 @@
1
+ function printOperatorNext(command, { label = 'Next' } = {}) {
2
+ const clean = String(command || '').trim();
3
+ if (!clean) return;
4
+ console.log(`${label}: ${clean}`);
5
+ }
6
+
7
+ module.exports = { printOperatorNext };
package/lib/pulse.js CHANGED
@@ -14,6 +14,7 @@
14
14
 
15
15
  const fs = require('fs');
16
16
  const path = require('path');
17
+ const { DEFAULT_CLAUDE_RUNNER_MODEL } = require('./runner-command');
17
18
 
18
19
  const PULSE_RECEIPT_SCHEMA = 'atris.pulse_tick.v1';
19
20
  // Reuse the improve-tick scorecard schema so the brain + policy-lessons see
@@ -354,7 +355,7 @@ function buildTickScript(opts = {}) {
354
355
  stateHome,
355
356
  deadlineEpoch,
356
357
  marker = PULSE_MARKER,
357
- model = 'opus',
358
+ model = DEFAULT_CLAUDE_RUNNER_MODEL,
358
359
  runnerProfile = '',
359
360
  runnerBin = '',
360
361
  runnerCommandTemplate = '',
@@ -419,8 +420,8 @@ log="$LOG_DIR/$stamp.log"
419
420
 
420
421
  cd "$ROOT" || { echo "$(date -Iseconds) ROOT missing" >> "$LOG_DIR/control.log"; exit 1; }
421
422
 
422
- # Autonomous ticks must target a live model alias, never a versioned id that can
423
- # retire out from under the loop (lesson: retired-model-kills-loop-silently).
423
+ # Autonomous ticks use the pinned default unless the installer supplied a model.
424
+ # Operators can still override per install with --model or env.
424
425
  ${runnerModelExport}
425
426
  ${runnerProfileExport}
426
427
  ${runnerBinExport}
@@ -10,24 +10,40 @@
10
10
  // ATRIS_RUNNER_PROFILE -> legacy ATRIS_CLAUDE_MODEL env -> pinned default.
11
11
  const DEFAULT_CLAUDE_RUNNER_MODEL = 'claude-opus-4-8';
12
12
  const DEFAULT_CLAUDE_RUNNER_BIN = 'claude';
13
- const RUNNER_PROFILES = Object.freeze({
13
+
14
+ // Canonical runner profiles. Each entry is the config an operator would pick
15
+ // on purpose. Aliases (spelling variants of the same profile) resolve to the
16
+ // canonical config but are kept OUT of operator-facing lists so config errors
17
+ // stay honest: "one of: atris-fast", not three duplicate spellings.
18
+ const RUNNER_PROFILE_DEFS = Object.freeze({
14
19
  'atris-fast': Object.freeze({
15
20
  bin: 'ax',
16
21
  model: 'atris:fast',
17
22
  commandTemplate: '{bin} --fast {prompt}',
18
23
  }),
19
- 'atris2-fast': Object.freeze({
20
- bin: 'ax',
21
- model: 'atris:fast',
22
- commandTemplate: '{bin} --fast {prompt}',
23
- }),
24
- 'atris-2-fast': Object.freeze({
25
- bin: 'ax',
26
- model: 'atris:fast',
27
- commandTemplate: '{bin} --fast {prompt}',
28
- }),
29
24
  });
30
25
 
26
+ // Alias -> canonical profile name. Every alias resolves to the same config as
27
+ // its canonical target; adding a spelling variant here is a config change, not
28
+ // a duplicated profile body that can drift.
29
+ const RUNNER_PROFILE_ALIASES = Object.freeze({
30
+ 'atris2-fast': 'atris-fast',
31
+ 'atris-2-fast': 'atris-fast',
32
+ });
33
+
34
+ // Back-compat surface: RUNNER_PROFILES still resolves every accepted name
35
+ // (canonical + alias) to its frozen config, so existing lookups by any spelling
36
+ // keep working.
37
+ const RUNNER_PROFILES = Object.freeze(
38
+ Object.fromEntries([
39
+ ...Object.entries(RUNNER_PROFILE_DEFS),
40
+ ...Object.entries(RUNNER_PROFILE_ALIASES).map(([alias, target]) => [alias, RUNNER_PROFILE_DEFS[target]]),
41
+ ])
42
+ );
43
+
44
+ // Operator-facing list: canonical profile names only (no alias noise).
45
+ const RUNNER_PROFILE_NAMES = Object.freeze(Object.keys(RUNNER_PROFILE_DEFS));
46
+
31
47
  function shellWord(value) {
32
48
  const s = String(value || '');
33
49
  if (/^[A-Za-z0-9_./:-]+$/.test(s)) return s;
@@ -51,7 +67,7 @@ function resolveRunnerProfile() {
51
67
  if (!name) return null;
52
68
  const profile = RUNNER_PROFILES[name];
53
69
  if (!profile) {
54
- throw new Error(`Unknown ATRIS_RUNNER_PROFILE "${name}". Known profiles: ${Object.keys(RUNNER_PROFILES).join(', ')}`);
70
+ throw new Error(`Unknown ATRIS_RUNNER_PROFILE "${name}". Known profiles: ${RUNNER_PROFILE_NAMES.join(', ')}`);
55
71
  }
56
72
  return profile;
57
73
  }
@@ -98,7 +114,7 @@ function buildRunnerAvailabilityCommand() {
98
114
  function runnerAvailabilityFailureMessage(error) {
99
115
  const message = error && error.message ? String(error.message).trim() : '';
100
116
  if (message.startsWith('Unknown ATRIS_RUNNER_PROFILE')) {
101
- return `${message}. Set ATRIS_RUNNER_PROFILE to one of: ${Object.keys(RUNNER_PROFILES).join(', ')}.`;
117
+ return `${message}. Set ATRIS_RUNNER_PROFILE to one of: ${RUNNER_PROFILE_NAMES.join(', ')}.`;
102
118
  }
103
119
 
104
120
  let runnerBin = 'configured runner';
@@ -154,6 +170,9 @@ module.exports = {
154
170
  DEFAULT_CLAUDE_RUNNER_MODEL,
155
171
  DEFAULT_CLAUDE_RUNNER_BIN,
156
172
  RUNNER_PROFILES,
173
+ RUNNER_PROFILE_DEFS,
174
+ RUNNER_PROFILE_ALIASES,
175
+ RUNNER_PROFILE_NAMES,
157
176
  resolveRunnerProfileName,
158
177
  resolveRunnerProfile,
159
178
  resolveRunnerModel: resolveClaudeRunnerModel,
package/lib/task-db.js CHANGED
@@ -1198,7 +1198,7 @@ function chatTask(db, { id, actor, content, goal, summary }) {
1198
1198
  };
1199
1199
  }
1200
1200
 
1201
- function reviewTask(db, { id, actor, reward, lesson, nextTask, proof, verify, careerXpEligible = false, clearedFields = [] }) {
1201
+ function reviewTask(db, { id, actor, reward, lesson, nextTask, proof, verify, careerXpEligible = false, clearedFields = [], landing = {} }) {
1202
1202
  if (!id) throw new Error('id required');
1203
1203
  const row = getTask(db, id);
1204
1204
  if (!row) return { reviewed: false, reason: 'not_found' };
@@ -1213,6 +1213,15 @@ function reviewTask(db, { id, actor, reward, lesson, nextTask, proof, verify, ca
1213
1213
  const clearedReviewFields = Array.isArray(clearedFields)
1214
1214
  ? Array.from(new Set(clearedFields.filter(field => field === 'lesson' || field === 'next_task')))
1215
1215
  : [];
1216
+ const landingInput = landing && typeof landing === 'object' ? landing : {};
1217
+ let landingUpdated = false;
1218
+ for (const key of ['happened', 'checked', 'tested', 'decision']) {
1219
+ const cleaned = cleanLandingText(landingInput[key]);
1220
+ if (cleaned) {
1221
+ metadata[`landing_${key}`] = cleaned;
1222
+ landingUpdated = true;
1223
+ }
1224
+ }
1216
1225
  const reviewingPendingProof = row.status === 'review'
1217
1226
  && metadata.approval_status === 'pending'
1218
1227
  && numericReward <= 0
@@ -1250,7 +1259,7 @@ function reviewTask(db, { id, actor, reward, lesson, nextTask, proof, verify, ca
1250
1259
  metadata.accepted_at = new Date().toISOString();
1251
1260
  metadata.accepted_by = reviewer;
1252
1261
  }
1253
- if (reviewingPendingProof || updatingPendingReviewProof || (numericReward > 0 && row.status === 'done')) {
1262
+ if (reviewingPendingProof || updatingPendingReviewProof || (numericReward > 0 && row.status === 'done') || landingUpdated) {
1254
1263
  withBusyRetry(() => db.prepare(`
1255
1264
  UPDATE tasks
1256
1265
  SET metadata = ?,
@@ -1271,6 +1280,14 @@ function reviewTask(db, { id, actor, reward, lesson, nextTask, proof, verify, ca
1271
1280
  payload.agent_certified = metadata.agent_certified === true;
1272
1281
  payload.agent_certification_policy = metadata.agent_certification_policy || null;
1273
1282
  }
1283
+ if (landingUpdated) {
1284
+ payload.landing = {
1285
+ happened: metadata.landing_happened || null,
1286
+ checked: metadata.landing_checked || null,
1287
+ tested: metadata.landing_tested || null,
1288
+ decision: metadata.landing_decision || null,
1289
+ };
1290
+ }
1274
1291
  if (clearedReviewFields.length) payload.cleared_review_fields = clearedReviewFields;
1275
1292
  const event = appendTaskEvent(db, {
1276
1293
  taskId: id,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.31.0",
3
+ "version": "3.32.0",
4
4
  "main": "bin/atris.js",
5
5
  "bin": {
6
6
  "atris": "bin/atris.js",
@@ -55,7 +55,7 @@
55
55
  "test": "node --test",
56
56
  "lint:decks": "node scripts/lint-all-decks.js",
57
57
  "publish:release": "node scripts/publish-atris-release.js",
58
- "prepare": "cp scripts/pre-commit .git/hooks/pre-commit 2>/dev/null && chmod +x .git/hooks/pre-commit || true"
58
+ "prepare": "cp scripts/pre-commit .git/hooks/pre-commit 2>/dev/null && cp scripts/commit-msg .git/hooks/commit-msg 2>/dev/null && cp scripts/prepare-commit-msg .git/hooks/prepare-commit-msg 2>/dev/null && chmod +x .git/hooks/pre-commit .git/hooks/commit-msg .git/hooks/prepare-commit-msg || true"
59
59
  },
60
60
  "keywords": [
61
61
  "atrisdev",