@jonit-dev/night-watch-cli 1.7.23 → 1.7.25

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 (45) hide show
  1. package/dist/shared/types.d.ts +1 -1
  2. package/dist/shared/types.d.ts.map +1 -1
  3. package/dist/src/agents/soul-compiler.d.ts.map +1 -1
  4. package/dist/src/agents/soul-compiler.js +60 -6
  5. package/dist/src/agents/soul-compiler.js.map +1 -1
  6. package/dist/src/commands/qa.d.ts +4 -0
  7. package/dist/src/commands/qa.d.ts.map +1 -1
  8. package/dist/src/commands/qa.js +35 -0
  9. package/dist/src/commands/qa.js.map +1 -1
  10. package/dist/src/commands/serve.d.ts +12 -0
  11. package/dist/src/commands/serve.d.ts.map +1 -1
  12. package/dist/src/commands/serve.js +115 -0
  13. package/dist/src/commands/serve.js.map +1 -1
  14. package/dist/src/config.d.ts.map +1 -1
  15. package/dist/src/config.js +16 -3
  16. package/dist/src/config.js.map +1 -1
  17. package/dist/src/slack/channel-manager.js +3 -3
  18. package/dist/src/slack/channel-manager.js.map +1 -1
  19. package/dist/src/slack/client.d.ts +2 -1
  20. package/dist/src/slack/client.d.ts.map +1 -1
  21. package/dist/src/slack/client.js +20 -2
  22. package/dist/src/slack/client.js.map +1 -1
  23. package/dist/src/slack/deliberation.d.ts +17 -1
  24. package/dist/src/slack/deliberation.d.ts.map +1 -1
  25. package/dist/src/slack/deliberation.js +220 -46
  26. package/dist/src/slack/deliberation.js.map +1 -1
  27. package/dist/src/slack/interaction-listener.d.ts +48 -0
  28. package/dist/src/slack/interaction-listener.d.ts.map +1 -1
  29. package/dist/src/slack/interaction-listener.js +812 -20
  30. package/dist/src/slack/interaction-listener.js.map +1 -1
  31. package/dist/src/storage/repositories/sqlite/agent-persona-repository.d.ts.map +1 -1
  32. package/dist/src/storage/repositories/sqlite/agent-persona-repository.js +209 -99
  33. package/dist/src/storage/repositories/sqlite/agent-persona-repository.js.map +1 -1
  34. package/dist/src/utils/avatar-generator.d.ts +1 -1
  35. package/dist/src/utils/avatar-generator.d.ts.map +1 -1
  36. package/dist/src/utils/avatar-generator.js +55 -15
  37. package/dist/src/utils/avatar-generator.js.map +1 -1
  38. package/dist/src/utils/notify.d.ts +1 -0
  39. package/dist/src/utils/notify.d.ts.map +1 -1
  40. package/dist/src/utils/notify.js +13 -1
  41. package/dist/src/utils/notify.js.map +1 -1
  42. package/package.json +1 -1
  43. package/scripts/night-watch-pr-reviewer-cron.sh +36 -8
  44. package/scripts/night-watch-qa-cron.sh +15 -3
  45. package/templates/night-watch-pr-reviewer.md +46 -17
@@ -4,13 +4,364 @@
4
4
  * and applies loop-protection safeguards.
5
5
  */
6
6
  import { SocketModeClient } from '@slack/socket-mode';
7
+ import { spawn } from 'child_process';
8
+ import * as fs from 'fs';
9
+ import * as path from 'path';
7
10
  import { getDb } from '../storage/sqlite/client.js';
8
11
  import { getRepositories } from '../storage/repositories/index.js';
12
+ import { parseScriptResult } from '../utils/script-result.js';
13
+ import { getRoadmapStatus } from '../utils/roadmap-scanner.js';
9
14
  import { generatePersonaAvatar } from '../utils/avatar-generator.js';
10
15
  import { DeliberationEngine } from './deliberation.js';
11
16
  import { SlackClient } from './client.js';
12
17
  const MAX_PROCESSED_MESSAGE_KEYS = 2000;
13
18
  const PERSONA_REPLY_COOLDOWN_MS = 45_000;
