atris 3.34.0 → 3.36.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 (163) hide show
  1. package/AGENTS.md +35 -0
  2. package/FOR_AGENTS.md +5 -3
  3. package/README.md +5 -3
  4. package/atris/GETTING_STARTED.md +1 -1
  5. package/atris/atris.md +3 -0
  6. package/atris/policies/day-loop-voice.md +102 -0
  7. package/atris/policies/outbound-artifact-gate.md +2 -0
  8. package/atris/skills/design/SKILL.md +56 -32
  9. package/atris/skills/endgame/SKILL.md +12 -6
  10. package/atris/skills/engines/SKILL.md +35 -8
  11. package/atris/skills/fable-method/SKILL.md +66 -0
  12. package/atris/skills/improve/SKILL.md +65 -45
  13. package/atris/skills/render-cli/SKILL.md +88 -0
  14. package/atris/skills/youtube/SKILL.md +10 -1
  15. package/atris.md +6 -2
  16. package/ax +617 -31
  17. package/bin/atris.js +719 -266
  18. package/commands/activate.js +194 -88
  19. package/commands/aeo.js +52 -0
  20. package/commands/agents.js +166 -0
  21. package/commands/autoland.js +718 -72
  22. package/commands/autopilot-front.js +20 -2
  23. package/commands/autopilot.js +118 -2
  24. package/commands/avail.js +407 -0
  25. package/commands/bench.js +188 -0
  26. package/commands/brain.js +3 -0
  27. package/commands/brief.js +651 -0
  28. package/commands/business-sync.js +192 -6
  29. package/commands/business.js +91 -8
  30. package/commands/clean.js +50 -24
  31. package/commands/close.js +1083 -0
  32. package/commands/cloud.js +245 -0
  33. package/commands/codex-goal.js +26 -2
  34. package/commands/compile.js +292 -1
  35. package/commands/computer.js +150 -3
  36. package/commands/dream.js +365 -0
  37. package/commands/drill.js +371 -0
  38. package/commands/drive.js +187 -0
  39. package/commands/engine.js +1061 -29
  40. package/commands/experiments.js +28 -0
  41. package/commands/feed.js +202 -0
  42. package/commands/feedback.js +34 -12
  43. package/commands/fleet-report.js +206 -0
  44. package/commands/github.js +38 -0
  45. package/commands/gm.js +285 -3
  46. package/commands/goal.js +247 -0
  47. package/commands/improve.js +642 -26
  48. package/commands/init.js +83 -43
  49. package/commands/integrations.js +39 -11
  50. package/commands/interview.js +209 -0
  51. package/commands/land.js +253 -52
  52. package/commands/lesson.js +112 -1
  53. package/commands/lifecycle.js +39 -3
  54. package/commands/linear.js +38 -0
  55. package/commands/log.js +84 -1
  56. package/commands/loops.js +220 -16
  57. package/commands/meet.js +220 -0
  58. package/commands/member.js +899 -66
  59. package/commands/mission.js +3512 -332
  60. package/commands/next.js +137 -0
  61. package/commands/now.js +240 -21
  62. package/commands/one-lap.js +776 -0
  63. package/commands/orb.js +314 -0
  64. package/commands/pack-craft.js +179 -0
  65. package/commands/pack.js +823 -0
  66. package/commands/play.js +3 -2
  67. package/commands/probe.js +30 -3
  68. package/commands/pulse.js +241 -46
  69. package/commands/push.js +260 -82
  70. package/commands/radar.js +259 -14
  71. package/commands/rainmaker.js +49 -0
  72. package/commands/report.js +415 -0
  73. package/commands/scout.js +147 -0
  74. package/commands/search.js +363 -0
  75. package/commands/serve.js +54 -0
  76. package/commands/skill.js +47 -3
  77. package/commands/slop.js +50 -2
  78. package/commands/soul.js +1 -1
  79. package/commands/status.js +50 -5
  80. package/commands/stream.js +861 -0
  81. package/commands/stripe.js +38 -0
  82. package/commands/study.js +693 -0
  83. package/commands/supabase.js +39 -0
  84. package/commands/sync.js +67 -54
  85. package/commands/task.js +2275 -182
  86. package/commands/team.js +73 -0
  87. package/commands/truth.js +29 -3
  88. package/commands/unknowns.js +627 -0
  89. package/commands/update.js +44 -0
  90. package/commands/vercel.js +38 -0
  91. package/commands/verify.js +96 -0
  92. package/commands/watch.js +303 -0
  93. package/commands/wish.js +500 -0
  94. package/commands/workflow.js +11 -5
  95. package/commands/worktree.js +299 -20
  96. package/commands/write.js +399 -0
  97. package/commands/xp.js +29 -11
  98. package/lib/auto-accept-certified.js +391 -43
  99. package/lib/autoland.js +353 -52
  100. package/lib/ax-auto-lane.js +79 -0
  101. package/lib/bench/context.js +147 -0
  102. package/lib/bench/engines.js +141 -0
  103. package/lib/bench/report.js +140 -0
  104. package/lib/bench/runner.js +512 -0
  105. package/lib/brief-ledger.js +350 -0
  106. package/lib/cloud-mission.js +259 -0
  107. package/lib/codex-flight.js +154 -0
  108. package/lib/default-runner.js +45 -0
  109. package/lib/default-verifier.js +70 -0
  110. package/lib/engine-registry.js +232 -0
  111. package/lib/experiments/daily.js +640 -0
  112. package/lib/fleet.js +2431 -38
  113. package/lib/improve-vitals-html.js +171 -0
  114. package/lib/known-commands.js +58 -0
  115. package/lib/loop-doctor.js +416 -0
  116. package/lib/member-switches.js +144 -0
  117. package/lib/memory-view.js +14 -5
  118. package/lib/mission-room.js +1 -0
  119. package/lib/mission-root.js +52 -0
  120. package/lib/mission-runtime-loop.js +7 -0
  121. package/lib/next-moves.js +327 -10
  122. package/lib/official-cli-integration.js +174 -0
  123. package/lib/one-lap-validator.js +60 -0
  124. package/lib/orb-context.js +477 -0
  125. package/lib/orb-scorecard.js +224 -0
  126. package/lib/outbound-send-gate.js +165 -0
  127. package/lib/permission-grants.js +293 -0
  128. package/lib/policy-lessons.js +52 -1
  129. package/lib/pulse.js +277 -3
  130. package/lib/receipt-block.js +168 -0
  131. package/lib/receipt-evidence.js +65 -4
  132. package/lib/review-integrity.js +147 -0
  133. package/lib/router-brain.js +352 -0
  134. package/lib/runner-command.js +33 -0
  135. package/lib/self-drive.js +258 -0
  136. package/lib/short-name.js +103 -0
  137. package/lib/spawn-env.js +18 -0
  138. package/lib/state-detection.js +56 -1
  139. package/lib/sync-status.js +59 -0
  140. package/lib/task-db.js +319 -27
  141. package/lib/task-proof.js +43 -1
  142. package/lib/task-receipt.js +93 -0
  143. package/lib/team-presence.js +260 -0
  144. package/lib/tool-result-encode.js +7 -0
  145. package/lib/trust-tiers.js +90 -0
  146. package/lib/usage.js +107 -0
  147. package/lib/voice-gate.js +163 -0
  148. package/lib/wish-audit.js +1368 -0
  149. package/lib/wish-delegate.js +1840 -0
  150. package/lib/wish-design.js +110 -0
  151. package/lib/wish-stats.js +183 -0
  152. package/lib/wish-store.js +354 -0
  153. package/lib/zip.js +221 -0
  154. package/package.json +3 -1
  155. package/templates/loops/atris/loops/LOOPS.md +55 -0
  156. package/templates/loops/atris/loops/TICK.md +24 -0
  157. package/templates/loops/atris/loops/feedback.md +22 -0
  158. package/templates/loops/atris/loops/quality.md +22 -0
  159. package/templates/loops/atris/wiki/systems/loops.md +41 -0
  160. package/utils/api.js +5 -1
  161. package/utils/auth.js +57 -21
  162. package/utils/update-check.js +27 -6
  163. package/atris/learnings.jsonl +0 -1
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');
@@ -36,9 +68,29 @@ function writeJson(file, value) {
36
68
  fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
37
69
  }
