atris 3.35.0 → 3.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/AGENTS.md +37 -0
  2. package/README.md +5 -3
  3. package/atris/GETTING_STARTED.md +1 -1
  4. package/atris/atris.md +3 -0
  5. package/atris/policies/day-loop-voice.md +102 -0
  6. package/atris/policies/outbound-artifact-gate.md +2 -0
  7. package/atris/skills/design/SKILL.md +56 -32
  8. package/atris/skills/endgame/SKILL.md +12 -6
  9. package/atris/skills/engines/SKILL.md +22 -4
  10. package/atris/skills/fable-method/SKILL.md +66 -0
  11. package/atris/skills/improve/SKILL.md +65 -45
  12. package/atris/skills/youtube/SKILL.md +10 -1
  13. package/atris.md +2 -0
  14. package/ax +147 -19
  15. package/bin/atris.js +576 -267
  16. package/commands/activate.js +194 -88
  17. package/commands/agents.js +166 -0
  18. package/commands/autoland.js +459 -107
  19. package/commands/autopilot-front.js +20 -2
  20. package/commands/autopilot.js +118 -2
  21. package/commands/avail.js +407 -0
  22. package/commands/bench.js +188 -0
  23. package/commands/brain.js +3 -0
  24. package/commands/brief.js +651 -0
  25. package/commands/business-sync.js +192 -6
  26. package/commands/clean.js +50 -24
  27. package/commands/close.js +1083 -0
  28. package/commands/cloud.js +245 -0
  29. package/commands/compile.js +292 -1
  30. package/commands/computer.js +150 -3
  31. package/commands/dream.js +365 -0
  32. package/commands/drill.js +371 -0
  33. package/commands/engine.js +993 -32
  34. package/commands/experiments.js +28 -0
  35. package/commands/feedback.js +34 -12
  36. package/commands/fleet-report.js +206 -0
  37. package/commands/gm.js +23 -0
  38. package/commands/goal.js +247 -0
  39. package/commands/improve.js +642 -26
  40. package/commands/init.js +72 -44
  41. package/commands/interview.js +67 -1
  42. package/commands/land.js +152 -52
  43. package/commands/lifecycle.js +39 -3
  44. package/commands/log.js +84 -1
  45. package/commands/loops.js +220 -16
  46. package/commands/meet.js +220 -0
  47. package/commands/member.js +511 -34
  48. package/commands/mission.js +3144 -338
  49. package/commands/next.js +137 -0
  50. package/commands/now.js +220 -25
  51. package/commands/one-lap.js +776 -0
  52. package/commands/orb.js +314 -0
  53. package/commands/pack-craft.js +179 -0
  54. package/commands/pack.js +823 -0
  55. package/commands/play.js +3 -2
  56. package/commands/probe.js +30 -3
  57. package/commands/pulse.js +241 -46
  58. package/commands/push.js +260 -82
  59. package/commands/rainmaker.js +49 -0
  60. package/commands/report.js +415 -0
  61. package/commands/scout.js +147 -0
  62. package/commands/search.js +363 -0
  63. package/commands/skill.js +47 -3
  64. package/commands/slop.js +50 -2
  65. package/commands/soul.js +1 -1
  66. package/commands/stream.js +861 -0
  67. package/commands/study.js +693 -0
  68. package/commands/sync.js +67 -54
  69. package/commands/task.js +1346 -117
  70. package/commands/team.js +73 -0
  71. package/commands/verify.js +96 -0
  72. package/commands/watch.js +303 -0
  73. package/commands/wish.js +500 -0
  74. package/commands/workflow.js +11 -5
  75. package/commands/worktree.js +234 -13
  76. package/commands/xp.js +29 -11
  77. package/lib/auto-accept-certified.js +331 -34
  78. package/lib/autoland.js +319 -54
  79. package/lib/ax-auto-lane.js +96 -0
  80. package/lib/bench/context.js +147 -0
  81. package/lib/bench/engines.js +141 -0
  82. package/lib/bench/report.js +140 -0
  83. package/lib/bench/runner.js +512 -0
  84. package/lib/brief-ledger.js +350 -0
  85. package/lib/cloud-mission.js +259 -0
  86. package/lib/codex-flight.js +154 -0
  87. package/lib/default-runner.js +45 -0
  88. package/lib/default-verifier.js +70 -0
  89. package/lib/engine-registry.js +232 -0
  90. package/lib/experiments/daily.js +640 -0
  91. package/lib/fleet.js +2219 -67
  92. package/lib/improve-vitals-html.js +171 -0
  93. package/lib/known-commands.js +58 -0
  94. package/lib/loop-doctor.js +416 -0
  95. package/lib/member-switches.js +144 -0
  96. package/lib/mission-room.js +1 -0
  97. package/lib/mission-root.js +52 -0
  98. package/lib/next-moves.js +327 -10
  99. package/lib/one-lap-validator.js +60 -0
  100. package/lib/orb-context.js +477 -0
  101. package/lib/orb-scorecard.js +224 -0
  102. package/lib/policy-lessons.js +52 -1
  103. package/lib/pulse.js +277 -3
  104. package/lib/receipt-block.js +168 -0
  105. package/lib/receipt-evidence.js +65 -4
  106. package/lib/router-brain.js +352 -0
  107. package/lib/runner-command.js +10 -0
  108. package/lib/self-drive.js +258 -0
  109. package/lib/short-name.js +103 -0
  110. package/lib/spawn-env.js +18 -0
  111. package/lib/state-detection.js +56 -1
  112. package/lib/sync-status.js +59 -0
  113. package/lib/task-db.js +108 -29
  114. package/lib/task-proof.js +23 -1
  115. package/lib/team-presence.js +260 -0
  116. package/lib/tool-result-encode.js +7 -0
  117. package/lib/trust-tiers.js +90 -0
  118. package/lib/usage.js +107 -0
  119. package/lib/voice-gate.js +163 -0
  120. package/lib/wish-audit.js +1368 -0
  121. package/lib/wish-delegate.js +1840 -0
  122. package/lib/wish-design.js +110 -0
  123. package/lib/wish-stats.js +183 -0
  124. package/lib/wish-store.js +354 -0
  125. package/lib/zip.js +221 -0
  126. package/package.json +3 -1
  127. package/templates/loops/atris/loops/LOOPS.md +55 -0
  128. package/templates/loops/atris/loops/TICK.md +24 -0
  129. package/templates/loops/atris/loops/feedback.md +22 -0
  130. package/templates/loops/atris/loops/quality.md +22 -0
  131. package/templates/loops/atris/wiki/systems/loops.md +41 -0
  132. package/utils/api.js +5 -1
  133. package/utils/auth.js +57 -21