19
+ const AD_HOC_THREAD_MEMORY_MS = 60 * 60_000; // 1h
20
+ const PROACTIVE_IDLE_MS = 20 * 60_000; // 20 min
21
+ const PROACTIVE_MIN_INTERVAL_MS = 90 * 60_000; // per channel
22
+ const PROACTIVE_SWEEP_INTERVAL_MS = 60_000;
23
+ const PROACTIVE_CODEWATCH_MIN_INTERVAL_MS = 3 * 60 * 60_000; // per project
24
+ const PROACTIVE_CODEWATCH_REPEAT_COOLDOWN_MS = 24 * 60 * 60_000; // per issue signature
25
+ const MAX_JOB_OUTPUT_CHARS = 12_000;
26
+ const HUMAN_REACTION_PROBABILITY = 0.65;
27
+ const REACTION_DELAY_MIN_MS = 180;
28
+ const REACTION_DELAY_MAX_MS = 1200;
29
+ const RESPONSE_DELAY_MIN_MS = 700;
30
+ const RESPONSE_DELAY_MAX_MS = 3400;
31
+ const CODEWATCH_MAX_FILES = 250;
32
+ const CODEWATCH_MAX_FILE_BYTES = 256_000;
33
+ const CODEWATCH_INCLUDE_EXTENSIONS = new Set([
34
+ '.ts',
35
+ '.tsx',
36
+ '.js',
37
+ '.jsx',
38
+ '.mjs',
39
+ '.cjs',
40
+ '.py',
41
+ '.go',
42
+ '.rb',
43
+ '.java',
44
+ '.kt',
45
+ '.rs',
46
+ '.php',
47
+ '.cs',
48
+ '.swift',
49
+ '.scala',
50
+ '.sh',
51
+ ]);
52
+ const CODEWATCH_IGNORE_DIRS = new Set([
53
+ '.git',
54
+ 'node_modules',
55
+ 'dist',
56
+ 'build',
57
+ '.next',
58
+ 'coverage',
59
+ '.turbo',
60
+ '.cache',
61
+ 'logs',
62
+ '.yarn',
63
+ 'vendor',
64
+ 'tmp',
65
+ 'temp',
66
+ ]);
67
+ const JOB_STOPWORDS = new Set([
68
+ 'and',
69
+ 'or',
70
+ 'for',
71
+ 'on',
72
+ 'of',
73
+ 'please',
74
+ 'now',
75
+ 'it',
76
+ 'this',
77
+ 'these',
78
+ 'those',
79
+ 'the',
80
+ 'a',
81
+ 'an',
82
+ 'pr',
83
+ 'pull',
84
+ 'that',
85
+ 'thanks',
86
+ 'thank',
87
+ 'again',
88
+ 'job',
89
+ 'pipeline',
90
+ ]);
91
+ function sleep(ms) {
92
+ return new Promise((resolve) => setTimeout(resolve, ms));
93
+ }
94
+ function extractLastMeaningfulLines(output, maxLines = 4) {
95
+ const lines = output
96
+ .split(/\r?\n/)
97
+ .map((line) => line.trim())
98
+ .filter(Boolean);
99
+ if (lines.length === 0)
100
+ return '';
101
+ return lines.slice(-maxLines).join(' | ');
102
+ }
103
+ function buildCurrentCliInvocation(args) {
104
+ const cliEntry = process.argv[1];
105
+ if (!cliEntry)
106
+ return null;
107
+ return [...process.execArgv, cliEntry, ...args];
108
+ }
109
+ function formatCommandForLog(bin, args) {
110
+ return [bin, ...args].map((part) => JSON.stringify(part)).join(' ');
111
+ }
112
+ function extractInboundEvent(payload) {
113
+ return payload.event ?? payload.body?.event ?? payload.payload?.event ?? null;
114
+ }
115
+ export function buildInboundMessageKey(channel, ts, type) {
116
+ return `${channel}:${ts}:${type ?? 'message'}`;
117
+ }
118
+ function normalizeProjectRef(value) {
119
+ return value.toLowerCase().replace(/[^a-z0-9]/g, '');
120
+ }
121
+ function stripSlackUserMentions(text) {
122
+ return text.replace(/<@[A-Z0-9]+>/g, ' ');
123
+ }
124
+ function normalizeForParsing(text) {
125
+ return text
126
+ .toLowerCase()
127
+ .replace(/[^\w\s./-]/g, ' ')
128
+ .replace(/\s+/g, ' ')
129
+ .trim();
130
+ }
131
+ function isCodeWatchSourceFile(filePath) {
132
+ return CODEWATCH_INCLUDE_EXTENSIONS.has(path.extname(filePath).toLowerCase());
133
+ }
134
+ function lineNumberAt(content, index) {
135
+ if (index <= 0)
136
+ return 1;
137
+ let line = 1;
138
+ for (let i = 0; i < index && i < content.length; i += 1) {
139
+ if (content.charCodeAt(i) === 10) {
140
+ line += 1;
141
+ }
142
+ }
143
+ return line;
144
+ }
145
+ function extractLineSnippet(content, index) {
146
+ const clamped = Math.max(0, Math.min(index, content.length));
147
+ const before = content.lastIndexOf('\n', clamped);
148
+ const after = content.indexOf('\n', clamped);
149
+ const start = before === -1 ? 0 : before + 1;
150
+ const end = after === -1 ? content.length : after;
151
+ return content.slice(start, end).trim().slice(0, 220);
152
+ }
153
+ function walkProjectFilesForCodeWatch(projectPath) {
154
+ const files = [];
155
+ const stack = [projectPath];
156
+ while (stack.length > 0 && files.length < CODEWATCH_MAX_FILES) {
157
+ const dir = stack.pop();
158
+ if (!dir)
159
+ break;
160
+ let entries;
161
+ try {
162
+ entries = fs.readdirSync(dir, { withFileTypes: true });
163
+ }
164
+ catch {
165
+ continue;
166
+ }
167
+ for (const entry of entries) {
168
+ if (files.length >= CODEWATCH_MAX_FILES)
169
+ break;
170
+ const fullPath = path.join(dir, entry.name);
171
+ if (entry.isDirectory()) {
172
+ if (CODEWATCH_IGNORE_DIRS.has(entry.name)) {
173
+ continue;
174
+ }
175
+ stack.push(fullPath);
176
+ continue;
177
+ }
178
+ if (!entry.isFile()) {
179
+ continue;
180
+ }
181
+ if (isCodeWatchSourceFile(fullPath)) {
182
+ files.push(fullPath);
183
+ }
184
+ }
185
+ }
186
+ return files;
187
+ }
188
+ export function findCodeWatchSignal(content) {
189
+ if (!content || content.trim().length === 0)
190
+ return null;
191
+ const emptyCatchMatch = /catch\s*(?:\([^)]*\))?\s*\{\s*(?:(?:\/\/[^\n]*|\/\*[\s\S]*?\*\/)\s*)*\}/gm.exec(content);
192
+ const criticalTodoMatch = /\b(?:TODO|FIXME|HACK)\b[^\n]{0,140}\b(?:bug|security|race|leak|crash|hotfix|rollback|unsafe)\b/gi.exec(content);
193
+ const emptyCatchIndex = emptyCatchMatch?.index ?? Number.POSITIVE_INFINITY;
194
+ const criticalTodoIndex = criticalTodoMatch?.index ?? Number.POSITIVE_INFINITY;
195
+ if (!Number.isFinite(emptyCatchIndex) && !Number.isFinite(criticalTodoIndex)) {
196
+ return null;
197
+ }
198
+ if (emptyCatchIndex <= criticalTodoIndex) {
199
+ return {
200
+ type: 'empty_catch',
201
+ index: emptyCatchIndex,
202
+ summary: 'empty catch block may hide runtime failures',
203
+ snippet: extractLineSnippet(content, emptyCatchIndex),
204
+ };
205
+ }
206
+ return {
207
+ type: 'critical_todo',
208
+ index: criticalTodoIndex,
209
+ summary: 'high-risk TODO/FIXME likely indicates unresolved bug or security concern',
210
+ snippet: (criticalTodoMatch?.[0] ?? extractLineSnippet(content, criticalTodoIndex)).trim().slice(0, 220),
211
+ };
212
+ }
213
+ function detectCodeWatchCandidate(projectPath) {
214
+ const files = walkProjectFilesForCodeWatch(projectPath);
215
+ for (const filePath of files) {
216
+ let stat;
217
+ try {
218
+ stat = fs.statSync(filePath);
219
+ }
220
+ catch {
221
+ continue;
222
+ }
223
+ if (stat.size <= 0 || stat.size > CODEWATCH_MAX_FILE_BYTES) {
224
+ continue;
225
+ }
226
+ let content;
227
+ try {
228
+ content = fs.readFileSync(filePath, 'utf-8');
229
+ }
230
+ catch {
231
+ continue;
232
+ }
233
+ if (content.includes('\u0000')) {
234
+ continue;
235
+ }
236
+ const signal = findCodeWatchSignal(content);
237
+ if (!signal)
238
+ continue;
239
+ const relativePath = path.relative(projectPath, filePath).replace(/\\/g, '/');
240
+ const line = lineNumberAt(content, signal.index);
241
+ const signature = `${signal.type}:${relativePath}:${line}`;
242
+ return {
243
+ ...signal,
244
+ relativePath,
245
+ line,
246
+ signature,
247
+ };
248
+ }
249
+ return null;
250
+ }
251
+ export function isAmbientTeamMessage(text) {
252
+ const normalized = normalizeForParsing(stripSlackUserMentions(text));
253
+ if (!normalized)
254
+ return false;
255
+ if (/^(hey|hi|hello|yo|sup)\b/.test(normalized) && /\b(guys|team|everyone|folks)\b/.test(normalized)) {
256
+ return true;
257
+ }
258
+ if (/^(hey|hi|hello|yo|sup)\b/.test(normalized) && normalized.split(' ').length <= 6) {
259
+ return true;
260
+ }
261
+ return false;
262
+ }
263
+ export function parseSlackJobRequest(text) {
264
+ const withoutMentions = stripSlackUserMentions(text);
265
+ const normalized = normalizeForParsing(withoutMentions);
266
+ if (!normalized)
267
+ return null;
268
+ // Be tolerant of wrapped/copied URLs where whitespace/newlines split segments.
269
+ const compactForUrl = withoutMentions.replace(/\s+/g, '');
270
+ const prUrlMatch = compactForUrl.match(/https?:\/\/github\.com\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)/i);
271
+ const prPathMatch = compactForUrl.match(/\/pull\/(\d+)(?:[/?#]|$)/i);
272
+ const prHashMatch = withoutMentions.match(/(?:^|\s)#(\d+)(?:\s|$)/);
273
+ const conflictSignal = /\b(conflict|conflicts|merge conflict|merge issues?|rebase)\b/i.test(normalized);
274
+ const requestSignal = /\b(can someone|someone|anyone|please|need|look at|take a look|fix|review|check)\b/i.test(normalized);
275
+ const match = normalized.match(/\b(run|review|qa)\b(?:\s+(?:for|on)?\s*([a-z0-9./_-]+))?/i);
276
+ if (!match && !prUrlMatch && !prHashMatch)
277
+ return null;
278
+ const explicitJob = match?.[1]?.toLowerCase();
279
+ const hasPrReference = Boolean(prUrlMatch?.[3] ?? prPathMatch?.[1] ?? prHashMatch?.[1]);
280
+ const inferredReviewJob = conflictSignal || (hasPrReference && requestSignal);
281
+ const job = explicitJob ?? (inferredReviewJob ? 'review' : undefined);
282
+ if (!job || !['run', 'review', 'qa'].includes(job))
283
+ return null;
284
+ const prNumber = prUrlMatch?.[3] ?? prPathMatch?.[1] ?? prHashMatch?.[1];
285
+ const repoHintFromUrl = prUrlMatch?.[2]?.toLowerCase();
286
+ const candidates = [match?.[2]?.toLowerCase(), repoHintFromUrl].filter((value) => Boolean(value && !JOB_STOPWORDS.has(value)));
287
+ const projectHint = candidates[0];
288
+ const request = { job };
289
+ if (projectHint)
290
+ request.projectHint = projectHint;
291
+ if (prNumber)
292
+ request.prNumber = prNumber;
293
+ if (job === 'review' && conflictSignal)
294
+ request.fixConflicts = true;
295
+ return request;
296
+ }
297
+ function getPersonaDomain(persona) {
298
+ const role = persona.role.toLowerCase();
299
+ const expertise = (persona.soul?.expertise ?? []).join(' ').toLowerCase();
300
+ const blob = `${role} ${expertise}`;
301
+ if (/\bsecurity|auth|pentest|owasp|crypt|vuln\b/.test(blob))
302
+ return 'security';
303
+ if (/\bqa|quality|test|e2e\b/.test(blob))
304
+ return 'qa';
305
+ if (/\blead|architect|architecture|systems\b/.test(blob))
306
+ return 'lead';
307
+ if (/\bimplementer|developer|executor|engineer\b/.test(blob))
308
+ return 'dev';
309
+ return 'general';
310
+ }
311
+ export function scorePersonaForText(text, persona) {
312
+ const normalized = normalizeForParsing(stripSlackUserMentions(text));
313
+ if (!normalized)
314
+ return 0;
315
+ let score = 0;
316
+ const domain = getPersonaDomain(persona);
317
+ if (normalized.includes(persona.name.toLowerCase())) {
318
+ score += 12;
319
+ }
320
+ const securitySignal = /\b(security|auth|vuln|owasp|xss|csrf|token|permission|exploit|threat)\b/.test(normalized);
321
+ const qaSignal = /\b(qa|test|testing|bug|e2e|playwright|regression|flaky)\b/.test(normalized);
322
+ const leadSignal = /\b(architecture|architect|design|scalability|performance|tech debt|tradeoff|strategy)\b/.test(normalized);
323
+ const devSignal = /\b(implement|implementation|code|build|fix|patch|ship|pr)\b/.test(normalized);
324
+ if (securitySignal && domain === 'security')
325
+ score += 8;
326
+ if (qaSignal && domain === 'qa')
327
+ score += 8;
328
+ if (leadSignal && domain === 'lead')
329
+ score += 8;
330
+ if (devSignal && domain === 'dev')
331
+ score += 8;
332
+ const personaTokens = new Set([
333
+ ...persona.role.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length >= 3),
334
+ ...(persona.soul?.expertise ?? [])
335
+ .flatMap((s) => s.toLowerCase().split(/[^a-z0-9]+/))
336
+ .filter((t) => t.length >= 3),
337
+ ]);
338
+ const textTokens = normalized.split(/\s+/).filter((t) => t.length >= 3);
339
+ for (const token of textTokens) {
340
+ if (personaTokens.has(token)) {
341
+ score += 2;
342
+ }
343
+ }
344
+ return score;
345
+ }
346
+ export function selectFollowUpPersona(preferred, personas, text) {
347
+ if (personas.length === 0)
348
+ return preferred;
349
+ const preferredScore = scorePersonaForText(text, preferred);
350
+ let best = preferred;
351
+ let bestScore = preferredScore;
352
+ for (const persona of personas) {
353
+ const score = scorePersonaForText(text, persona);
354
+ if (score > bestScore) {
355
+ best = persona;
356
+ bestScore = score;
357
+ }
358
+ }
359
+ // Default to continuity unless another persona is clearly a better fit.
360
+ if (best.id !== preferred.id && bestScore >= preferredScore + 4 && bestScore >= 8) {
361
+ return best;
362
+ }
363
+ return preferred;
364
+ }
14
365
  function normalizeHandle(value) {
15
366
  return value.toLowerCase().replace(/[^a-z0-9]/g, '');
16
367
  }
@@ -100,6 +451,12 @@ export class SlackInteractionListener {
100
451
  _processedMessageKeys = new Set();
101
452
  _processedMessageOrder = [];
102
453
  _lastPersonaReplyAt = new Map();
454
+ _adHocThreadState = new Map();
455
+ _lastChannelActivityAt = new Map();
456
+ _lastProactiveAt = new Map();
457
+ _lastCodeWatchAt = new Map();
458
+ _lastCodeWatchSignatureAt = new Map();
459
+ _proactiveTimer = null;
103
460
  constructor(config) {
104
461
  this._config = config;
105
462
  const token = config.slack?.botToken ?? '';
@@ -128,9 +485,15 @@ export class SlackInteractionListener {
128
485
  const socket = new SocketModeClient({
129
486
  appToken: slack.appToken,
130
487
  });
131
- socket.on('events_api', (payload) => {
488
+ const onInboundEvent = (payload) => {
132
489
  void this._handleEventsApi(payload);
133
- });
490
+ };
491
+ // Socket Mode emits concrete event types (e.g. "app_mention", "message")
492
+ // for Events API payloads in current SDK versions.
493
+ socket.on('app_mention', onInboundEvent);
494
+ socket.on('message', onInboundEvent);
495
+ // Keep compatibility with alternate wrappers/older payload routing.
496
+ socket.on('events_api', onInboundEvent);
134
497
  socket.on('error', (err) => {
135
498
  const msg = err instanceof Error ? err.message : String(err);
136
499
  console.warn(`Slack interaction listener error: ${msg}`);
@@ -138,9 +501,11 @@ export class SlackInteractionListener {
138
501
  await socket.start();
139
502
  this._socketClient = socket;
140
503
  console.log('Slack interaction listener started (Socket Mode)');
504
+ this._startProactiveLoop();
141
505
  void this._postPersonaIntros();
142
506
  }
143
507
  async stop() {
508
+ this._stopProactiveLoop();
144
509
  if (!this._socketClient) {
145
510
  return;
146
511
  }
@@ -166,6 +531,10 @@ export class SlackInteractionListener {
166
531
  return;
167
532
  // Join all configured channels so the bot receives messages in them
168
533
  const channelIds = Object.values(slack.channels ?? {}).filter(Boolean);
534
+ const now = Date.now();
535
+ for (const channelId of channelIds) {
536
+ this._lastChannelActivityAt.set(channelId, now);
537
+ }
169
538
  for (const channelId of channelIds) {
170
539
  try {
171
540
  await this._slackClient.joinChannel(channelId);
@@ -197,7 +566,7 @@ export class SlackInteractionListener {
197
566
  if (!currentPersona.avatarUrl && slack.replicateApiToken) {
198
567
  try {
199
568
  console.log(`[slack] Generating avatar for ${persona.name}…`);
200
- const avatarUrl = await generatePersonaAvatar(persona.role, slack.replicateApiToken);
569
+ const avatarUrl = await generatePersonaAvatar(persona.name, persona.role, slack.replicateApiToken);
201
570
  if (avatarUrl) {
202
571
  currentPersona = repos.agentPersona.update(persona.id, { avatarUrl });
203
572
  console.log(`[slack] Avatar set for ${persona.name}: ${avatarUrl}`);
@@ -230,19 +599,33 @@ export class SlackInteractionListener {
230
599
  }
231
600
  }
232
601
  async _handleEventsApi(payload) {
233
- try {
234
- await payload.ack();
235
- }
236
- catch {
237
- // Ignore ack races/timeouts; processing can continue.
602
+ if (payload.ack) {
603
+ try {
604
+ await payload.ack();
605
+ }
606
+ catch {
607
+ // Ignore ack races/timeouts; processing can continue.
608
+ }
238
609
  }
239
- const event = payload.event;
610
+ const event = extractInboundEvent(payload);
240
611
  if (!event)
241
612
  return;
242
- // Log every event so we can debug what's arriving
243
- console.log(`[slack] event type=${event.type ?? '?'} subtype=${event.subtype ?? '-'} channel=${event.channel ?? '-'} user=${event.user ?? '-'} bot_id=${event.bot_id ?? '-'} text=${(event.text ?? '').slice(0, 80)}`);
244
613
  if (event.type !== 'message' && event.type !== 'app_mention')
245
614
  return;
615
+ const ignored = shouldIgnoreInboundSlackEvent(event, this._botUserId);
616
+ if (ignored) {
617
+ console.log(`[slack] ignored self/system event type=${event.type ?? '?'} subtype=${event.subtype ?? '-'} channel=${event.channel ?? '-'} user=${event.user ?? '-'} bot_id=${event.bot_id ?? '-'}`);
618
+ return;
619
+ }
620
+ console.log(`[slack] inbound human event type=${event.type ?? '?'} channel=${event.channel ?? '-'} user=${event.user ?? '-'} text=${(event.text ?? '').slice(0, 80)}`);
621
+ // Direct bot mentions arrive as app_mention; ignore the mirrored message event
622
+ // to avoid duplicate or out-of-order handling on the same Slack message ts.
623
+ if (event.type === 'message'
624
+ && this._botUserId
625
+ && (event.text ?? '').includes(`<@${this._botUserId}>`)) {
626
+ console.log('[slack] ignoring mirrored message event for direct bot mention');
627
+ return;
628
+ }
246
629
  try {
247
630
  await this._handleInboundMessage(event);
248
631
  }
@@ -276,6 +659,376 @@ export class SlackInteractionListener {
276
659
  const key = `${channel}:${threadTs}:${personaId}`;
277
660
  this._lastPersonaReplyAt.set(key, Date.now());
278
661
  }
662
+ _threadKey(channel, threadTs) {
663
+ return `${channel}:${threadTs}`;
664
+ }
665
+ _markChannelActivity(channel) {
666
+ this._lastChannelActivityAt.set(channel, Date.now());
667
+ }
668
+ _rememberAdHocThreadPersona(channel, threadTs, personaId) {
669
+ this._adHocThreadState.set(this._threadKey(channel, threadTs), {
670
+ personaId,
671
+ expiresAt: Date.now() + AD_HOC_THREAD_MEMORY_MS,
672
+ });
673
+ }
674
+ _getRememberedAdHocPersona(channel, threadTs, personas) {
675
+ const key = this._threadKey(channel, threadTs);
676
+ const remembered = this._adHocThreadState.get(key);
677
+ if (!remembered)
678
+ return null;
679
+ if (Date.now() > remembered.expiresAt) {
680
+ this._adHocThreadState.delete(key);
681
+ return null;
682
+ }
683
+ return personas.find((p) => p.id === remembered.personaId) ?? null;
684
+ }
685
+ _pickRandomPersona(personas, channel, threadTs) {
686
+ if (personas.length === 0)
687
+ return null;
688
+ const available = personas.filter((p) => !this._isPersonaOnCooldown(channel, threadTs, p.id));
689
+ const pool = available.length > 0 ? available : personas;
690
+ return pool[Math.floor(Math.random() * pool.length)] ?? null;
691
+ }
692
+ _findPersonaByName(personas, name) {
693
+ const target = name.toLowerCase();
694
+ return personas.find((p) => p.name.toLowerCase() === target) ?? null;
695
+ }
696
+ _buildProjectContext(channel, projects) {
697
+ if (projects.length === 0)
698
+ return '';
699
+ const inChannel = projects.find((p) => p.slackChannelId === channel);
700
+ const names = projects.map((p) => p.name).join(', ');
701
+ if (inChannel) {
702
+ return `Current channel project: ${inChannel.name}. Registered projects: ${names}.`;
703
+ }
704
+ return `Registered projects: ${names}.`;
705
+ }
706
+ _buildRoadmapContext(channel, projects) {
707
+ if (projects.length === 0)
708
+ return '';
709
+ const parts = [];
710
+ for (const project of projects) {
711
+ try {
712
+ const status = getRoadmapStatus(project.path, this._config);
713
+ if (!status.found || status.items.length === 0)
714
+ continue;
715
+ const pending = status.items.filter((i) => !i.processed && !i.checked);
716
+ const done = status.items.filter((i) => i.processed);
717
+ const total = status.items.length;
718
+ let summary = `${project.name}: ${done.length}/${total} roadmap items done`;
719
+ if (pending.length > 0) {
720
+ const nextItems = pending.slice(0, 3).map((i) => i.title);
721
+ summary += `. Next up: ${nextItems.join(', ')}`;
722
+ }
723
+ if (done.length === total) {
724
+ summary += ' (all complete)';
725
+ }
726
+ parts.push(summary);
727
+ }
728
+ catch {
729
+ // Skip projects where roadmap can't be read
730
+ }
731
+ }
732
+ return parts.join('\n');
733
+ }
734
+ _resolveProjectByHint(projects, hint) {
735
+ const normalizedHint = normalizeProjectRef(hint);
736
+ if (!normalizedHint)
737
+ return null;
738
+ const byNameExact = projects.find((p) => normalizeProjectRef(p.name) === normalizedHint);
739
+ if (byNameExact)
740
+ return byNameExact;
741
+ const byPathExact = projects.find((p) => {
742
+ const base = p.path.split('/').pop() ?? '';
743
+ return normalizeProjectRef(base) === normalizedHint;
744
+ });
745
+ if (byPathExact)
746
+ return byPathExact;
747
+ const byNameContains = projects.find((p) => normalizeProjectRef(p.name).includes(normalizedHint));
748
+ if (byNameContains)
749
+ return byNameContains;
750
+ return projects.find((p) => {
751
+ const base = p.path.split('/').pop() ?? '';
752
+ return normalizeProjectRef(base).includes(normalizedHint);
753
+ }) ?? null;
754
+ }
755
+ _resolveTargetProject(channel, projects, projectHint) {
756
+ if (projectHint) {
757
+ return this._resolveProjectByHint(projects, projectHint);
758
+ }
759
+ const byChannel = projects.find((p) => p.slackChannelId === channel);
760
+ if (byChannel)
761
+ return byChannel;
762
+ if (projects.length === 1)
763
+ return projects[0];
764
+ return null;
765
+ }
766
+ _isMessageAddressedToBot(event) {
767
+ if (event.type === 'app_mention')
768
+ return true;
769
+ const text = normalizeForParsing(stripSlackUserMentions(event.text ?? ''));
770
+ return /^night[-\s]?watch\b/.test(text) || /^nw\b/.test(text);
771
+ }
772
+ _randomInt(min, max) {
773
+ if (max <= min)
774
+ return min;
775
+ return Math.floor(Math.random() * (max - min + 1)) + min;
776
+ }
777
+ _reactionCandidatesForPersona(persona) {
778
+ const role = persona.role.toLowerCase();
779
+ if (role.includes('security'))
780
+ return ['eyes', 'thinking_face', 'shield', 'thumbsup'];
781
+ if (role.includes('qa') || role.includes('quality'))
782
+ return ['test_tube', 'mag', 'thinking_face', 'thumbsup'];
783
+ if (role.includes('lead') || role.includes('architect'))
784
+ return ['thinking_face', 'thumbsup', 'memo', 'eyes'];
785
+ if (role.includes('implementer') || role.includes('developer'))
786
+ return ['wrench', 'hammer_and_wrench', 'thumbsup', 'eyes'];
787
+ return ['eyes', 'thinking_face', 'thumbsup', 'wave'];
788
+ }
789
+ async _maybeReactToHumanMessage(channel, messageTs, persona) {
790
+ if (Math.random() > HUMAN_REACTION_PROBABILITY) {
791
+ return;
792
+ }
793
+ const candidates = this._reactionCandidatesForPersona(persona);
794
+ const reaction = candidates[this._randomInt(0, candidates.length - 1)];
795
+ await sleep(this._randomInt(REACTION_DELAY_MIN_MS, REACTION_DELAY_MAX_MS));
796
+ try {
797
+ await this._slackClient.addReaction(channel, messageTs, reaction);
798
+ }
799
+ catch {
800
+ // Ignore reaction failures (permissions, already reacted, etc.)
801
+ }
802
+ }
803
+ async _applyHumanResponseTiming(channel, messageTs, persona) {
804
+ await this._maybeReactToHumanMessage(channel, messageTs, persona);
805
+ await sleep(this._randomInt(RESPONSE_DELAY_MIN_MS, RESPONSE_DELAY_MAX_MS));
806
+ }
807
+ async _spawnNightWatchJob(job, project, channel, threadTs, persona, opts) {
808
+ const invocationArgs = buildCurrentCliInvocation([job]);
809
+ const prRef = opts?.prNumber ? ` PR #${opts.prNumber}` : '';
810
+ if (!invocationArgs) {
811
+ console.warn(`[slack][job] ${persona.name} cannot start ${job} for ${project.name}${prRef ? ` (${prRef.trim()})` : ''}: CLI entry path unavailable`);
812
+ await this._slackClient.postAsAgent(channel, `Can't start that ${job} right now — runtime issue. Checking it.`, persona, threadTs);
813
+ this._markChannelActivity(channel);
814
+ this._markPersonaReply(channel, threadTs, persona.id);
815
+ return;
816
+ }
817
+ console.log(`[slack][job] persona=${persona.name} project=${project.name}${opts?.prNumber ? ` pr=${opts.prNumber}` : ''} spawn=${formatCommandForLog(process.execPath, invocationArgs)}`);
818
+ const child = spawn(process.execPath, invocationArgs, {
819
+ cwd: project.path,
820
+ env: {
821
+ ...process.env,
822
+ NW_EXECUTION_CONTEXT: 'agent',
823
+ ...(opts?.prNumber ? { NW_TARGET_PR: opts.prNumber } : {}),
824
+ ...(opts?.fixConflicts
825
+ ? {
826
+ NW_SLACK_FEEDBACK: JSON.stringify({
827
+ source: 'slack',
828
+ kind: 'merge_conflict_resolution',
829
+ prNumber: opts.prNumber ?? '',
830
+ changes: 'Resolve merge conflicts and stabilize the PR for re-review.',
831
+ }),
832
+ }
833
+ : {}),
834
+ },
835
+ stdio: ['ignore', 'pipe', 'pipe'],
836
+ });
837
+ console.log(`[slack][job] ${persona.name} spawned ${job} for ${project.name}${opts?.prNumber ? ` (PR #${opts.prNumber})` : ''} pid=${child.pid ?? 'unknown'}`);
838
+ let output = '';
839
+ let errored = false;
840
+ const appendOutput = (chunk) => {
841
+ output += chunk.toString();
842
+ if (output.length > MAX_JOB_OUTPUT_CHARS) {
843
+ output = output.slice(-MAX_JOB_OUTPUT_CHARS);
844
+ }
845
+ };
846
+ child.stdout?.on('data', appendOutput);
847
+ child.stderr?.on('data', appendOutput);
848
+ child.on('error', async (err) => {
849
+ errored = true;
850
+ console.warn(`[slack][job] ${persona.name} ${job} spawn error for ${project.name}${opts?.prNumber ? ` (PR #${opts.prNumber})` : ''}: ${err.message}`);
851
+ await this._slackClient.postAsAgent(channel, `Couldn't kick off that ${job}. Error logged — looking into it.`, persona, threadTs);
852
+ this._markChannelActivity(channel);
853
+ this._markPersonaReply(channel, threadTs, persona.id);
854
+ });
855
+ child.on('close', async (code) => {
856
+ if (errored)
857
+ return;
858
+ console.log(`[slack][job] ${persona.name} ${job} finished for ${project.name}${opts?.prNumber ? ` (PR #${opts.prNumber})` : ''} exit=${code ?? 'unknown'}`);
859
+ const parsed = parseScriptResult(output);
860
+ const status = parsed?.status ? ` (${parsed.status})` : '';
861
+ const detail = extractLastMeaningfulLines(output);
862
+ if (code === 0) {
863
+ const doneMessage = job === 'review'
864
+ ? `Review done${prRef ? ` on${prRef}` : ''}.`
865
+ : job === 'qa'
866
+ ? `QA pass done${prRef ? ` on${prRef}` : ''}.`
867
+ : `Run finished${prRef ? ` for${prRef}` : ''}.`;
868
+ await this._slackClient.postAsAgent(channel, doneMessage, persona, threadTs);
869
+ }
870
+ else {
871
+ if (detail) {
872
+ console.warn(`[slack][job] ${persona.name} ${job} failure detail: ${detail}`);
873
+ }
874
+ await this._slackClient.postAsAgent(channel, `Hit a snag running ${job}${prRef ? ` on${prRef}` : ''}. Logged the details — looking into it.`, persona, threadTs);
875
+ }
876
+ if (code !== 0 && status) {
877
+ console.warn(`[slack][job] ${persona.name} ${job} status=${status.replace(/[()]/g, '')}`);
878
+ }
879
+ this._markChannelActivity(channel);
880
+ this._markPersonaReply(channel, threadTs, persona.id);
881
+ });
882
+ }
883
+ async _triggerSlackJobIfRequested(event, channel, threadTs, messageTs, personas) {
884
+ const request = parseSlackJobRequest(event.text ?? '');
885
+ if (!request)
886
+ return false;
887
+ const addressedToBot = this._isMessageAddressedToBot(event);
888
+ const normalized = normalizeForParsing(stripSlackUserMentions(event.text ?? ''));
889
+ const teamRequestLanguage = /\b(can someone|someone|anyone|please|need)\b/i.test(normalized);
890
+ const startsWithCommand = /^(run|review|qa)\b/i.test(normalized);
891
+ if (!addressedToBot
892
+ && !request.prNumber
893
+ && !request.fixConflicts
894
+ && !teamRequestLanguage
895
+ && !startsWithCommand) {
896
+ return false;
897
+ }
898
+ const repos = getRepositories();
899
+ const projects = repos.projectRegistry.getAll();
900
+ const persona = (request.job === 'run' ? this._findPersonaByName(personas, 'Dev') : null)
901
+ ?? (request.job === 'qa' ? this._findPersonaByName(personas, 'Priya') : null)
902
+ ?? (request.job === 'review' ? this._findPersonaByName(personas, 'Carlos') : null)
903
+ ?? this._pickRandomPersona(personas, channel, threadTs)
904
+ ?? personas[0];
905
+ if (!persona)
906
+ return false;
907
+ const targetProject = this._resolveTargetProject(channel, projects, request.projectHint);
908
+ if (!targetProject) {
909
+ const projectNames = projects.map((p) => p.name).join(', ') || '(none registered)';
910
+ await this._slackClient.postAsAgent(channel, `Which project? Registered: ${projectNames}.`, persona, threadTs);
911
+ this._markChannelActivity(channel);
912
+ this._markPersonaReply(channel, threadTs, persona.id);
913
+ return true;
914
+ }
915
+ console.log(`[slack][job] routing job=${request.job} to persona=${persona.name} project=${targetProject.name}${request.prNumber ? ` pr=${request.prNumber}` : ''}${request.fixConflicts ? ' fix_conflicts=true' : ''}`);
916
+ const planLine = request.job === 'review'
917
+ ? `On it${request.prNumber ? ` — PR #${request.prNumber}` : ''}${request.fixConflicts ? ', including the conflicts' : ''}.`
918
+ : request.job === 'qa'
919
+ ? `Running QA${request.prNumber ? ` on #${request.prNumber}` : ''}.`
920
+ : `Starting the run${request.prNumber ? ` for #${request.prNumber}` : ''}.`;
921
+ await this._applyHumanResponseTiming(channel, messageTs, persona);
922
+ await this._slackClient.postAsAgent(channel, `${planLine}`, persona, threadTs);
923
+ console.log(`[slack][job] ${persona.name} accepted job=${request.job} project=${targetProject.name}${request.prNumber ? ` pr=${request.prNumber}` : ''}`);
924
+ this._markChannelActivity(channel);
925
+ this._markPersonaReply(channel, threadTs, persona.id);
926
+ this._rememberAdHocThreadPersona(channel, threadTs, persona.id);
927
+ await this._spawnNightWatchJob(request.job, targetProject, channel, threadTs, persona, { prNumber: request.prNumber, fixConflicts: request.fixConflicts });
928
+ return true;
929
+ }
930
+ _resolveProactiveChannelForProject(project) {
931
+ const slack = this._config.slack;
932
+ if (!slack)
933
+ return null;
934
+ return project.slackChannelId || slack.channels.eng || null;
935
+ }
936
+ async _runProactiveCodeWatch(projects, now) {
937
+ for (const project of projects) {
938
+ const channel = this._resolveProactiveChannelForProject(project);
939
+ if (!channel)
940
+ continue;
941
+ const lastScan = this._lastCodeWatchAt.get(project.path) ?? 0;
942
+ if (now - lastScan < PROACTIVE_CODEWATCH_MIN_INTERVAL_MS) {
943
+ continue;
944
+ }
945
+ this._lastCodeWatchAt.set(project.path, now);
946
+ const candidate = detectCodeWatchCandidate(project.path);
947
+ if (!candidate) {
948
+ continue;
949
+ }
950
+ const signatureKey = `${project.path}:${candidate.signature}`;
951
+ const lastSeen = this._lastCodeWatchSignatureAt.get(signatureKey) ?? 0;
952
+ if (now - lastSeen < PROACTIVE_CODEWATCH_REPEAT_COOLDOWN_MS) {
953
+ continue;
954
+ }
955
+ const ref = `codewatch-${candidate.signature}`;
956
+ const context = `Project: ${project.name}\n` +
957
+ `Signal: ${candidate.summary}\n` +
958
+ `Location: ${candidate.relativePath}:${candidate.line}\n` +
959
+ `Snippet: ${candidate.snippet}\n` +
960
+ `Question: Is this intentional, or should we patch it now?`;
961
+ console.log(`[slack][codewatch] project=${project.name} location=${candidate.relativePath}:${candidate.line} signal=${candidate.type}`);
962
+ try {
963
+ await this._engine.startDiscussion({
964
+ type: 'code_watch',
965
+ projectPath: project.path,
966
+ ref,
967
+ context,
968
+ channelId: channel,
969
+ });
970
+ this._lastCodeWatchSignatureAt.set(signatureKey, now);
971
+ this._markChannelActivity(channel);
972
+ return;
973
+ }
974
+ catch (err) {
975
+ const msg = err instanceof Error ? err.message : String(err);
976
+ console.warn(`[slack][codewatch] failed for ${project.name}: ${msg}`);
977
+ }
978
+ }
979
+ }
980
+ _startProactiveLoop() {
981
+ if (this._proactiveTimer)
982
+ return;
983
+ this._proactiveTimer = setInterval(() => {
984
+ void this._sendProactiveMessages();
985
+ }, PROACTIVE_SWEEP_INTERVAL_MS);
986
+ this._proactiveTimer.unref?.();
987
+ }
988
+ _stopProactiveLoop() {
989
+ if (!this._proactiveTimer)
990
+ return;
991
+ clearInterval(this._proactiveTimer);
992
+ this._proactiveTimer = null;
993
+ }
994
+ async _sendProactiveMessages() {
995
+ const slack = this._config.slack;
996
+ if (!slack?.enabled || !slack.discussionEnabled)
997
+ return;
998
+ const channelIds = Object.values(slack.channels ?? {}).filter(Boolean);
999
+ if (channelIds.length === 0)
1000
+ return;
1001
+ const repos = getRepositories();
1002
+ const personas = repos.agentPersona.getActive();
1003
+ if (personas.length === 0)
1004
+ return;
1005
+ const now = Date.now();
1006
+ const projects = repos.projectRegistry.getAll();
1007
+ await this._runProactiveCodeWatch(projects, now);
1008
+ for (const channel of channelIds) {
1009
+ const lastActivity = this._lastChannelActivityAt.get(channel) ?? now;
1010
+ const lastProactive = this._lastProactiveAt.get(channel) ?? 0;
1011
+ if (now - lastActivity < PROACTIVE_IDLE_MS)
1012
+ continue;
1013
+ if (now - lastProactive < PROACTIVE_MIN_INTERVAL_MS)
1014
+ continue;
1015
+ const persona = this._pickRandomPersona(personas, channel, `${now}`) ?? personas[0];
1016
+ if (!persona)
1017
+ continue;
1018
+ const projectContext = this._buildProjectContext(channel, projects);
1019
+ const roadmapContext = this._buildRoadmapContext(channel, projects);
1020
+ try {
1021
+ await this._engine.postProactiveMessage(channel, persona, projectContext, roadmapContext);
1022
+ this._lastProactiveAt.set(channel, now);
1023
+ this._markChannelActivity(channel);
1024
+ console.log(`[slack] proactive message posted by ${persona.name} in ${channel}`);
1025
+ }
1026
+ catch (err) {
1027
+ const msg = err instanceof Error ? err.message : String(err);
1028
+ console.warn(`Slack proactive message failed: ${msg}`);
1029
+ }
1030
+ }
1031
+ }
279
1032
  async _handleInboundMessage(event) {
280
1033
  if (shouldIgnoreInboundSlackEvent(event, this._botUserId)) {
281
1034
  console.log(`[slack] ignoring event — failed shouldIgnore check (user=${event.user}, bot_id=${event.bot_id ?? '-'}, subtype=${event.subtype ?? '-'})`);
@@ -285,7 +1038,8 @@ export class SlackInteractionListener {
285
1038
  const ts = event.ts;
286
1039
  const threadTs = event.thread_ts ?? ts;
287
1040
  const text = event.text ?? '';
288
- const messageKey = `${channel}:${ts}`;
1041
+ const messageKey = buildInboundMessageKey(channel, ts, event.type);
1042
+ this._markChannelActivity(channel);
289
1043
  // Deduplicate retried/replayed events to prevent response loops.
290
1044
  if (!this._rememberMessageKey(messageKey)) {
291
1045
  console.log(`[slack] duplicate event ${messageKey} — skipping`);
@@ -293,14 +1047,19 @@ export class SlackInteractionListener {
293
1047
  }
294
1048
  const repos = getRepositories();
295
1049
  const personas = repos.agentPersona.getActive();
1050
+ const projects = repos.projectRegistry.getAll();
1051
+ const projectContext = this._buildProjectContext(channel, projects);
1052
+ if (await this._triggerSlackJobIfRequested(event, channel, threadTs, ts, personas)) {
1053
+ return;
1054
+ }
296
1055
  // @mention matching: "@maya ..."
297
1056
  let mentionedPersonas = resolveMentionedPersonas(text, personas);
298
- // For app_mention events (bot was @-tagged by Slack), also try plain-name matching.
299
- // Text arrives as "<@UBOTID> maya check this" the @-regex won't find "maya".
300
- if (mentionedPersonas.length === 0 && event.type === 'app_mention') {
1057
+ // Also try plain-name matching (e.g. "Carlos, are you there?").
1058
+ // For app_mention text like "<@UBOTID> maya check this", the @-regex won't find "maya".
1059
+ if (mentionedPersonas.length === 0) {
301
1060
  mentionedPersonas = resolvePersonasByPlainName(text, personas);
302
1061
  if (mentionedPersonas.length > 0) {
303
- console.log(`[slack] plain-name match in app_mention: ${mentionedPersonas.map((p) => p.name).join(', ')}`);
1062
+ console.log(`[slack] plain-name match: ${mentionedPersonas.map((p) => p.name).join(', ')}`);
304
1063
  }
305
1064
  }
306
1065
  // Persona mentioned → respond regardless of whether a formal discussion exists.
@@ -315,14 +1074,18 @@ export class SlackInteractionListener {
315
1074
  console.log(`[slack] ${persona.name} is on cooldown — skipping`);
316
1075
  continue;
317
1076
  }
1077
+ await this._applyHumanResponseTiming(channel, ts, persona);
318
1078
  if (discussion) {
319
1079
  await this._engine.contributeAsAgent(discussion.id, persona);
320
1080
  }
321
1081
  else {
322
- await this._engine.replyAsAgent(channel, threadTs, text, persona);
1082
+ await this._engine.replyAsAgent(channel, threadTs, text, persona, projectContext);
323
1083
  }
324
1084
  this._markPersonaReply(channel, threadTs, persona.id);
325
1085
  }
1086
+ if (!discussion && mentionedPersonas[0]) {
1087
+ this._rememberAdHocThreadPersona(channel, threadTs, mentionedPersonas[0].id);
1088
+ }
326
1089
  return;
327
1090
  }
328
1091
  console.log(`[slack] no persona match — checking for active discussion in ${channel}:${threadTs}`);
@@ -331,11 +1094,40 @@ export class SlackInteractionListener {
331
1094
  .slackDiscussion
332
1095
  .getActive('')
333
1096
  .find((d) => d.channelId === channel && d.threadTs === threadTs);
334
- if (!discussion) {
335
- console.log(`[slack] no active discussion found — ignoring message`);
1097
+ if (discussion) {
1098
+ await this._engine.handleHumanMessage(channel, threadTs, text, event.user);
1099
+ return;
1100
+ }
1101
+ // Continue ad-hoc threads even without a persisted discussion.
1102
+ const rememberedPersona = this._getRememberedAdHocPersona(channel, threadTs, personas);
1103
+ if (rememberedPersona) {
1104
+ const followUpPersona = selectFollowUpPersona(rememberedPersona, personas, text);
1105
+ if (followUpPersona.id !== rememberedPersona.id) {
1106
+ console.log(`[slack] handing off ad-hoc thread from ${rememberedPersona.name} to ${followUpPersona.name} based on topic`);
1107
+ }
1108
+ else {
1109
+ console.log(`[slack] continuing ad-hoc thread with ${rememberedPersona.name}`);
1110
+ }
1111
+ await this._applyHumanResponseTiming(channel, ts, followUpPersona);
1112
+ await this._engine.replyAsAgent(channel, threadTs, text, followUpPersona, projectContext);
1113
+ this._markPersonaReply(channel, threadTs, followUpPersona.id);
1114
+ this._rememberAdHocThreadPersona(channel, threadTs, followUpPersona.id);
336
1115
  return;
337
1116
  }
338
- await this._engine.handleHumanMessage(channel, threadTs, text, event.user);
1117
+ // Keep the channel alive: direct mentions and ambient greetings get a random responder.
1118
+ const shouldAutoEngage = event.type === 'app_mention' || isAmbientTeamMessage(text);
1119
+ if (shouldAutoEngage) {
1120
+ const randomPersona = this._pickRandomPersona(personas, channel, threadTs);
1121
+ if (randomPersona) {
1122
+ console.log(`[slack] auto-engaging via ${randomPersona.name}`);
1123
+ await this._applyHumanResponseTiming(channel, ts, randomPersona);
1124
+ await this._engine.replyAsAgent(channel, threadTs, text, randomPersona, projectContext);
1125
+ this._markPersonaReply(channel, threadTs, randomPersona.id);
1126
+ this._rememberAdHocThreadPersona(channel, threadTs, randomPersona.id);
1127
+ return;
1128
+ }
1129
+ }
1130
+ console.log(`[slack] no active discussion found — ignoring message`);
339
1131
  }
340
1132
  }
341
1133
  //# sourceMappingURL=interaction-listener.js.map