38
70
 
71
+ // Default is ON: agents are good enough that a growing review queue is
72
+ // latency, not safety — the denied tags keep the irreversible lanes human.
73
+ // A missing policy file means "on, owner inferred"; only an explicit
74
+ // `atris autoland off` (enabled: false) turns it off.
75
+ function inferOwner(root) {
76
+ const git = spawnSync('git', ['config', 'user.name'], { cwd: root, encoding: 'utf8' });
77
+ const name = git.status === 0 ? String(git.stdout || '').trim() : '';
78
+ return name || os.userInfo().username;
79
+ }
80
+
81
+ function defaultPolicy(root) {
82
+ return {
83
+ enabled: true,
84
+ enabled_by: inferOwner(root),
85
+ enabled_at: null,
86
+ daily_experiment: true,
87
+ default: true,
88
+ };
89
+ }
90
+
39
91
  function readPolicy(root) {
40
92
  const policy = readJson(policyPath(root), null);
41
- if (!policy || typeof policy !== 'object') return null;
93
+ if (!policy || typeof policy !== 'object') return defaultPolicy(root);
42
94
  return policy;
43
95
  }
44
96
 
@@ -134,8 +186,10 @@ function waitingOnHuman(tasks, now = Date.now()) {
134
186
  .map((t) => ({
135
187
  ref: t.display_id || t.legacy_ref || t.id,
136
188
  title: String(t.title || '').slice(0, 200),
189
+ result: String(t.result || t.metadata?.result || '').slice(0, 220),
137
190
  tag: String(t.tag || ''),
138
191
  hours: waitingHours(t, now),
192
+ member: String(t.claimed_by || t.assigned_to || '').trim(),
139
193
  }))
140
194
  .sort((a, b) => b.hours - a.hours);
141
195
  }
