@jonit-dev/night-watch-cli 1.7.24 → 1.7.27

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 (55) hide show
  1. package/dist/shared/types.d.ts +2 -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 +10 -2
  20. package/dist/src/slack/client.d.ts.map +1 -1
  21. package/dist/src/slack/client.js +38 -5
  22. package/dist/src/slack/client.js.map +1 -1
  23. package/dist/src/slack/deliberation.d.ts +26 -1
  24. package/dist/src/slack/deliberation.d.ts.map +1 -1
  25. package/dist/src/slack/deliberation.js +325 -53
  26. package/dist/src/slack/deliberation.js.map +1 -1
  27. package/dist/src/slack/interaction-listener.d.ts +54 -0
  28. package/dist/src/slack/interaction-listener.d.ts.map +1 -1
  29. package/dist/src/slack/interaction-listener.js +830 -13
  30. package/dist/src/slack/interaction-listener.js.map +1 -1
  31. package/dist/src/storage/repositories/index.d.ts.map +1 -1
  32. package/dist/src/storage/repositories/index.js +2 -0
  33. package/dist/src/storage/repositories/index.js.map +1 -1
  34. package/dist/src/storage/repositories/interfaces.d.ts +1 -0
  35. package/dist/src/storage/repositories/interfaces.d.ts.map +1 -1
  36. package/dist/src/storage/repositories/sqlite/agent-persona-repository.d.ts +5 -0
  37. package/dist/src/storage/repositories/sqlite/agent-persona-repository.d.ts.map +1 -1
  38. package/dist/src/storage/repositories/sqlite/agent-persona-repository.js +243 -100
  39. package/dist/src/storage/repositories/sqlite/agent-persona-repository.js.map +1 -1
  40. package/dist/src/utils/avatar-generator.d.ts +1 -1
  41. package/dist/src/utils/avatar-generator.d.ts.map +1 -1
  42. package/dist/src/utils/avatar-generator.js +62 -17
  43. package/dist/src/utils/avatar-generator.js.map +1 -1
  44. package/dist/src/utils/notify.d.ts +1 -0
  45. package/dist/src/utils/notify.d.ts.map +1 -1
  46. package/dist/src/utils/notify.js +13 -1
  47. package/dist/src/utils/notify.js.map +1 -1
  48. package/package.json +1 -1
  49. package/scripts/night-watch-pr-reviewer-cron.sh +36 -8
  50. package/scripts/night-watch-qa-cron.sh +15 -3
  51. package/templates/night-watch-pr-reviewer.md +46 -17
  52. package/web/dist/avatars/carlos.webp +0 -0
  53. package/web/dist/avatars/dev.webp +0 -0
  54. package/web/dist/avatars/maya.webp +0 -0
  55. package/web/dist/avatars/priya.webp +0 -0
@@ -5,10 +5,13 @@
5
5
  */
6
6
  import { compileSoul } from "../agents/soul-compiler.js";
7
7
  import { getRepositories } from "../storage/repositories/index.js";
8
+ import { createBoardProvider } from "../board/factory.js";
8
9
  const MAX_ROUNDS = 3;
9
- const MESSAGE_DELAY_MS = 1500; // Rate limit: 1.5s between posts
10
+ const HUMAN_DELAY_MIN_MS = 20_000; // Minimum pause between agent replies (20s)
11
+ const HUMAN_DELAY_MAX_MS = 60_000; // Maximum pause between agent replies (60s)
10
12
  const DISCUSSION_RESUME_DELAY_MS = 60_000;
11
13
  const DISCUSSION_REPLAY_GUARD_MS = 30 * 60_000;
14
+ const MAX_HUMANIZED_SENTENCES = 2;
12
15
  const inFlightDiscussionStarts = new Map();