package/lib/autoland.js CHANGED
@@ -9,11 +9,43 @@ const fs = require('fs');
9
9
  const path = require('path');
10
10
  const os = require('os');
11
11
  const { spawnSync } = require('child_process');
12
+ const { computeTrustTier } = require('./trust-tiers');
12
13
 
13
14
  const DEFAULT_DIGEST_HOUR = 9;
14
15
  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+/;
16
+ const OPERATOR_WHY = /\b(sav(?:e|es|ing)|burn(?:s|ing)?|wast(?:e|es|ing)|cost(?:s|ing)?|prevent\w*|reduc\w*|risk|stop(?:s|ping)?|break(?:s|ing)?|fail(?:s|ing)?|slow(?:s|er)?|faster|cheaper|easier|clearer|safer|simpler|trust|revenue|instead|rather than|so(?: that)?|because)\b/i;
17
+ const AGENT_JARGON = /\b[a-z0-9]+(?:_[a-z0-9]+)+\b|\b[a-z]+(?:[A-Z][A-Za-z0-9]*)+\b|--[a-z][a-z-]*\b|\bCLI-\d+\b/;
18
+ const RESULT_ULID = /\b[0-9A-HJKMNP-TV-Z]{20,}\b/;
19
+ const RESULT_TICKET = /\b[A-Z]{2,4}-\d+\b/;
20
+ const RESULT_FILE_PATH = /\b(?:[\w.-]+\/)*[\w.-]+\.(?:js|md|py|json|ts)(?::\d+)?\b/i;
21
+ const RESULT_COMMAND = /--|->|\b(?:npm|node|grep|git)\s+/i;
22
+ const SHELL_COMMAND_FRAGMENT = /\b(?:npm|node|git|grep)\s+[^\s,;]+|->/i;
23
+ const TRAILING_FRAGMENT_WORDS = new Set([
24
+ 'after',
25
+ 'and',
26
+ 'as',
27
+ 'at',
28
+ 'because',
29
+ 'before',
30
+ 'by',
31
+ 'for',
32
+ 'from',
33
+ 'in',
34
+ 'into',
35
+ 'of',
36
+ 'on',
37
+ 'or',
38
+ 'over',
39
+ 'so',
40
+ 'than',
41
+ 'that',
42
+ 'the',
43
+ 'to',
44
+ 'under',
45
+ 'with',
46
+ 'without',
47
+ ]);
48
+ const COMPLETE_TRAILING_PHRASE = /\b(?:enough|what|which)\s+to\s+(?:act|focus|work)\s+on[.!?]$/i;
17
49
 