@@ -149,8 +203,9 @@ function acceptedInLastDay(tasks, now = Date.now()) {
149
203
  const entry = {
150
204
  ref: t.display_id || t.legacy_ref || t.id,
151
205
  title: String(t.title || '').slice(0, 120),
206
+ result: String(t.result || t.metadata?.result || '').slice(0, 220),
152
207
  happened: String(t.review?.landing?.happened || t.metadata?.landing_happened || '').slice(0, 300),
153
- member: String(t.claimed_by || t.assigned_to || '').trim(),
208
+ member: String(t.claimed_by || t.metadata?.executed_by || t.assigned_to || '').trim(),
154
209
  };
155
210
  if (t.metadata?.auto_accepted_at) auto.push(entry);
156
211
  else human.push(entry);
@@ -162,6 +217,83 @@ function plur(n, word) {
162
217
  return `${n} ${word}${n === 1 ? '' : 's'}`;
163
218
  }
164
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
+
165
297
  function cutAtWord(text, max) {
166
298
  const clean = String(text || '').replace(/\s+/g, ' ').trim();
167
299
  if (clean.length <= max) return clean;
@@ -170,19 +302,63 @@ function cutAtWord(text, max) {
170
302
  return `${cut.slice(0, lastSpace > max * 0.6 ? lastSpace : max)}...`;
171
303
  }
172
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
+
173
320
  // A line ends where a thought ends. Long lines close at their last complete
174
321
  // clause inside the budget and read whole: no ellipsis, nothing dangling.
175
322
  // Only when a line has no clause boundary at all does a word cut remain.
176
323
  function finishThought(text, max = 160) {
177
- const clean = String(text || '').replace(/\s+/g, ' ').trim().replace(/[,;:\s]+$/, '');
324
+ const clean = stripDanglingFragment(String(text || '').replace(/\s+/g, ' ').trim().replace(/[,;:\s]+$/, ''));
178
325
  if (clean.length <= max) return clean;
179
326
  const cut = clean.slice(0, max);
180
327
  const sentence = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf('! '), cut.lastIndexOf('? '));
181
- if (sentence > max * 0.3) return cut.slice(0, sentence + 1);
328
+ if (sentence > max * 0.3) return stripDanglingFragment(cut.slice(0, sentence + 1));
182
329
  const clause = Math.max(cut.lastIndexOf(', '), cut.lastIndexOf('; '), cut.lastIndexOf(' ('), cut.lastIndexOf(': '));
183
- if (clause > max * 0.45) return cut.slice(0, clause);
330
+ if (clause > max * 0.45) return stripDanglingFragment(cut.slice(0, clause));
184
331
  const space = cut.lastIndexOf(' ');
185
- 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);
186
362
  }