13
16
  function discussionStartKey(trigger) {
14
17
  return `${trigger.projectPath}:${trigger.type}:${trigger.ref}`;
@@ -19,6 +22,13 @@ function discussionStartKey(trigger) {
19
22
  function sleep(ms) {
20
23
  return new Promise(resolve => setTimeout(resolve, ms));
21
24
  }
25
+ /**
26
+ * Return a random delay in the human-like range so replies don't arrive
27
+ * in an obviously robotic cadence.
28
+ */
29
+ function humanDelay() {
30
+ return HUMAN_DELAY_MIN_MS + Math.random() * (HUMAN_DELAY_MAX_MS - HUMAN_DELAY_MIN_MS);
31
+ }
22
32
  /**
23
33
  * Determine which Slack channel to use for a trigger type
24
34
  */
@@ -36,6 +46,8 @@ function getChannelForTrigger(trigger, config) {
36
46
  return slack.channels.incidents;
37
47
  case 'prd_kickoff':
38
48
  return slack.channels.eng; // Callers should populate trigger.channelId with proj channel
49
+ case 'code_watch':
50
+ return slack.channels.eng;
39
51
  default:
40
52
  return slack.channels.eng;
41
53
  }
@@ -87,6 +99,12 @@ function getParticipatingPersonas(triggerType, personas) {
87
99
  add(dev);
88
100
  add(carlos);
89
101
  break;
102
+ case 'code_watch':
103
+ add(dev);
104
+ add(carlos);
105
+ add(maya);
106
+ add(priya);
107
+ break;
90
108
  default:
91
109
  add(carlos);
92
110
  break;
@@ -145,42 +163,71 @@ function resolvePersonaAIConfig(persona, config) {
145
163
  function buildOpeningMessage(trigger) {
146
164
  switch (trigger.type) {
147
165
  case 'pr_review':
148
- return `Just opened a PR — ${trigger.ref}${trigger.prUrl ? ` ${trigger.prUrl}` : ''}. Ready for review. 🔨`;
166
+ return `Opened ${trigger.ref}${trigger.prUrl ? ` ${trigger.prUrl}` : ''}. Ready for eyes.`;
149
167
  case 'build_failure':
150
- return `Build failure on ${trigger.ref}. Looking into it now 🔍\n\n${trigger.context.slice(0, 500)}`;
168
+ return `Build broke on ${trigger.ref}. Looking into it.\n\n${trigger.context.slice(0, 500)}`;
151
169
  case 'prd_kickoff':
152
- return `Picking up PRD: ${trigger.ref}. Starting implementation. 🚀`;
170
+ return `Picking up ${trigger.ref}. Going to start carving out the implementation.`;
171
+ case 'code_watch': {
172
+ const CODE_WATCH_OPENERS = [
173
+ 'Something caught my eye during a scan — want to get a second opinion on this.',
174
+ 'Quick flag from the latest code scan. Might be nothing, might be worth patching.',
175
+ 'Scanner flagged this one. Thought it was worth surfacing before it bites us.',
176
+ 'Flagging something from the codebase — could be intentional, but it pinged the scanner.',
177
+ 'Spotted this during a scan. Curious if it\'s expected or something we should fix.',
178
+ ];
179
+ const hash = trigger.ref.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0);
180
+ const opener = CODE_WATCH_OPENERS[hash % CODE_WATCH_OPENERS.length];
181
+ return `${opener}\n\n${trigger.context.slice(0, 600)}`;
182
+ }
153
183
  default:
154
184
  return trigger.context.slice(0, 500);
155
185
  }
156
186
  }
187
+ /**
188
+ * Parse the structured code_watch context string and derive a git-style issue title.
189
+ */
190
+ function buildIssueTitleFromTrigger(trigger) {
191
+ const signalMatch = trigger.context.match(/^Signal: (.+)$/m);
192
+ const locationMatch = trigger.context.match(/^Location: (.+)$/m);
193
+ const signal = signalMatch?.[1] ?? 'code signal';
194
+ const location = locationMatch?.[1] ?? 'unknown location';
195
+ return `fix: ${signal} at ${location}`;
196
+ }
157
197
  /**
158
198
  * Build the contribution prompt for an agent's AI call.
159
199
  * This is what gets sent to the AI provider to generate the agent's message.
160
200
  */
161
201
  function buildContributionPrompt(persona, trigger, threadHistory, round) {
162
- return `You are ${persona.name}, ${persona.role}, participating in a Slack thread with your team.
202
+ const isFirstRound = round === 1;
203
+ const isFinalRound = round >= MAX_ROUNDS;
204
+ return `You are ${persona.name}, ${persona.role}.
205
+ You're in a Slack thread with your teammates — Dev (implementer), Carlos (tech lead), Maya (security), and Priya (QA). This is a real conversation, not a report.
163
206
 
164
- ## Thread Context
165
207
  Trigger: ${trigger.type} — ${trigger.ref}
166
- Round: ${round} of ${MAX_ROUNDS}
208
+ Round: ${round}/${MAX_ROUNDS}${isFinalRound ? ' (final round — wrap up)' : ''}
167
209
 
168
210
  ## Context
169
211
  ${trigger.context.slice(0, 2000)}
170
212
 
171
213
  ## Thread So Far
172
- ${threadHistory || '(No messages yet)'}
214
+ ${threadHistory || '(Thread just started)'}
173
215
 
174
- ## Your Task
175
- Review the above from your specific expertise angle. Post a SHORT Slack message (2-3 sentences max).
176
- - This is Slack chat, not a document. Be concise.
177
- - Speak only to your domain don't repeat what others said.
178
- - Use your natural emoji style.
179
- - If everything looks fine from your angle, just say so briefly.
180
- - If you have a concern, state it clearly with a specific fix suggestion.
181
- - If you have no concerns and others seem satisfied, you can just react positively.
216
+ ## How to respond
217
+ Write a short Slack message 1 to 2 sentences. This is chat, not documentation.
218
+ ${isFirstRound ? '- First round: give your initial take from your angle. Be specific.' : '- Follow-up round: respond to what others said. Agree, push back, or add something new.'}
219
+ - Talk like a teammate, not an assistant. No pleasantries, no filler.
220
+ - Stay in your lane only comment on your domain unless something crosses into it.
221
+ - You can name-drop teammates when handing off ("Maya should look at the auth here").
222
+ - If nothing concerns you, a brief "nothing from me" or a short acknowledgment is fine.
223
+ - If you have a concern, name it specifically and suggest a direction.
224
+ - No markdown formatting. No bullet lists. No headings. Just a message.
225
+ - Emojis: use one only if it genuinely fits. Default to none.
226
+ - Never start with "Great question", "Of course", "I hope this helps", or similar.
227
+ - Never say "as an AI" or break character.
228
+ ${isFinalRound ? '- Final round: be decisive. State your position clearly.' : ''}
182
229
 
183
- Write ONLY your message, nothing else. Do not include your name or any prefix.`;
230
+ Write ONLY your message. No name prefix, no labels.`;
184
231
  }
185
232
  /**
186
233
  * Call the AI provider to generate an agent contribution.
@@ -240,14 +287,106 @@ async function callAIForContribution(persona, config, contributionPrompt) {
240
287
  }
241
288
  return `[${persona.name}: No AI provider configured]`;
242
289
  }
290
+ const CANNED_PHRASE_PREFIXES = [
291
+ /^great question[,.! ]*/i,
292
+ /^of course[,.! ]*/i,
293
+ /^certainly[,.! ]*/i,
294
+ /^you['’]re absolutely right[,.! ]*/i,
295
+ /^i hope this helps[,.! ]*/i,
296
+ ];
297
+ function limitEmojiCount(text, maxEmojis) {
298
+ let seen = 0;
299
+ return text.replace(/[\p{Extended_Pictographic}]/gu, (m) => {
300
+ seen += 1;
301
+ return seen <= maxEmojis ? m : '';
302
+ });
303
+ }
304
+ function isFacialEmoji(char) {
305
+ return /[\u{1F600}-\u{1F64F}\u{1F910}-\u{1F92F}\u{1F970}-\u{1F97A}]/u.test(char);
306
+ }
307
+ function applyEmojiPolicy(text, allowEmoji, allowNonFacialEmoji) {
308
+ if (!allowEmoji) {
309
+ return text.replace(/[\p{Extended_Pictographic}]/gu, '');
310
+ }
311
+ const emojis = Array.from(text.matchAll(/[\p{Extended_Pictographic}]/gu)).map((m) => m[0]);
312
+ if (emojis.length === 0)
313
+ return text;
314
+ const chosenFacial = emojis.find((e) => isFacialEmoji(e));
315
+ const chosen = chosenFacial ?? (allowNonFacialEmoji ? emojis[0] : null);
316
+ if (!chosen) {
317
+ return text.replace(/[\p{Extended_Pictographic}]/gu, '');
318
+ }
319
+ let kept = false;
320
+ return text.replace(/[\p{Extended_Pictographic}]/gu, (e) => {
321
+ if (!kept && e === chosen) {
322
+ kept = true;
323
+ return e;
324
+ }
325
+ return '';
326
+ });
327
+ }
328
+ function trimToSentences(text, maxSentences) {
329
+ const parts = text
330
+ .split(/(?<=[.!?])\s+/)
331
+ .map((s) => s.trim())
332
+ .filter(Boolean);
333
+ if (parts.length <= maxSentences)
334
+ return text.trim();
335
+ return parts.slice(0, maxSentences).join(' ').trim();
336
+ }
337
+ export function humanizeSlackReply(raw, options = {}) {
338
+ const { allowEmoji = true, allowNonFacialEmoji = true, maxSentences = MAX_HUMANIZED_SENTENCES, } = options;
339
+ let text = raw.trim();
340
+ if (!text)
341
+ return text;
342
+ // Remove markdown formatting artifacts that look templated in chat.
343
+ text = text
344
+ .replace(/^#{1,6}\s+/gm, '')
345
+ .replace(/^\s*[-*]\s+/gm, '')
346
+ .replace(/\*\*(.*?)\*\*/g, '$1')
347
+ .replace(/\s+/g, ' ')
348
+ .trim();
349
+ // Strip common assistant-y openers.
350
+ for (const pattern of CANNED_PHRASE_PREFIXES) {
351
+ text = text.replace(pattern, '').trim();
352
+ }
353
+ text = applyEmojiPolicy(text, allowEmoji, allowNonFacialEmoji);
354
+ text = limitEmojiCount(text, 1);
355
+ text = trimToSentences(text, maxSentences);
356
+ if (text.length > 260) {
357
+ text = `${text.slice(0, 257).trimEnd()}...`;
358
+ }
359
+ return text;
360
+ }
361
+ function buildCurrentCliInvocation(args) {
362
+ const cliEntry = process.argv[1];
363
+ if (!cliEntry)
364
+ return null;
365
+ return [...process.execArgv, cliEntry, ...args];
366
+ }
367
+ function formatCommandForLog(bin, args) {
368
+ return [bin, ...args].map((part) => JSON.stringify(part)).join(' ');
369
+ }
243
370
  export class DeliberationEngine {
244
371
  _slackClient;
245
372
  _config;
246
373
  _humanResumeTimers = new Map();
374
+ _emojiCadenceCounter = new Map();
247
375
  constructor(slackClient, config) {
248
376
  this._slackClient = slackClient;
249
377
  this._config = config;
250
378
  }
379
+ _humanizeForPost(channel, threadTs, persona, raw) {
380
+ const key = `${channel}:${threadTs}:${persona.id}`;
381
+ const count = (this._emojiCadenceCounter.get(key) ?? 0) + 1;
382
+ this._emojiCadenceCounter.set(key, count);
383
+ // Human cadence:
384
+ // - emoji roughly every 3rd message by same persona in same thread
385
+ // - non-facial emoji much rarer (roughly every 9th message)
386
+ const allowEmoji = count % 3 === 0;
387
+ const allowNonFacialEmoji = count % 9 === 0;
388
+ return humanizeSlackReply(raw, { allowEmoji, allowNonFacialEmoji, maxSentences: 2 });
389
+ }
251
390
  /**
252
391
  * Start a new discussion thread for a trigger event.
253
392
  * Posts the opening message and kicks off the first round of contributions.
@@ -305,7 +444,7 @@ export class DeliberationEngine {
305
444
  // Post opening message to start the thread
306
445
  const openingText = buildOpeningMessage(trigger);
307
446
  const openingMsg = await this._slackClient.postAsAgent(channel, openingText, devPersona);
308
- await sleep(MESSAGE_DELAY_MS);
447
+ await sleep(humanDelay());
309
448
  // Create discussion record
310
449
  const discussion = repos.slackDiscussion.create({
311
450
  projectPath: trigger.projectPath,
@@ -352,9 +491,10 @@ export class DeliberationEngine {
352
491
  message = `[Contribution from ${persona.name} unavailable — AI provider not configured]`;
353
492
  }
354
493
  if (message) {
355
- await this._slackClient.postAsAgent(discussion.channelId, message, persona, discussion.threadTs);
494
+ const finalMessage = this._humanizeForPost(discussion.channelId, discussion.threadTs, persona, message);
495
+ await this._slackClient.postAsAgent(discussion.channelId, finalMessage, persona, discussion.threadTs);
356
496
  repos.slackDiscussion.addParticipant(discussionId, persona.id);
357
- await sleep(MESSAGE_DELAY_MS);
497
+ await sleep(humanDelay());
358
498
  }
359
499
  }
360
500
  /**
@@ -383,8 +523,8 @@ export class DeliberationEngine {
383
523
  const updated = innerRepos.slackDiscussion.getById(discussion.id);
384
524
  if (!updated || updated.status !== 'active')
385
525
  return;
386
- await this._slackClient.postAsAgent(channel, "Picking back up let me summarize where we are and continue. 🏗️", carlos, threadTs);
387
- await sleep(MESSAGE_DELAY_MS);
526
+ await this._slackClient.postAsAgent(channel, "Ok, picking this back up. Let me see where we landed.", carlos, threadTs);
527
+ await sleep(humanDelay());
388
528
  await this._evaluateConsensus(discussion.id, {
389
529
  type: discussion.triggerType,
390
530
  projectPath: discussion.projectPath,
@@ -421,10 +561,11 @@ export class DeliberationEngine {
421
561
  message = '';
422
562
  }
423
563
  if (message) {
424
- await this._slackClient.postAsAgent(discussion.channelId, message, persona, discussion.threadTs);
564
+ const finalMessage = this._humanizeForPost(discussion.channelId, discussion.threadTs, persona, message);
565
+ await this._slackClient.postAsAgent(discussion.channelId, finalMessage, persona, discussion.threadTs);
425
566
  repos.slackDiscussion.addParticipant(discussionId, persona.id);
426
- historyText = historyText ? `${historyText}\n---\n${message}` : message;
427
- await sleep(MESSAGE_DELAY_MS);
567
+ historyText = historyText ? `${historyText}\n---\n${finalMessage}` : finalMessage;
568
+ await sleep(humanDelay());
428
569
  }
429
570
  }
430
571
  }
@@ -449,19 +590,21 @@ export class DeliberationEngine {
449
590
  // Get thread history and let Carlos evaluate
450
591
  const history = await this._slackClient.getChannelHistory(discussion.channelId, discussion.threadTs, 20);
451
592
  const historyText = history.map(m => m.text).join('\n---\n');
452
- const consensusPrompt = `You are ${carlos.name}, ${carlos.role}.
453
-
454
- Review this discussion thread and decide: are we ready to ship, do we need another round of review, or do we need a human?
593
+ const consensusPrompt = `You are ${carlos.name}, ${carlos.role}. You're wrapping up a team discussion.
455
594
 
456
595
  Thread:
457
596
  ${historyText}
458
597
 
459
- Round: ${discussion.round} of ${MAX_ROUNDS}
598
+ Round: ${discussion.round}/${MAX_ROUNDS}
599
+
600
+ Make the call. Are we done, do we need another pass, or does a human need to weigh in?
601
+
602
+ Respond with EXACTLY one of these formats (include the prefix):
603
+ - APPROVE: [short closing message in your voice — e.g., "Clean. Let's ship it."]
604
+ - CHANGES: [what specifically still needs work — be concrete, not vague]
605
+ - HUMAN: [why this needs a human decision — be specific about what's ambiguous]
460
606
 
461
- Respond with ONLY one of:
462
- - APPROVE: [your short closing message, e.g., "LGTM 👍 Ship it 🚀"]
463
- - CHANGES: [summary of what still needs to change — be specific]
464
- - HUMAN: [why you need a human decision]`;
607
+ Write the prefix and your message. Nothing else.`;
465
608
  let decision;
466
609
  try {
467
610
  decision = await callAIForContribution(carlos, this._config, consensusPrompt);
@@ -470,15 +613,19 @@ Respond with ONLY one of:
470
613
  decision = 'HUMAN: AI evaluation failed — needs manual review';
471
614
  }
472
615
  if (decision.startsWith('APPROVE')) {
473
- const message = decision.replace(/^APPROVE:\s*/, '').trim() || 'Ship it 🚀';
616
+ const message = decision.replace(/^APPROVE:\s*/, '').trim() || 'Clean. Ship it.';
474
617
  await this._slackClient.postAsAgent(discussion.channelId, message, carlos, discussion.threadTs);
475
618
  repos.slackDiscussion.updateStatus(discussionId, 'consensus', 'approved');
619
+ if (trigger.type === 'code_watch') {
620
+ await this.triggerIssueOpener(discussionId, trigger)
621
+ .catch((e) => console.warn('Issue opener failed:', String(e)));
622
+ }
476
623
  return;
477
624
  }
478
625
  if (decision.startsWith('CHANGES') && discussion.round < MAX_ROUNDS) {
479
626
  const changes = decision.replace(/^CHANGES:\s*/, '').trim();
480
- await this._slackClient.postAsAgent(discussion.channelId, `One more pass needed:\n${changes}`, carlos, discussion.threadTs);
481
- await sleep(MESSAGE_DELAY_MS);
627
+ await this._slackClient.postAsAgent(discussion.channelId, changes, carlos, discussion.threadTs);
628
+ await sleep(humanDelay());
482
629
  // Increment round and start another contribution round, then loop back.
483
630
  const nextRound = discussion.round + 1;
484
631
  repos.slackDiscussion.updateRound(discussionId, nextRound);
@@ -491,7 +638,7 @@ Respond with ONLY one of:
491
638
  if (decision.startsWith('CHANGES') && discussion.round >= MAX_ROUNDS) {
492
639
  // Max rounds reached — set changes_requested and optionally trigger PR refinement
493
640
  const changesSummary = decision.replace(/^CHANGES:\s*/, '').trim();
494
- await this._slackClient.postAsAgent(discussion.channelId, "3 rounds inshipping what we have. Ship it 🚀", carlos, discussion.threadTs);
641
+ await this._slackClient.postAsAgent(discussion.channelId, `We've been at this for ${MAX_ROUNDS} rounds. Sending it through with the remaining notes Dev can address them in the next pass.`, carlos, discussion.threadTs);
495
642
  repos.slackDiscussion.updateStatus(discussionId, 'consensus', 'changes_requested');
496
643
  if (discussion.triggerType === 'pr_review') {
497
644
  await this.triggerPRRefinement(discussionId, changesSummary, discussion.triggerRef).catch(e => console.warn('PR refinement trigger failed:', e));
@@ -499,7 +646,10 @@ Respond with ONLY one of:
499
646
  return;
500
647
  }
501
648
  // HUMAN or fallback
502
- await this._slackClient.postAsAgent(discussion.channelId, "This one needs a human call. Flagging for review. 🚩", carlos, discussion.threadTs);
649
+ const humanReason = decision.replace(/^HUMAN:\s*/, '').trim();
650
+ await this._slackClient.postAsAgent(discussion.channelId, humanReason
651
+ ? `Need a human on this one — ${humanReason}`
652
+ : 'This needs a human call. Flagging it.', carlos, discussion.threadTs);
503
653
  repos.slackDiscussion.updateStatus(discussionId, 'blocked', 'human_needed');
504
654
  return;
505
655
  }
@@ -515,31 +665,36 @@ Respond with ONLY one of:
515
665
  return;
516
666
  const personas = repos.agentPersona.getActive();
517
667
  const carlos = findCarlos(personas) ?? personas[0];
518
- const dev = findDev(personas) ?? personas[0];
668
+ const actor = carlos?.name ?? 'Night Watch';
519
669
  if (carlos) {
520
- await this._slackClient.postAsAgent(discussion.channelId, `Sending feedback to the reviewer agent. Changes needed:\n${changesSummary}`, carlos, discussion.threadTs);
521
- await sleep(MESSAGE_DELAY_MS);
670
+ await this._slackClient.postAsAgent(discussion.channelId, `Sending PR #${prNumber} back through with the notes.`, carlos, discussion.threadTs);
671
+ await sleep(humanDelay());
522
672
  }
523
673
  // Set NW_SLACK_FEEDBACK and trigger reviewer
524
674
  const feedback = JSON.stringify({ discussionId, prNumber, changes: changesSummary });
675
+ const invocationArgs = buildCurrentCliInvocation(['review']);
676
+ if (!invocationArgs) {
677
+ console.warn(`[slack][job] triggerPRRefinement reviewer spawn failed via ${actor} pr=${prNumber}: CLI entry path unavailable`);
678
+ if (carlos) {
679
+ await this._slackClient.postAsAgent(discussion.channelId, `Can't start the reviewer right now — runtime issue. Will retry.`, carlos, discussion.threadTs);
680
+ }
681
+ return;
682
+ }
683
+ console.log(`[slack][job] triggerPRRefinement reviewer spawn via ${actor} pr=${prNumber} cmd=${formatCommandForLog(process.execPath, invocationArgs)}`);
525
684
  // Spawn the reviewer as a detached process
526
685
  const { spawn } = await import('child_process');
527
- const reviewer = spawn(process.execPath, [process.argv[1], 'review', '--pr', prNumber], {
686
+ const reviewer = spawn(process.execPath, invocationArgs, {
528
687
  detached: true,
529
688
  stdio: 'ignore',
530
- env: { ...process.env, NW_SLACK_FEEDBACK: feedback },
689
+ env: { ...process.env, NW_SLACK_FEEDBACK: feedback, NW_TARGET_PR: prNumber },
531
690
  });
532
691
  reviewer.unref();
533
- // Post update
534
- if (dev) {
535
- await this._slackClient.postAsAgent(discussion.channelId, `Reviewer agent kicked off for PR #${prNumber} 🔨 Will post back when done.`, dev, discussion.threadTs);
536
- }
537
692
  }
538
693
  /**
539
694
  * Reply as a persona in any Slack thread — no formal discussion required.
540
695
  * Used when someone @mentions a persona outside of a Night Watch discussion.
541
696
  */
542
- async replyAsAgent(channel, threadTs, incomingText, persona) {
697
+ async replyAsAgent(channel, threadTs, incomingText, persona, projectContext) {
543
698
  let history = [];
544
699
  try {
545
700
  history = await this._slackClient.getChannelHistory(channel, threadTs, 10);
@@ -549,10 +704,18 @@ Respond with ONLY one of:
549
704
  }
550
705
  const historyText = history.map((m) => m.text).join('\n---\n');
551
706
  const prompt = `You are ${persona.name}, ${persona.role}.\n` +
552
- (persona.soul?.whoIAm ? `About you: ${persona.soul.whoIAm}\n\n` : '') +
553
- (historyText ? `Thread context:\n${historyText}\n\n` : '') +
554
- `Someone just said: "${incomingText}"\n\n` +
555
- `Reply concisely in your own voice. Keep it under 3 sentences unless detail is clearly needed.`;
707
+ `Your teammates: Dev (implementer), Carlos (tech lead), Maya (security), Priya (QA).\n\n` +
708
+ (projectContext ? `Project context: ${projectContext}\n\n` : '') +
709
+ (historyText ? `Thread so far:\n${historyText}\n\n` : '') +
710
+ `Latest message: "${incomingText}"\n\n` +
711
+ `Respond in your own voice. This is Slack — keep it to 1-2 sentences.\n` +
712
+ `- Talk like a colleague, not a bot. No "Great question", "Of course", or "I hope this helps".\n` +
713
+ `- You can tag teammates by name if someone else should weigh in.\n` +
714
+ `- No markdown formatting, headings, or bullet lists.\n` +
715
+ `- Emojis: one max, only if it fits naturally. Default to none.\n` +
716
+ `- If the question is outside your domain, say so briefly and point to the right person.\n` +
717
+ `- If you disagree, say why in one line. If you agree, keep it short.\n\n` +
718
+ `Write only your reply. No name prefix.`;
556
719
  let message;
557
720
  try {
558
721
  message = await callAIForContribution(persona, this._config, prompt);
@@ -561,7 +724,116 @@ Respond with ONLY one of:
561
724
  message = `[Reply from ${persona.name} unavailable — AI provider not configured]`;
562
725
  }
563
726
  if (message) {
564
- await this._slackClient.postAsAgent(channel, message, persona, threadTs);
727
+ await this._slackClient.postAsAgent(channel, this._humanizeForPost(channel, threadTs, persona, message), persona, threadTs);
728
+ }
729
+ }
730
+ /**
731
+ * Generate and post a proactive message from a persona.
732
+ * Used by the interaction listener when a channel has been idle.
733
+ * The persona shares an observation, question, or suggestion based on
734
+ * project context and roadmap state — in their own voice.
735
+ */
736
+ async postProactiveMessage(channel, persona, projectContext, roadmapContext) {
737
+ const prompt = `You are ${persona.name}, ${persona.role}.\n` +
738
+ `Your teammates: Dev (implementer), Carlos (tech lead), Maya (security), Priya (QA).\n\n` +
739
+ `You're posting an unprompted message in the team's Slack channel. ` +
740
+ `The channel has been quiet — you want to share something useful, not just fill silence.\n\n` +
741
+ (projectContext ? `Project context: ${projectContext}\n\n` : '') +
742
+ (roadmapContext ? `Roadmap/PRD status:\n${roadmapContext}\n\n` : '') +
743
+ `Write a SHORT proactive message (1-2 sentences) that does ONE of these:\n` +
744
+ `- Question a roadmap priority or ask if something should be reordered\n` +
745
+ `- Flag something you've been thinking about from your domain (security concern, test gap, architectural question, implementation idea)\n` +
746
+ `- Suggest an improvement or raise a "have we thought about..." question\n` +
747
+ `- Share a concrete observation about the current state of the project\n` +
748
+ `- Offer to kick off a task: "I can run a review on X if nobody's on it"\n\n` +
749
+ `Rules:\n` +
750
+ `- Stay in your lane. Only bring up things relevant to your expertise.\n` +
751
+ `- Be specific — name the feature, file, or concern. No vague "we should think about things."\n` +
752
+ `- Sound like a teammate dropping a thought in chat, not making an announcement.\n` +
753
+ `- No markdown, headings, bullets. Just a message.\n` +
754
+ `- No "Great question", "Just checking in", or "Hope everyone is doing well."\n` +
755
+ `- Emojis: one max, only if natural. Default to none.\n` +
756
+ `- If you genuinely have nothing useful to say, write exactly: SKIP\n\n` +
757
+ `Write only your message. No name prefix.`;
758
+ let message;
759
+ try {
760
+ message = await callAIForContribution(persona, this._config, prompt);
761
+ }
762
+ catch {
763
+ return; // Silently skip — proactive messages are optional
764
+ }
765
+ if (!message || message.trim().toUpperCase() === 'SKIP') {
766
+ return;
767
+ }
768
+ const dummyTs = `${Date.now()}`;
769
+ const finalMessage = this._humanizeForPost(channel, dummyTs, persona, message);
770
+ if (finalMessage) {
771
+ await this._slackClient.postAsAgent(channel, finalMessage, persona);
772
+ }
773
+ }
774
+ /**
775
+ * Generate a structured GitHub issue body written by the Dev persona.
776
+ */
777
+ async _generateIssueBody(trigger, devPersona) {
778
+ const prompt = `You are ${devPersona.name}, ${devPersona.role}.
779
+ Write a concise GitHub issue body for the following code scan finding.
780
+ Use this structure exactly (GitHub Markdown):
781
+
782
+ ## Problem
783
+ One sentence describing what was detected and why it's risky.
784
+
785
+ ## Location
786
+ File and line where the issue exists.
787
+
788
+ ## Code
789
+ \`\`\`
790
+ The offending snippet
791
+ \`\`\`
792
+
793
+ ## Suggested Fix
794
+ 2-3 bullet points on how to address it.
795
+
796
+ ## Acceptance Criteria
797
+ - [ ] Checkbox items describing what "done" looks like
798
+
799
+ Keep it tight — this is a bug report, not a spec. No fluff, no greetings.
800
+
801
+ Context:
802
+ ${trigger.context}`;
803
+ const raw = await callAIForContribution(devPersona, this._config, prompt);
804
+ return raw.trim();
805
+ }
806
+ /**
807
+ * Open a GitHub issue from a code_watch finding and post back to the thread.
808
+ * Called automatically after an approved code_watch consensus.
809
+ */
810
+ async triggerIssueOpener(discussionId, trigger) {
811
+ const repos = getRepositories();
812
+ const discussion = repos.slackDiscussion.getById(discussionId);
813
+ if (!discussion)
814
+ return;
815
+ const devPersona = findDev(repos.agentPersona.getActive());
816
+ if (!devPersona)
817
+ return;
818
+ // Acknowledge before doing async work
819
+ await this._slackClient.postAsAgent(discussion.channelId, 'Agreed. Writing up an issue for this.', devPersona, discussion.threadTs);
820
+ const title = buildIssueTitleFromTrigger(trigger);
821
+ const body = await this._generateIssueBody(trigger, devPersona);
822
+ const boardConfig = this._config.boardProvider;
823
+ if (boardConfig?.enabled) {
824
+ try {
825
+ const provider = createBoardProvider(boardConfig, trigger.projectPath);
826
+ const issue = await provider.createIssue({ title, body, column: 'Ready' });
827
+ await this._slackClient.postAsAgent(discussion.channelId, `Opened #${issue.number}: *${issue.title}* — ${issue.url}\n\nAnyone want to pick this up, or should I take a pass at it?`, devPersona, discussion.threadTs);
828
+ }
829
+ catch (err) {
830
+ console.warn('[issue_opener] board createIssue failed:', err);
831
+ await this._slackClient.postAsAgent(discussion.channelId, `Couldn't open the issue automatically — board might not be configured. Here's the writeup:\n\n${body.slice(0, 600)}`, devPersona, discussion.threadTs);
832
+ }
833
+ }
834
+ else {
835
+ // No board configured — post the writeup in thread so it's not lost
836
+ await this._slackClient.postAsAgent(discussion.channelId, `No board configured, dropping the writeup here:\n\n${body.slice(0, 600)}`, devPersona, discussion.threadTs);
565
837
  }
566
838
  }
567
839
  }