18
50
  function policyPath(root) {
19
51
  return path.join(root, '.atris', 'policy', 'autoland.json');
@@ -47,7 +79,13 @@ function inferOwner(root) {
47
79
  }
48
80
 
49
81
  function defaultPolicy(root) {
50
- return { enabled: true, enabled_by: inferOwner(root), enabled_at: null, default: true };
82
+ return {
83
+ enabled: true,
84
+ enabled_by: inferOwner(root),
85
+ enabled_at: null,
86
+ daily_experiment: true,
87
+ default: true,
88
+ };
51
89
  }
52
90
 
53
91
  function readPolicy(root) {
@@ -148,8 +186,10 @@ function waitingOnHuman(tasks, now = Date.now()) {
148
186
  .map((t) => ({
149
187
  ref: t.display_id || t.legacy_ref || t.id,
150
188
  title: String(t.title || '').slice(0, 200),
189
+ result: String(t.result || t.metadata?.result || '').slice(0, 220),
151
190
  tag: String(t.tag || ''),
152
191
  hours: waitingHours(t, now),
192
+ member: String(t.claimed_by || t.assigned_to || '').trim(),
153
193
  }))
154
194
  .sort((a, b) => b.hours - a.hours);
155
195
  }
@@ -163,8 +203,9 @@ function acceptedInLastDay(tasks, now = Date.now()) {
163
203
  const entry = {
164
204
  ref: t.display_id || t.legacy_ref || t.id,
165
205
  title: String(t.title || '').slice(0, 120),
206
+ result: String(t.result || t.metadata?.result || '').slice(0, 220),
166
207
  happened: String(t.review?.landing?.happened || t.metadata?.landing_happened || '').slice(0, 300),
167
- member: String(t.claimed_by || t.assigned_to || '').trim(),
208
+ member: String(t.claimed_by || t.metadata?.executed_by || t.assigned_to || '').trim(),
168
209
  };
169
210
  if (t.metadata?.auto_accepted_at) auto.push(entry);
170
211
  else human.push(entry);
@@ -176,6 +217,83 @@ function plur(n, word) {
176
217
  return `${n} ${word}${n === 1 ? '' : 's'}`;
177
218
  }
178
219
 
220
+ function pauseReasonDigestLabel(reason) {
221
+ if (reason === 'auth-required') return 'awaiting login';
222
+ if (reason === 'model-unavailable') return 'awaiting an available model';
223
+ if (reason === 'rate-limit-exceeded-wall') return 'waiting for the rate-limit reset';
224
+ if (reason === 'stale' || reason === 'paused-idle') return 'stale after the idle window';
225
+ return 'paused for operator action';
226
+ }
227
+
228
+ function normalizeReasonTally(value) {
229
+ const tally = {};
230
+ if (!value || typeof value !== 'object') return tally;
231
+ for (const [reason, count] of Object.entries(value)) {
232
+ const cleanReason = String(reason || '').trim();
233
+ const n = Number(count) || 0;
234
+ if (cleanReason && n > 0) tally[cleanReason] = (tally[cleanReason] || 0) + n;
235
+ }
236
+ return tally;
237
+ }
238
+
239
+ function heldReasonTally(janitor) {
240
+ const holds = janitor?.mission_holds && typeof janitor.mission_holds === 'object'
241
+ ? Object.values(janitor.mission_holds)
242
+ : [];
243
+ const tally = {};
244
+ for (const hold of holds) {
245
+ const reason = String(hold?.reason || '').trim();
246
+ if (reason) tally[reason] = (tally[reason] || 0) + 1;
247
+ }
248
+ if (holds.length > 0) return tally;
249
+ return normalizeReasonTally(janitor?.missions_held_reasons);
250
+ }
251
+
252
+ function pushJanitorMissionReasonLines(lines, janitor) {
253
+ const held = heldReasonTally(janitor);
254
+ for (const [reason, count] of Object.entries(held).sort()) {
255
+ lines.push(`held: ${plur(count, 'mission')} ${pauseReasonDigestLabel(reason)} (${reason})`);
256
+ }
257
+ const stopped = normalizeReasonTally(janitor?.mission_stop_reasons || janitor?.missions_stopped_reasons);
258
+ for (const [reason, count] of Object.entries(stopped).sort()) {
259
+ lines.push(`stopped: ${plur(count, 'mission')} ${pauseReasonDigestLabel(reason)} (${reason})`);
260
+ }
261
+ }
262
+
263
+ function pushLandingSweepLines(lines, landingSweep) {
264
+ if (!landingSweep || typeof landingSweep !== 'object') return;
265
+ const stale = Array.isArray(landingSweep.stale) ? landingSweep.stale : [];
266
+ const staleCount = Number(landingSweep.stale_count) || stale.length;
267
+ if (staleCount > 0) {
268
+ const oldest = stale[0];
269
+ const oldestText = oldest
270
+ ? `; oldest: ${oldest.name} (${Number(oldest.activityHours || oldest.ageHours || 0)}h)`
271
+ : '';
272
+ lines.push(`stuck in the air past 48h: ${plur(staleCount, 'piece')} need${staleCount === 1 ? 's' : ''} a human${oldestText}; inspect: atris land`);
273
+ }
274
+ const human = Array.isArray(landingSweep.human) ? landingSweep.human : [];
275
+ const humanCount = Number(landingSweep.human_count) || human.length;
276
+ if (humanCount > 0) {
277
+ const first = human[0] ? ` ${finishThought(human[0], 130)}` : '';
278
+ const more = humanCount > 1 ? `; ${humanCount - 1} more` : '';
279
+ lines.push(`cleanup needs a human:${first}${more}`);
280
+ }
281
+ }
282
+
283
+ function pushWaitingWishLines(lines, waitingWishes) {
284
+ const wishes = (Array.isArray(waitingWishes) ? waitingWishes : [])
285
+ .filter((wish) => wish && String(wish.text || '').trim());
286
+ if (!wishes.length) return;
287
+ lines.push(`${plur(wishes.length, 'wish')} waiting on you:`);
288
+ for (const wish of wishes.slice(0, 2)) {
289
+ const text = finishThought(String(wish.text || '').replace(/[.\s]+$/, ''), 90);
290
+ const need = cutAtWord(String(wish.need || 'answer the open question before it can start'), 120);
291
+ lines.push(`- ${text}: ${need}`);
292
+ }
293
+ const hidden = Math.max(0, wishes.length - 2);
294
+ if (hidden > 0) lines.push(`- ${plur(hidden, 'more wish')} waiting on an answer`);
295
+ }
296
+
179
297
  function cutAtWord(text, max) {
180
298
  const clean = String(text || '').replace(/\s+/g, ' ').trim();
181
299
  if (clean.length <= max) return clean;
@@ -184,19 +302,63 @@ function cutAtWord(text, max) {
184
302
  return `${cut.slice(0, lastSpace > max * 0.6 ? lastSpace : max)}...`;
185
303
  }
186
304
 
305
+ function stripDanglingFragment(text) {
306
+ let out = String(text || '').replace(/\s+/g, ' ').trim();
307
+ while (out.includes('(') && out.lastIndexOf('(') > out.lastIndexOf(')')) {
308
+ out = out.slice(0, out.lastIndexOf('(')).trim();
309
+ }
310
+ out = out.replace(/[,\s;:]+$/g, '').trim();
311
+ while (out) {
312
+ const withoutPunctuation = out.replace(/[.!?]+$/g, '').trim();
313
+ const word = (withoutPunctuation.match(/[A-Za-z]+$/) || [''])[0].toLowerCase();
314
+ if (!TRAILING_FRAGMENT_WORDS.has(word)) break;
315
+ out = withoutPunctuation.slice(0, Math.max(0, withoutPunctuation.length - word.length)).replace(/[,\s;:]+$/g, '').trim();
316
+ }
317
+ return out;
318
+ }
319
+
187
320
  // A line ends where a thought ends. Long lines close at their last complete
188
321
  // clause inside the budget and read whole: no ellipsis, nothing dangling.
189
322
  // Only when a line has no clause boundary at all does a word cut remain.
190
323
  function finishThought(text, max = 160) {
191
- const clean = String(text || '').replace(/\s+/g, ' ').trim().replace(/[,;:\s]+$/, '');
324
+ const clean = stripDanglingFragment(String(text || '').replace(/\s+/g, ' ').trim().replace(/[,;:\s]+$/, ''));
192
325
  if (clean.length <= max) return clean;
193
326
  const cut = clean.slice(0, max);
194
327
  const sentence = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf('! '), cut.lastIndexOf('? '));
195
- if (sentence > max * 0.3) return cut.slice(0, sentence + 1);
328
+ if (sentence > max * 0.3) return stripDanglingFragment(cut.slice(0, sentence + 1));
196
329
  const clause = Math.max(cut.lastIndexOf(', '), cut.lastIndexOf('; '), cut.lastIndexOf(' ('), cut.lastIndexOf(': '));
197
- if (clause > max * 0.45) return cut.slice(0, clause);
330
+ if (clause > max * 0.45) return stripDanglingFragment(cut.slice(0, clause));
198
331
  const space = cut.lastIndexOf(' ');
199
- return `${cut.slice(0, space > max * 0.6 ? space : max)}...`;
332
+ return stripDanglingFragment(cut.slice(0, space > max * 0.6 ? space : max));
333
+ }
334
+
335
+ function digestMoveLine(move) {
336
+ if (move.kind === 'mission_ready') {
337
+ const title = String(move.title || '').trim();
338
+ const boundary = title.search(/[:,]\s+/);
339
+ const clause = (boundary >= 0 ? title.slice(0, boundary) : title).trim();
340
+ const clauseWords = clause.split(/\s+/).filter(Boolean).length;
341
+ const afterColon = boundary >= 0 && title[boundary] === ':'
342
+ ? title.slice(boundary + 1).replace(/\([^)]*\)/g, '').trim()
343
+ : '';
344
+ const candidate = afterColon && clauseWords <= 5 ? finishThought(afterColon, 80) : clause;
345
+ const completeClause = candidate && candidate.length <= 80
346
+ ? candidate.charAt(0).toLowerCase() + candidate.slice(1).replace(/[.!?]+$/g, '')
347
+ : '';
348
+ const label = finishThought(completeClause || String(move.label || '').replace(/^#\d+\s+/, ''), 80);
349
+ return label
350
+ ? `review proof${move.owner ? ` from ${move.owner}` : ''} for ${label} so finished work can leave the queue`
351
+ : 'review the finished mission proof so accepted work can leave the queue';
352
+ }
353
+ return `${finishThought(move.title, 140)}${move.owner ? ` (best fit: ${move.owner})` : ''}`;
354
+ }
355
+
356
+ function historicalLandingText(text, max = 160) {
357
+ const clean = String(text || '')
358
+ .replace(/\s+/g, ' ')
359
+ .replace(/\bas exists and not empty\b/gi, 'as a saved file that exists and is not empty')
360
+ .trim();
361
+ return finishThought(clean, max);
200
362
  }
201
363
 
202
364
  function operatorReady(text) {
@@ -208,28 +370,77 @@ function hasAgentJargon(text) {
208
370
  return AGENT_JARGON.test(String(text || ''));
209
371
  }
210
372
 
373
+ function explainResult(text) {
374
+ const raw = String(text || '');
375
+ const value = raw.replace(/\s+/g, ' ').trim();
376
+ if (!value) return { ok: false, reason: 'result must say what someone can do now and why it matters.' };
377
+ if (raw.includes('\n') || raw.includes('\r')) return { ok: false, reason: 'result must be one sentence, not a paragraph.' };
378
+ if (value.length < 30) return { ok: false, reason: 'result is too short to explain what changed for a human.' };
379
+ if (value.length > 180) return { ok: false, reason: 'result must stay under 180 characters so it survives briefs.' };
380
+ if (!/[.!?]$/.test(value)) return { ok: false, reason: 'result must end as a complete sentence with a period, question mark, or exclamation point.' };
381
+ const sentenceStops = value.match(/[.!?]/g) || [];
382
+ if (sentenceStops.length !== 1) return { ok: false, reason: 'result must be exactly one complete sentence.' };
383
+ if (RESULT_ULID.test(value)) return { ok: false, reason: 'result should explain the gain, not include database ids.' };
384
+ if (RESULT_TICKET.test(value)) return { ok: false, reason: 'result should not include ticket ids.' };
385
+ if (RESULT_FILE_PATH.test(value)) return { ok: false, reason: 'result should not include file paths.' };
386
+ if (RESULT_COMMAND.test(value)) return { ok: false, reason: 'result should not include command syntax or flags.' };
387
+ const finalWord = (value.replace(/[.!?]+$/g, '').match(/[A-Za-z]+$/) || [''])[0].toLowerCase();
388
+ if (TRAILING_FRAGMENT_WORDS.has(finalWord) && !COMPLETE_TRAILING_PHRASE.test(value)) {
389
+ return { ok: false, reason: 'result ends mid-thought; finish the sentence with the actual human gain.' };
390
+ }
391
+ if (!operatorReady(value)) {
392
+ return { ok: false, reason: 'result needs a plain operator why: say who can do what now, and why it saves time, reduces risk, increases trust, or helps users.' };
393
+ }
394
+ return { ok: true, reason: null };
395
+ }
396
+
211
397
  // One line per piece, in the work's own words: the landing sentence beats
212
398
  // the task title, because titles are written for the queue, not for a text.
213
399
  // The cut is generous on purpose: a specific sentence the reader trusts
214
400
  // (what happened + how big + how we know) beats a short one they must chase.
215
401
  function digestLine(item) {
402
+ const result = String(item.result || '').replace(/^completed:\s*/i, '').split(/(?<=[.!?])\s+/)[0] || '';
216
403
  const happened = String(item.happened || '').replace(/^completed:\s*/i, '').split(/(?<=[.!?])\s+/)[0] || '';
217
- const title = String(item.title || '').replace(/^Mission XP:\s*/i, '');
218
- const source = happened.length >= 12 ? happened : title;
404
+ const title = clarify(String(item.title || '').replace(/^Mission XP:\s*/i, ''));
405
+ const source = result.length >= 12 ? result : (happened.length >= 12 && operatorReady(happened) ? happened : (operatorReady(title) ? title : ''));
406
+ if (!source) return '';
219
407
  const line = finishThought(source.replace(/[.\s]+$/, ''), 160);
408
+ if (!operatorReady(line) && source === result && happened.length >= 12 && operatorReady(happened)) {
409
+ const fallback = finishThought(happened.replace(/[.\s]+$/, ''), 160);
410
+ if (operatorReady(fallback)) return fallback.charAt(0).toLowerCase() + fallback.slice(1);
411
+ }
220
412
  return line.charAt(0).toLowerCase() + line.slice(1);
221
413
  }
222
414
 
223
- // A rough sentence still beats a count: when a landing sentence carries agent
224
- // vocabulary, strip what a reader can't use (flag dashes, ids, underscores)
225
- // and show it anyway. Content always ships; the gate only changes how it reads.
226
- function dejargon(line) {
227
- return String(line || '')
228
- .replace(/--([a-z][a-z-]*)/g, '$1')
229
- .replace(/\bCLI-\d+\b/g, '')
230
- .replace(/([a-z0-9])_([a-z0-9])/gi, '$1 $2')
231
- .replace(/\s+/g, ' ')
232
- .trim();
415
+ function unexplainedSummaryRows(items, label) {
416
+ const count = (items || []).length;
417
+ if (count === 0) return [];
418
+ if (label === 'landed') {
419
+ return [`${plur(count, 'landed result')} stayed hidden because ${count === 1 ? 'its outcome was' : 'their outcomes were'} unclear`];
420
+ }
421
+ if (label === 'in review') {
422
+ return [`${plur(count, 'review')} stayed hidden because ${count === 1 ? 'its next action was' : 'their next actions were'} unclear`];
423
+ }
424
+ return [`${plur(count, label)} stayed hidden because ${count === 1 ? 'its meaning was' : 'their meanings were'} unclear`];
425
+ }
426
+
427
+ function missionDigestLine(mission, max = 160) {
428
+ const visibleGoal = mission?.visible_goal?.desired_objective
429
+ || mission?.goal?.visible_goal?.desired_objective
430
+ || (typeof mission?.visible_goal === 'string' ? mission.visible_goal : '');
431
+ const objective = visibleGoal || mission?.objective || '';
432
+ return finishThought(clarify(objective, max), max);
433
+ }
434
+
435
+ // Some older surfaces still need a readable fallback for rough text, but the
436
+ // digest paths above now hide agent-speak until a result sentence exists.
437
+ function dejargon(line, options = {}) {
438
+ let value = String(line || '')
439
+ .replace(/--([a-z][a-z-]*)/g, (_match, flag) => flag.replace(/-/g, ' '))
440
+ .replace(/\b[a-z0-9]+(?:_[a-z0-9]+)+\b/gi, match => match.replace(/_/g, ' '))
441
+ .replace(/\b[a-z]+(?:[A-Z][A-Za-z0-9]*)+\b/g, match => match.replace(/([a-z0-9])([A-Z])/g, '$1 $2'));
442
+ if (options.preserveTickets !== true) value = value.replace(/\bCLI-\d+\b/g, '');
443
+ return value.replace(/\s+/g, ' ').trim();
233
444
  }
234
445
 
235
446
  // The clarity gate in one call: strip what an operator can't act on (flag
@@ -248,16 +459,33 @@ function isCleanStop(item) {
248
459
  || /^(Mission XP:\s*)?Decide and start the next useful mission/i.test(String(item.title || ''));
249
460
  }
250
461
 
462
+ function hiddenMoveText(count, { more = false } = {}) {
463
+ const label = plur(count, `${more ? 'more queued ' : 'queued '}move`);
464
+ return count === 1
465
+ ? `${label} stays hidden until its owner writes a clear next action`
466
+ : `${label} stay hidden until their owners write clear next actions`;
467
+ }
468
+
469
+ function memberTrustLabel(member, root) {
470
+ if (!member) return '';
471
+ const tier = computeTrustTier(member, root);
472
+ if (tier === 'trusted') return ` (${member})`;
473
+ if (tier === 'standard') return ` (${member}, newer, spot-checked)`;
474
+ return ` (${member}, needs review)`;
475
+ }
476
+
251
477
  // Short enough for one iMessage, plain enough to read walking to the car.
252
478
  // Every line answers: what happened, how big, how we know. It names the work
253
479
  // because "7 things landed" tells the score, not the story — and it always
254
480
  // ends with what's waiting and what to do next, because those are the only
255
481
  // lines that ask anything of the reader.
256
- function composeDigest({ accepted, waiting, landed, project, nextMoves = [], acceptAll = false, reapError = null, janitor = null }) {
482
+ function composeDigest({ accepted, waiting, protectedWaiting = [], waitingWishes = [], landed, project, nextMoves = [], acceptAll = false, reapError = null, janitor = null, landingSweep = null, fullStory = false, root = process.cwd() }) {
257
483
  const lines = [];
258
- lines.push(`atris ${project}: yesterday`);
484
+ lines.push(`atris ${project}: last 24 hours`);
259
485
  const autoCount = accepted.auto.length;
260
486
  const humanCount = accepted.human.length;
487
+ const wishWaits = (Array.isArray(waitingWishes) ? waitingWishes : [])
488
+ .filter((wish) => wish && String(wish.text || '').trim());
261
489
  if (autoCount > 0) {
262
490
  // Three results, air between them, the rest on ask: the whole message
263
491
  // fits a laptop screen with no scrolling. Reading it is one glance.
@@ -265,46 +493,66 @@ function composeDigest({ accepted, waiting, landed, project, nextMoves = [], acc
265
493
  const work = accepted.auto
266
494
  .filter((item) => !isCleanStop(item))
267
495
  .map((item) => ({ item, line: digestLine(item) }));
268
- // Operator-ready sentences lead; rough ones get de-jargoned and still
269
- // shown. An empty report is the one thing a report must never be.
270
- const ready = work.filter(({ line }) => operatorReady(line));
271
- const rough = work.filter(({ line }) => !operatorReady(line))
272
- .map(({ item, line }) => ({ item, line: dejargon(line) }));
273
- const visibleWork = [...ready, ...rough].slice(0, 3);
274
- const held = Math.max(0, work.length - visibleWork.length) + (stops.length > 0 ? 1 : 0);
496
+ const ready = work.filter(({ line }) => line && operatorReady(line));
497
+ const unexplainedWork = work.filter(({ line }) => !line || !operatorReady(line));
498
+ const visibleWork = ready.slice(0, 3);
499
+ const held = Math.max(0, ready.length - visibleWork.length) + (stops.length > 0 ? 1 : 0);
275
500
  if (visibleWork.length > 0) {
276
501
  // The header states the actual bar: under accept-all the claim
277
502
  // "verified twice" would be a lie the operator builds trust on.
278
503
  lines.push(acceptAll
279
- ? 'landed on their own (protected lanes held back for you):'
280
- : 'landed on their own (verified twice, proof on file):');
504
+ ? `${autoCount} landed; ${plur(ready.length, 'clear result')}, protected lanes held back for you:`
505
+ : `${autoCount} landed; ${plur(ready.length, 'clear result')}, each verified twice with proof on file:`);
281
506
  for (const { item, line } of visibleWork) {
282
507
  lines.push('');
283
- lines.push(`- ${line || item.ref}${item.member ? ` (${item.member})` : ''}`);
508
+ lines.push(`- ${line || item.ref}${memberTrustLabel(item.member, root)}`);
284
509
  }
285
510
  lines.push('');
286
- if (held > 0) lines.push(`${plur(held, 'more result')} when you want them: atris autoland digest`);
511
+ if (held > 0) {
512
+ lines.push(fullStory
513
+ ? `${plur(held, 'more result')} in the full story below`
514
+ : `${plur(held, 'more result')} when you want them: atris autoland digest`);
515
+ }
516
+ } else {
517
+ lines.push(`${autoCount} landed with proof on file; no clear result was ready to show:`);
287
518
  }
519
+ for (const row of unexplainedSummaryRows(unexplainedWork, 'landed')) lines.push(row);
288
520
  }
289
521
  if (autoCount + humanCount === 0) lines.push('nothing finished in the last day');
290
522
  if (waiting.length > 0) {
291
523
  // The only lines that ask the reader to act, so they name what's asking.
292
- lines.push(`waiting on you (approve or bounce: atris task reviews):`);
293
- for (const item of waiting.slice(0, 2)) {
294
- lines.push(`- ${finishThought(dejargon(item.title), 140)}${item.hours >= 12 ? ` (${item.hours}h)` : ''}`);
524
+ const waitingWork = waiting.map((item) => ({ item, line: digestLine(item) }));
525
+ const explainedWaiting = waitingWork.filter(({ line }) => line && operatorReady(line));
526
+ const unexplainedWaiting = waitingWork.filter(({ line }) => !line || !operatorReady(line));
527
+ lines.push(`task approvals waiting on you (${explainedWaiting.length} clear next actions/${waiting.length} total; approve or bounce: atris task reviews):`);
528
+ for (const { item, line } of explainedWaiting.slice(0, 2)) {
529
+ lines.push(`- ${finishThought(line, 140)}${item.hours >= 12 ? ` (${item.hours}h)` : ''}`);
295
530
  }
296
- if (waiting.length > 2) lines.push(`- and ${waiting.length - 2} more`);
297
- } else {
298
- lines.push('waiting on you: nothing');
531
+ for (const row of unexplainedSummaryRows(unexplainedWaiting, 'in review')) lines.push(`- ${row}`);
532
+ const hiddenWaiting = Math.max(0, explainedWaiting.length - 2);
533
+ if (hiddenWaiting > 0) lines.push(`- ${hiddenWaiting} more clear review actions`);
534
+ }
535
+ const protectedCount = Array.isArray(protectedWaiting) ? protectedWaiting.length : 0;
536
+ if (protectedCount > 0) {
537
+ lines.push(`${plur(protectedCount, 'protected review')} waiting on you; inspect: atris task reviews`);
538
+ } else if (waiting.length === 0 && wishWaits.length === 0) {
539
+ lines.push('task approvals waiting on you: nothing');
299
540
  }
541
+ pushWaitingWishLines(lines, wishWaits);
300
542
  if (landed && landed.branches > 0) {
301
- lines.push(`in the air: ${plur(landed.branches, 'piece')}${landed.due > 0 ? `, ${landed.due} overdue` : ', all fresh'} (atris land)`);
543
+ const staleCount = Number(landingSweep?.stale_count) || (Array.isArray(landingSweep?.stale) ? landingSweep.stale.length : 0);
544
+ const freshness = landed.due > 0
545
+ ? `, ${landed.due} overdue`
546
+ : (staleCount > 0 ? `, ${staleCount} stale` : ', all fresh');
547
+ lines.push(`in the air: ${plur(landed.branches, 'piece')}${freshness} (atris land)`);
302
548
  }
303
- if (reapError) lines.push(`cleanup trouble: the daily sweep failed (${reapError}) — run: atris land --reap`);
549
+ if (reapError) lines.push(`cleanup trouble: the landing sweep failed (${reapError}) - run: atris land --reap`);
550
+ pushLandingSweepLines(lines, landingSweep);
304
551
  // The janitor's day in one line: stale paused missions stopped and merged
305
552
  // worktrees cleared since the last digest. Silence when it did nothing.
306
553
  const missionsStopped = Number(janitor?.missions_stopped) || 0;
307
554
  const worktreesReaped = Number(janitor?.worktrees_reaped) || 0;
555
+ if (janitor) pushJanitorMissionReasonLines(lines, janitor);
308
556
  if (missionsStopped > 0 || worktreesReaped > 0) {
309
557
  lines.push(`tidied up: ${plur(missionsStopped, 'stale mission')} stopped, ${plur(worktreesReaped, 'merged worktree')} cleared`);
310
558
  }
@@ -320,13 +568,13 @@ function composeDigest({ accepted, waiting, landed, project, nextMoves = [], acc
320
568
  if (moves.length > 0) {
321
569
  lines.push('next, if you agree:');
322
570
  for (const move of moves) {
323
- lines.push(`- ${finishThought(move.title, 140)}${move.owner ? ` (best fit: ${move.owner})` : ''}`);
571
+ lines.push(`- ${digestMoveLine(move)}`);
324
572
  }
325
- if (unexplained > 0) lines.push(`- ${plur(unexplained, 'more idea')} that can't explain themselves yet (atris now)`);
573
+ if (unexplained > 0) lines.push(`- ${hiddenMoveText(unexplained, { more: true })}`);
326
574
  } else if (unexplained > 0) {
327
- lines.push(`${plur(unexplained, 'idea')} in the queue can't explain themselves yet (atris now)`);
575
+ lines.push(hiddenMoveText(unexplained));
328
576
  }
329
- if (!lines.some((line) => line.includes('atris autoland digest'))) {
577
+ if (!fullStory && !lines.some((line) => line.includes('atris autoland digest'))) {
330
578
  lines.push('the full story: atris autoland digest');
331
579
  }
332
580
  return lines.join('\n');
@@ -335,7 +583,7 @@ function composeDigest({ accepted, waiting, landed, project, nextMoves = [], acc
335
583
  // The moment something lands, the operator hears it in one glance: the
336
584
  // landing sentence each piece wrote for a day-one PM at finish time, its
337
585
  // author, nothing else. Rough sentences get de-jargoned, never hidden.
338
- function composeLiveUpdate({ landedRefs, tasks, project }) {
586
+ function composeLiveUpdate({ landedRefs, tasks, project, root = process.cwd() }) {
339
587
  const byRef = new Map((tasks || []).map((t) => [String(t.display_id || t.legacy_ref || t.id), t]));
340
588
  const items = (landedRefs || []).map((ref) => {
341
589
  const t = byRef.get(String(ref));
@@ -343,21 +591,27 @@ function composeLiveUpdate({ landedRefs, tasks, project }) {
343
591
  return {
344
592
  ref: String(ref),
345
593
  title: String(t.title || '').slice(0, 200),
594
+ result: String(t.result || t.metadata?.result || '').slice(0, 220),
346
595
  happened: String(t.review?.landing?.happened || t.metadata?.landing_happened || '').slice(0, 300),
347
- member: String(t.claimed_by || t.assigned_to || '').trim(),
596
+ member: String(t.claimed_by || t.metadata?.executed_by || t.assigned_to || '').trim(),
348
597
  };
349
598
  }).filter((item) => !isCleanStop(item));
350
599
  if (!items.length) return '';
351
600
  const lines = [`atris ${project}: just landed`];
352
- for (const item of items.slice(0, 3)) {
353
- const raw = digestLine(item);
354
- const line = operatorReady(raw) ? raw : dejargon(raw);
601
+ const work = items.map((item) => ({ item, line: digestLine(item) }));
602
+ const ready = work.filter(({ line }) => line && operatorReady(line));
603
+ const unexplained = work.filter(({ line }) => !line || !operatorReady(line));
604
+ for (const { item, line } of ready.slice(0, 3)) {
605
+ lines.push('');
606
+ lines.push(`- ${finishThought(line, 160)}${memberTrustLabel(item.member, root)}`);
607
+ }
608
+ for (const row of unexplainedSummaryRows(unexplained, 'landed')) {
355
609
  lines.push('');
356
- lines.push(`- ${finishThought(line, 160)}${item.member ? ` (${item.member})` : ''}`);
610
+ lines.push(row);
357
611
  }
358
- if (items.length > 3) {
612
+ if (ready.length > 3) {
359
613
  lines.push('');
360
- lines.push(`and ${plur(items.length - 3, 'more piece')}: atris autoland digest`);
614
+ lines.push(`and ${plur(ready.length - 3, 'more piece')}: atris autoland digest`);
361
615
  }
362
616
  return lines.join('\n');
363
617
  }
@@ -366,7 +620,7 @@ function composeAlarm({ waiting, project, alarmHours }) {
366
620
  const worst = waiting[0];
367
621
  return [
368
622
  `atris ${project}: ${plur(waiting.length, 'piece')} of finished work ${waiting.length === 1 ? 'has' : 'have'} been waiting on you for over ${alarmHours}h`,
369
- `oldest: ${worst.ref} (${worst.hours}h) — ${worst.title}`,
623
+ `oldest: ${worst.ref} (${worst.hours}h), ${worst.title}`,
370
624
  'approve or bounce: atris task reviews',
371
625
  ].join('\n');
372
626
  }
@@ -409,11 +663,22 @@ module.exports = {
409
663
  cronInstalled,
410
664
  cronMarker,
411
665
  dueForAlarm,
666
+ digestLine,
667
+ dejargon,
668
+ explainResult,
412
669
  installCron,
413
670
  liveAcceptAuthorization,
414
671
  markAlerted,
672
+ missionDigestLine,
415
673
  operatorReady,
416
674
  hasAgentJargon,
675
+ voicePatterns: Object.freeze({
676
+ agentJargon: AGENT_JARGON,
677
+ filePath: RESULT_FILE_PATH,
678
+ rawUlid: RESULT_ULID,
679
+ shellCommand: SHELL_COMMAND_FRAGMENT,
680
+ }),
681
+ historicalLandingText,
417
682
  policyPath,
418
683
  readPolicy,
419
684
  readState,
@@ -0,0 +1,96 @@
1
+ 'use strict';
2
+
3
+ const SHORT_LOOKUP_MAX_CHARS = 140;
4
+ const LONG_INPUT_MIN_CHARS = 1200;
5
+ const TINY_MESSAGE_MAX_CHARS = 20;
6
+ const GREETING_MAX_CHARS = 40;
7
+ const DECISION_FACTOR_MIN_COMMAS = 3;
8
+ const TRADEOFF_DEPTH_MIN_CHARS = 100;
9
+
10
+ // Anchored to the start: a lookup word mid-sentence is usually a subordinate
11
+ // clause (my fan spins when i run tests), not a lookup.
12
+ const LOOKUP_START_RE = /^(?:(?:please|pls|hey|hi|yo)[,: ]+)?(?:whats|whos|wheres|what|who|when|where|list|show|define)\b/i;
13
+ // Words that turn a lookup-shaped message into a judgment call: never fast.
14
+ const JUDGMENT_WORD_RE = /\b(?:should|tradeoffs?|better|best|recommend|worth|good way)\b/i;
15
+ const HEAVY_REASONING_RE = /\b(?:design(?:s|ed|ing)?|architect(?:s|ed|ing)?|prov(?:e|es|ed|ing)|analy(?:ze|zes|zed|zing|se|ses|sed|sing)|compar(?:e|es|ed|ing)|plan(?:s|ned|ning)?|sketch(?:es|ed|ing)?)\b|\bwalk(?:s|ed|ing)? (?:me |us )?through\b/i;
16
+ const DECISION_PHRASE_RE = /\b(?:should|whether|decide|deciding)\b/i;
17
+ const ARGUE_BOTH_RE = /\bargue\b|\bboth sides\b|\bsteelman\b/i;
18
+ const CODE_EDIT_RE = /\b(?:fix(?:es|ed|ing)?|refactor(?:s|ed|ing)?|renam(?:e|es|ed|ing)|debug(?:s|ged|ging)?)\b/i;
19
+ const CODE_DIAGNOSIS_RE = /\bwhy\b|\bwrong\b|\bfail(?:s|ed|ing)?\b|\bresolv(?:e|es|ed|ing)?\b|\bbroken\b|\bnot work/i;
20
+ const CODE_CONTEXT_RE = /```|(?:^|\n)\s*(?:traceback\b|(?:[a-z]+)?(?:error|exception):|at\s+\S+\s+\()/im;
21
+ const ERROR_EXPLAIN_RE = /\bexplain (?:this|the) error\b|\bwhat does (?:this|the) error mean\b/i;
22
+ // Yes/no capability checks are lookups even without a lookup keyword.
23
+ const YESNO_LOOKUP_RE = /^(?:does|is|are|can|do)\b/i;
24
+ const YESNO_LOOKUP_MAX_CHARS = 80;
25
+ // Troubleshooting language means advice, not recall: keep it out of fast.
26
+ const TROUBLE_RE = /\bfail(?:s|ed|ing)?\b|\bcrash(?:es|ed|ing)?\b|\bbroken\b|\bnot working\b/i;
27
+ const WEIGHING_WORD_RE = /\b(?:consider|weigh|rank|estimate)\b/i;
28
+ const WEIGHING_FACTOR_MIN_COMMAS = 2;
29
+ const TRANSFORM_START_RE = /^(?:convert|summarize|translate)\b/i;
30
+ const GREETING_RE = /^(?:hey|hi|hello|yo|thanks|thank you|ok|okay|cool|nice|got it|great|perfect|yep|no worries)\b/i;
31
+
32
+ function threshold(value, fallback) {
33
+ return Number.isFinite(value) && value >= 0 ? value : fallback;
34
+ }
35
+
36
+ function pickLane(message, opts = {}) {
37
+ const text = String(message || '').trim();
38
+ const shortLookupMaxChars = threshold(opts.shortLookupMaxChars, SHORT_LOOKUP_MAX_CHARS);
39
+ const longInputMinChars = threshold(opts.longInputMinChars, LONG_INPUT_MIN_CHARS);
40
+ const questionCount = (text.match(/\?/g) || []).length;
41
+ const commaCount = (text.match(/,/g) || []).length;
42
+ const hasCodeContext = CODE_CONTEXT_RE.test(text);
43
+
44
+ if (text.length > longInputMinChars) {
45
+ return { lane: 'max', reason: 'max fits this long input.' };
46
+ }
47
+ if (questionCount > 1) {
48
+ return { lane: 'max', reason: 'max fits a request with multiple questions.' };
49
+ }
50
+ if (ARGUE_BOTH_RE.test(text)
51
+ || (DECISION_PHRASE_RE.test(text) && commaCount >= DECISION_FACTOR_MIN_COMMAS)
52
+ || (WEIGHING_WORD_RE.test(text) && commaCount >= WEIGHING_FACTOR_MIN_COMMAS)) {
53
+ return { lane: 'max', reason: 'max fits a decision weighing several factors.' };
54
+ }
55
+ if (HEAVY_REASONING_RE.test(text)) {
56
+ return { lane: 'max', reason: 'max fits this reasoning-heavy request.' };
57
+ }
58
+ if (/\btrade[- ]?offs?\b/i.test(text) && text.length > TRADEOFF_DEPTH_MIN_CHARS) {
59
+ return { lane: 'max', reason: 'max fits this tradeoff analysis.' };
60
+ }
61
+ if (hasCodeContext && (CODE_EDIT_RE.test(text) || CODE_DIAGNOSIS_RE.test(text))) {
62
+ return { lane: 'code-fast', reason: 'code-fast fits this bounded code task.' };
63
+ }
64
+ if (ERROR_EXPLAIN_RE.test(text) && !hasCodeContext) {
65
+ return { lane: 'fast', reason: 'fast fits a plain error explanation.' };
66
+ }
67
+ if (TRANSFORM_START_RE.test(text) && text.length <= shortLookupMaxChars) {
68
+ return { lane: 'fast', reason: 'fast fits this small transform.' };
69
+ }
70
+ if (GREETING_RE.test(text) && text.length <= GREETING_MAX_CHARS) {
71
+ return { lane: 'fast', reason: 'fast fits a quick reply.' };
72
+ }
73
+ if (text.length > 0 && text.length <= TINY_MESSAGE_MAX_CHARS && !CODE_EDIT_RE.test(text)) {
74
+ return { lane: 'fast', reason: 'fast fits this tiny message.' };
75
+ }
76
+ if (text.length <= YESNO_LOOKUP_MAX_CHARS
77
+ && YESNO_LOOKUP_RE.test(text)
78
+ && !JUDGMENT_WORD_RE.test(text)
79
+ && !TROUBLE_RE.test(text)) {
80
+ return { lane: 'fast', reason: 'fast fits this yes or no lookup.' };
81
+ }
82
+ if (text.length <= shortLookupMaxChars
83
+ && LOOKUP_START_RE.test(text)
84
+ && !JUDGMENT_WORD_RE.test(text)
85
+ && !TROUBLE_RE.test(text)) {
86
+ return { lane: 'fast', reason: 'fast fits this short factual lookup.' };
87
+ }
88
+ return { lane: 'pro', reason: 'pro fits this general request.' };
89
+ }
90
+
91
+ module.exports = {
92
+ LONG_INPUT_MIN_CHARS,
93
+ SHORT_LOOKUP_MAX_CHARS,
94
+ TINY_MESSAGE_MAX_CHARS,
95
+ pickLane,
96
+ };