187
363
 
188
364
  function operatorReady(text) {
@@ -194,28 +370,86 @@ function hasAgentJargon(text) {
194
370
  return AGENT_JARGON.test(String(text || ''));
195
371
  }
196
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
+
197
397
  // One line per piece, in the work's own words: the landing sentence beats
198
398
  // the task title, because titles are written for the queue, not for a text.
199
399
  // The cut is generous on purpose: a specific sentence the reader trusts
200
400
  // (what happened + how big + how we know) beats a short one they must chase.
201
401
  function digestLine(item) {
402
+ const result = String(item.result || '').replace(/^completed:\s*/i, '').split(/(?<=[.!?])\s+/)[0] || '';
202
403
  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;
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 '';
205
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
+ }
206
412
  return line.charAt(0).toLowerCase() + line.slice(1);
207
413
  }
208
414
 
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();
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();
444
+ }
445
+
446
+ // The clarity gate in one call: strip what an operator can't act on (flag
447
+ // dashes, task ids, snake_case) and close the sentence on a whole clause, so
448
+ // any title or line borrowed from the queue reaches a human actionable at a
449
+ // glance. One sentence in, one operator-ready sentence out — content always
450
+ // ships, the gate only changes how it reads.
451
+ function clarify(text, max = 160) {
452
+ return finishThought(dejargon(text), max);
219
453
  }
220
454
 
221
455
  // The mission loop files a check-off every time it stops instead of
@@ -225,16 +459,33 @@ function isCleanStop(item) {
225
459
  || /^(Mission XP:\s*)?Decide and start the next useful mission/i.test(String(item.title || ''));
226
460
  }
227
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
+
228
477
  // Short enough for one iMessage, plain enough to read walking to the car.
229
478
  // Every line answers: what happened, how big, how we know. It names the work
230
479
  // because "7 things landed" tells the score, not the story — and it always
231
480
  // ends with what's waiting and what to do next, because those are the only
232
481
  // lines that ask anything of the reader.
