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.
- package/AGENTS.md +35 -0
- package/FOR_AGENTS.md +5 -3
- package/README.md +5 -3
- package/atris/GETTING_STARTED.md +1 -1
- package/atris/atris.md +3 -0
- package/atris/policies/day-loop-voice.md +102 -0
- package/atris/policies/outbound-artifact-gate.md +2 -0
- package/atris/skills/design/SKILL.md +56 -32
- package/atris/skills/endgame/SKILL.md +12 -6
- package/atris/skills/engines/SKILL.md +35 -8
- package/atris/skills/fable-method/SKILL.md +66 -0
- package/atris/skills/improve/SKILL.md +65 -45
- package/atris/skills/render-cli/SKILL.md +88 -0
- package/atris/skills/youtube/SKILL.md +10 -1
- package/atris.md +6 -2
- package/ax +617 -31
- package/bin/atris.js +719 -266
- package/commands/activate.js +194 -88
- package/commands/aeo.js +52 -0
- package/commands/agents.js +166 -0
- package/commands/autoland.js +718 -72
- package/commands/autopilot-front.js +20 -2
- package/commands/autopilot.js +118 -2
- package/commands/avail.js +407 -0
- package/commands/bench.js +188 -0
- package/commands/brain.js +3 -0
- package/commands/brief.js +651 -0
- package/commands/business-sync.js +192 -6
- package/commands/business.js +91 -8
- package/commands/clean.js +50 -24
- package/commands/close.js +1083 -0
- package/commands/cloud.js +245 -0
- package/commands/codex-goal.js +26 -2
- package/commands/compile.js +292 -1
- package/commands/computer.js +150 -3
- package/commands/dream.js +365 -0
- package/commands/drill.js +371 -0
- package/commands/drive.js +187 -0
- package/commands/engine.js +1061 -29
- package/commands/experiments.js +28 -0
- package/commands/feed.js +202 -0
- package/commands/feedback.js +34 -12
- package/commands/fleet-report.js +206 -0
- package/commands/github.js +38 -0
- package/commands/gm.js +285 -3
- package/commands/goal.js +247 -0
- package/commands/improve.js +642 -26
- package/commands/init.js +83 -43
- package/commands/integrations.js +39 -11
- package/commands/interview.js +209 -0
- package/commands/land.js +253 -52
- package/commands/lesson.js +112 -1
- package/commands/lifecycle.js +39 -3
- package/commands/linear.js +38 -0
- package/commands/log.js +84 -1
- package/commands/loops.js +220 -16
- package/commands/meet.js +220 -0
- package/commands/member.js +899 -66
- package/commands/mission.js +3512 -332
- package/commands/next.js +137 -0
- package/commands/now.js +240 -21
- package/commands/one-lap.js +776 -0
- package/commands/orb.js +314 -0
- package/commands/pack-craft.js +179 -0
- package/commands/pack.js +823 -0
- package/commands/play.js +3 -2
- package/commands/probe.js +30 -3
- package/commands/pulse.js +241 -46
- package/commands/push.js +260 -82
- package/commands/radar.js +259 -14
- package/commands/rainmaker.js +49 -0
- package/commands/report.js +415 -0
- package/commands/scout.js +147 -0
- package/commands/search.js +363 -0
- package/commands/serve.js +54 -0
- package/commands/skill.js +47 -3
- package/commands/slop.js +50 -2
- package/commands/soul.js +1 -1
- package/commands/status.js +50 -5
- package/commands/stream.js +861 -0
- package/commands/stripe.js +38 -0
- package/commands/study.js +693 -0
- package/commands/supabase.js +39 -0
- package/commands/sync.js +67 -54
- package/commands/task.js +2275 -182
- package/commands/team.js +73 -0
- package/commands/truth.js +29 -3
- package/commands/unknowns.js +627 -0
- package/commands/update.js +44 -0
- package/commands/vercel.js +38 -0
- package/commands/verify.js +96 -0
- package/commands/watch.js +303 -0
- package/commands/wish.js +500 -0
- package/commands/workflow.js +11 -5
- package/commands/worktree.js +299 -20
- package/commands/write.js +399 -0
- package/commands/xp.js +29 -11
- package/lib/auto-accept-certified.js +391 -43
- package/lib/autoland.js +353 -52
- package/lib/ax-auto-lane.js +79 -0
- package/lib/bench/context.js +147 -0
- package/lib/bench/engines.js +141 -0
- package/lib/bench/report.js +140 -0
- package/lib/bench/runner.js +512 -0
- package/lib/brief-ledger.js +350 -0
- package/lib/cloud-mission.js +259 -0
- package/lib/codex-flight.js +154 -0
- package/lib/default-runner.js +45 -0
- package/lib/default-verifier.js +70 -0
- package/lib/engine-registry.js +232 -0
- package/lib/experiments/daily.js +640 -0
- package/lib/fleet.js +2431 -38
- package/lib/improve-vitals-html.js +171 -0
- package/lib/known-commands.js +58 -0
- package/lib/loop-doctor.js +416 -0
- package/lib/member-switches.js +144 -0
- package/lib/memory-view.js +14 -5
- package/lib/mission-room.js +1 -0
- package/lib/mission-root.js +52 -0
- package/lib/mission-runtime-loop.js +7 -0
- package/lib/next-moves.js +327 -10
- package/lib/official-cli-integration.js +174 -0
- package/lib/one-lap-validator.js +60 -0
- package/lib/orb-context.js +477 -0
- package/lib/orb-scorecard.js +224 -0
- package/lib/outbound-send-gate.js +165 -0
- package/lib/permission-grants.js +293 -0
- package/lib/policy-lessons.js +52 -1
- package/lib/pulse.js +277 -3
- package/lib/receipt-block.js +168 -0
- package/lib/receipt-evidence.js +65 -4
- package/lib/review-integrity.js +147 -0
- package/lib/router-brain.js +352 -0
- package/lib/runner-command.js +33 -0
- package/lib/self-drive.js +258 -0
- package/lib/short-name.js +103 -0
- package/lib/spawn-env.js +18 -0
- package/lib/state-detection.js +56 -1
- package/lib/sync-status.js +59 -0
- package/lib/task-db.js +319 -27
- package/lib/task-proof.js +43 -1
- package/lib/task-receipt.js +93 -0
- package/lib/team-presence.js +260 -0
- package/lib/tool-result-encode.js +7 -0
- package/lib/trust-tiers.js +90 -0
- package/lib/usage.js +107 -0
- package/lib/voice-gate.js +163 -0
- package/lib/wish-audit.js +1368 -0
- package/lib/wish-delegate.js +1840 -0
- package/lib/wish-design.js +110 -0
- package/lib/wish-stats.js +183 -0
- package/lib/wish-store.js +354 -0
- package/lib/zip.js +221 -0
- package/package.json +3 -1
- package/templates/loops/atris/loops/LOOPS.md +55 -0
- package/templates/loops/atris/loops/TICK.md +24 -0
- package/templates/loops/atris/loops/feedback.md +22 -0
- package/templates/loops/atris/loops/quality.md +22 -0
- package/templates/loops/atris/wiki/systems/loops.md +41 -0
- package/utils/api.js +5 -1
- package/utils/auth.js +57 -21
- package/utils/update-check.js +27 -6
- 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|
|
|
16
|
-
const AGENT_JARGON =
|
|
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
|
|
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
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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}:
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
const
|
|
248
|
-
const
|
|
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
|
-
|
|
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
|
|
508
|
+
lines.push(`- ${line || item.ref}${memberTrustLabel(item.member, root)}`);
|
|
257
509
|
}
|
|
258
510
|
lines.push('');
|
|
259
|
-
if (held > 0)
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
lines.push(
|
|
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
|
-
|
|
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(`- ${
|
|
571
|
+
lines.push(`- ${digestMoveLine(move)}`);
|
|
289
572
|
}
|
|
290
|
-
if (unexplained > 0) lines.push(`- ${
|
|
573
|
+
if (unexplained > 0) lines.push(`- ${hiddenMoveText(unexplained, { more: true })}`);
|
|
291
574
|
} else if (unexplained > 0) {
|
|
292
|
-
lines.push(
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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(
|
|
610
|
+
lines.push(row);
|
|
322
611
|
}
|
|
323
|
-
if (
|
|
612
|
+
if (ready.length > 3) {
|
|
324
613
|
lines.push('');
|
|
325
|
-
lines.push(`and ${plur(
|
|
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)
|
|
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
|
+
};
|