233
- function composeDigest({ accepted, waiting, landed, project, nextMoves = [] }) {
482
+ function composeDigest({ accepted, waiting, protectedWaiting = [], waitingWishes = [], landed, project, nextMoves = [], acceptAll = false, reapError = null, janitor = null, landingSweep = null, fullStory = false, root = process.cwd() }) {
234
483
  const lines = [];
235
- lines.push(`atris ${project}: yesterday`);
484
+ lines.push(`atris ${project}: last 24 hours`);
236
485
  const autoCount = accepted.auto.length;
237
486
  const humanCount = accepted.human.length;
487
+ const wishWaits = (Array.isArray(waitingWishes) ? waitingWishes : [])
488
+ .filter((wish) => wish && String(wish.text || '').trim());
238
489
  if (autoCount > 0) {
239
490
  // Three results, air between them, the rest on ask: the whole message
240
491
  // fits a laptop screen with no scrolling. Reading it is one glance.
@@ -242,36 +493,68 @@ function composeDigest({ accepted, waiting, landed, project, nextMoves = [] }) {
242
493
  const work = accepted.auto
243
494
  .filter((item) => !isCleanStop(item))
244
495
  .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);
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);
252
500
  if (visibleWork.length > 0) {
253
- lines.push('landed on their own (verified twice, proof on file):');
501
+ // The header states the actual bar: under accept-all the claim
502
+ // "verified twice" would be a lie the operator builds trust on.
503
+ lines.push(acceptAll
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:`);
254
506
  for (const { item, line } of visibleWork) {
255
507
  lines.push('');
256
- lines.push(`- ${line || item.ref}${item.member ? ` (${item.member})` : ''}`);
508
+ lines.push(`- ${line || item.ref}${memberTrustLabel(item.member, root)}`);
257
509
  }
258
510
  lines.push('');
259
- 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:`);
260
518
  }
519
+ for (const row of unexplainedSummaryRows(unexplainedWork, 'landed')) lines.push(row);
261
520
  }
262
521
  if (autoCount + humanCount === 0) lines.push('nothing finished in the last day');
263
522
  if (waiting.length > 0) {
264
523
  // 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)` : ''}`);
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)` : ''}`);
268
530
  }
269
- if (waiting.length > 2) lines.push(`- and ${waiting.length - 2} more`);
270
- } else {
271
- 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`);
272
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');
540
+ }
541
+ pushWaitingWishLines(lines, wishWaits);
273
542
  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)`);
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)`);
548
+ }
549
+ if (reapError) lines.push(`cleanup trouble: the landing sweep failed (${reapError}) - run: atris land --reap`);
550
+ pushLandingSweepLines(lines, landingSweep);
551
+ // The janitor's day in one line: stale paused missions stopped and merged
552
+ // worktrees cleared since the last digest. Silence when it did nothing.
553
+ const missionsStopped = Number(janitor?.missions_stopped) || 0;
554
+ const worktreesReaped = Number(janitor?.worktrees_reaped) || 0;
555
+ if (janitor) pushJanitorMissionReasonLines(lines, janitor);
556
+ if (missionsStopped > 0 || worktreesReaped > 0) {
557
+ lines.push(`tidied up: ${plur(missionsStopped, 'stale mission')} stopped, ${plur(worktreesReaped, 'merged worktree')} cleared`);
275
558
  }
276
559
  // nextMoves: array of {title, owner}, or {moves, unexplained} where
277
560
  // unexplained counts queue items whose sentence carries no operator why.
@@ -285,13 +568,13 @@ function composeDigest({ accepted, waiting, landed, project, nextMoves = [] }) {
285
568
  if (moves.length > 0) {
286
569
  lines.push('next, if you agree:');
287
570
  for (const move of moves) {
288
- lines.push(`- ${finishThought(move.title, 140)}${move.owner ? ` (best fit: ${move.owner})` : ''}`);
571
+ lines.push(`- ${digestMoveLine(move)}`);
289
572
  }
290
- 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 })}`);
291
574
  } else if (unexplained > 0) {
292
- lines.push(`${plur(unexplained, 'idea')} in the queue can't explain themselves yet (atris now)`);
575
+ lines.push(hiddenMoveText(unexplained));
293
576
  }
294
- if (!lines.some((line) => line.includes('atris autoland digest'))) {
577
+ if (!fullStory && !lines.some((line) => line.includes('atris autoland digest'))) {
295
578
  lines.push('the full story: atris autoland digest');
296
579
  }
297
580
  return lines.join('\n');
@@ -300,7 +583,7 @@ function composeDigest({ accepted, waiting, landed, project, nextMoves = [] }) {
300
583
  // The moment something lands, the operator hears it in one glance: the
301
584
  // landing sentence each piece wrote for a day-one PM at finish time, its
302
585
  // author, nothing else. Rough sentences get de-jargoned, never hidden.
303
- function composeLiveUpdate({ landedRefs, tasks, project }) {
586
+ function composeLiveUpdate({ landedRefs, tasks, project, root = process.cwd() }) {
304
587
  const byRef = new Map((tasks || []).map((t) => [String(t.display_id || t.legacy_ref || t.id), t]));
305
588
  const items = (landedRefs || []).map((ref) => {
306
589
  const t = byRef.get(String(ref));
@@ -308,21 +591,27 @@ function composeLiveUpdate({ landedRefs, tasks, project }) {
308
591
  return {
309
592
  ref: String(ref),
310
593
  title: String(t.title || '').slice(0, 200),
594
+ result: String(t.result || t.metadata?.result || '').slice(0, 220),
311
595
  happened: String(t.review?.landing?.happened || t.metadata?.landing_happened || '').slice(0, 300),
312
- member: String(t.claimed_by || t.assigned_to || '').trim(),
596
+ member: String(t.claimed_by || t.metadata?.executed_by || t.assigned_to || '').trim(),
313
597
  };
314
598
  }).filter((item) => !isCleanStop(item));
315
599
  if (!items.length) return '';
316
600
  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);
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')) {
320
609
  lines.push('');
321
- lines.push(`- ${finishThought(line, 160)}${item.member ? ` (${item.member})` : ''}`);
610
+ lines.push(row);
322
611
  }
323
- if (items.length > 3) {
612
+ if (ready.length > 3) {
324
613
  lines.push('');
325
- 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`);
326
615
  }
327
616
  return lines.join('\n');
328
617
  }
@@ -331,7 +620,7 @@ function composeAlarm({ waiting, project, alarmHours }) {
331
620
  const worst = waiting[0];
332
621
  return [
333
622
  `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}`,
623
+ `oldest: ${worst.ref} (${worst.hours}h), ${worst.title}`,
335
624
  'approve or bounce: atris task reviews',
336
625
  ].join('\n');
337
626
  }
@@ -367,17 +656,29 @@ module.exports = {
367
656
  DEFAULT_DIGEST_HOUR,
368
657
  acceptedInLastDay,
369
658
  buildCronLine,
659
+ clarify,
370
660
  composeAlarm,
371
661
  composeDigest,
372
662
  composeLiveUpdate,
373
663
  cronInstalled,
374
664
  cronMarker,
375
665
  dueForAlarm,
666
+ digestLine,
667
+ dejargon,
668
+ explainResult,
376
669
  installCron,
377
670
  liveAcceptAuthorization,
378
671
  markAlerted,
672
+ missionDigestLine,
379
673
  operatorReady,
380
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,
381
682
  policyPath,
382
683
  readPolicy,
383
684
  readState,
@@ -0,0 +1,79 @@
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
+ const LOOKUP_WORD_RE = /\b(?:what|who|when|where|list|show|define|convert)\b/i;
11
+ // Words that turn a lookup-shaped message into a judgment call: never fast.
12
+ const JUDGMENT_WORD_RE = /\b(?:should|tradeoffs?|better|best|recommend|worth)\b/i;
13
+ 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/i;
14
+ const DECISION_PHRASE_RE = /\b(?:should|whether|decide|deciding)\b/i;
15
+ const ARGUE_BOTH_RE = /\bargue\b|\bboth sides\b|\bsteelman\b/i;
16
+ const CODE_EDIT_RE = /\b(?:fix(?:es|ed|ing)?|refactor(?:s|ed|ing)?|renam(?:e|es|ed|ing)|debug(?:s|ged|ging)?)\b/i;
17
+ const CODE_DIAGNOSIS_RE = /\bwhy\b|\bwrong\b|\bfail(?:s|ed|ing)?\b|\bresolv(?:e|es|ed|ing)?\b|\bbroken\b|\bnot work/i;
18
+ const CODE_CONTEXT_RE = /```|(?:^|\n)\s*(?:traceback\b|(?:[a-z]+)?(?:error|exception):|at\s+\S+\s+\()/im;
19
+ const ERROR_EXPLAIN_RE = /\bexplain (?:this|the) error\b|\bwhat does (?:this|the) error mean\b/i;
20
+ const TRANSFORM_START_RE = /^(?:convert|summarize|translate)\b/i;
21
+ const GREETING_RE = /^(?:hey|hi|hello|yo|thanks|thank you|ok|okay|cool|nice|got it|great|perfect|yep|no worries)\b/i;
22
+
23
+ function threshold(value, fallback) {
24
+ return Number.isFinite(value) && value >= 0 ? value : fallback;
25
+ }
26
+
27
+ function pickLane(message, opts = {}) {
28
+ const text = String(message || '').trim();
29
+ const shortLookupMaxChars = threshold(opts.shortLookupMaxChars, SHORT_LOOKUP_MAX_CHARS);
30
+ const longInputMinChars = threshold(opts.longInputMinChars, LONG_INPUT_MIN_CHARS);
31
+ const questionCount = (text.match(/\?/g) || []).length;
32
+ const commaCount = (text.match(/,/g) || []).length;
33
+ const hasCodeContext = CODE_CONTEXT_RE.test(text);
34
+
35
+ if (text.length > longInputMinChars) {
36
+ return { lane: 'max', reason: 'max fits this long input.' };
37
+ }
38
+ if (questionCount > 1) {
39
+ return { lane: 'max', reason: 'max fits a request with multiple questions.' };
40
+ }
41
+ if (ARGUE_BOTH_RE.test(text)
42
+ || (DECISION_PHRASE_RE.test(text) && commaCount >= DECISION_FACTOR_MIN_COMMAS)) {
43
+ return { lane: 'max', reason: 'max fits a decision weighing several factors.' };
44
+ }
45
+ if (HEAVY_REASONING_RE.test(text)) {
46
+ return { lane: 'max', reason: 'max fits this reasoning-heavy request.' };
47
+ }
48
+ if (/\btrade[- ]?offs?\b/i.test(text) && text.length > TRADEOFF_DEPTH_MIN_CHARS) {
49
+ return { lane: 'max', reason: 'max fits this tradeoff analysis.' };
50
+ }
51
+ if (hasCodeContext && (CODE_EDIT_RE.test(text) || CODE_DIAGNOSIS_RE.test(text))) {
52
+ return { lane: 'code-fast', reason: 'code-fast fits this bounded code task.' };
53
+ }
54
+ if (ERROR_EXPLAIN_RE.test(text) && !hasCodeContext) {
55
+ return { lane: 'fast', reason: 'fast fits a plain error explanation.' };
56
+ }
57
+ if (TRANSFORM_START_RE.test(text) && text.length <= shortLookupMaxChars) {
58
+ return { lane: 'fast', reason: 'fast fits this small transform.' };
59
+ }
60
+ if (GREETING_RE.test(text) && text.length <= GREETING_MAX_CHARS) {
61
+ return { lane: 'fast', reason: 'fast fits a quick reply.' };
62
+ }
63
+ if (text.length > 0 && text.length <= TINY_MESSAGE_MAX_CHARS && !CODE_EDIT_RE.test(text)) {
64
+ return { lane: 'fast', reason: 'fast fits this tiny message.' };
65
+ }
66
+ if (text.length <= shortLookupMaxChars
67
+ && LOOKUP_WORD_RE.test(text)
68
+ && !JUDGMENT_WORD_RE.test(text)) {
69
+ return { lane: 'fast', reason: 'fast fits this short factual lookup.' };
70
+ }
71
+ return { lane: 'pro', reason: 'pro fits this general request.' };
72
+ }
73
+
74
+ module.exports = {
75
+ LONG_INPUT_MIN_CHARS,
76
+ SHORT_LOOKUP_MAX_CHARS,
77
+ TINY_MESSAGE_MAX_CHARS,
78
+ pickLane,
79
+ };