@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.
- package/dist/shared/types.d.ts +2 -1
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/src/agents/soul-compiler.d.ts.map +1 -1
- package/dist/src/agents/soul-compiler.js +60 -6
- package/dist/src/agents/soul-compiler.js.map +1 -1
- package/dist/src/commands/qa.d.ts +4 -0
- package/dist/src/commands/qa.d.ts.map +1 -1
- package/dist/src/commands/qa.js +35 -0
- package/dist/src/commands/qa.js.map +1 -1
- package/dist/src/commands/serve.d.ts +12 -0
- package/dist/src/commands/serve.d.ts.map +1 -1
- package/dist/src/commands/serve.js +115 -0
- package/dist/src/commands/serve.js.map +1 -1
- package/dist/src/config.d.ts.map +1 -1
- package/dist/src/config.js +16 -3
- package/dist/src/config.js.map +1 -1
- package/dist/src/slack/channel-manager.js +3 -3
- package/dist/src/slack/channel-manager.js.map +1 -1
- package/dist/src/slack/client.d.ts +10 -2
- package/dist/src/slack/client.d.ts.map +1 -1
- package/dist/src/slack/client.js +38 -5
- package/dist/src/slack/client.js.map +1 -1
- package/dist/src/slack/deliberation.d.ts +26 -1
- package/dist/src/slack/deliberation.d.ts.map +1 -1
- package/dist/src/slack/deliberation.js +325 -53
- package/dist/src/slack/deliberation.js.map +1 -1
- package/dist/src/slack/interaction-listener.d.ts +54 -0
- package/dist/src/slack/interaction-listener.d.ts.map +1 -1
- package/dist/src/slack/interaction-listener.js +830 -13
- package/dist/src/slack/interaction-listener.js.map +1 -1
- package/dist/src/storage/repositories/index.d.ts.map +1 -1
- package/dist/src/storage/repositories/index.js +2 -0
- package/dist/src/storage/repositories/index.js.map +1 -1
- package/dist/src/storage/repositories/interfaces.d.ts +1 -0
- package/dist/src/storage/repositories/interfaces.d.ts.map +1 -1
- package/dist/src/storage/repositories/sqlite/agent-persona-repository.d.ts +5 -0
- package/dist/src/storage/repositories/sqlite/agent-persona-repository.d.ts.map +1 -1
- package/dist/src/storage/repositories/sqlite/agent-persona-repository.js +243 -100
- package/dist/src/storage/repositories/sqlite/agent-persona-repository.js.map +1 -1
- package/dist/src/utils/avatar-generator.d.ts +1 -1
- package/dist/src/utils/avatar-generator.d.ts.map +1 -1
- package/dist/src/utils/avatar-generator.js +62 -17
- package/dist/src/utils/avatar-generator.js.map +1 -1
- package/dist/src/utils/notify.d.ts +1 -0
- package/dist/src/utils/notify.d.ts.map +1 -1
- package/dist/src/utils/notify.js +13 -1
- package/dist/src/utils/notify.js.map +1 -1
- package/package.json +1 -1
- package/scripts/night-watch-pr-reviewer-cron.sh +36 -8
- package/scripts/night-watch-qa-cron.sh +15 -3
- package/templates/night-watch-pr-reviewer.md +46 -17
- package/web/dist/avatars/carlos.webp +0 -0
- package/web/dist/avatars/dev.webp +0 -0
- package/web/dist/avatars/maya.webp +0 -0
- 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
|
|
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 `
|
|
166
|
+
return `Opened ${trigger.ref}${trigger.prUrl ? ` — ${trigger.prUrl}` : ''}. Ready for eyes.`;
|
|
149
167
|
case 'build_failure':
|
|
150
|
-
return `Build
|
|
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
|
|
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
|
-
|
|
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}
|
|
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 || '(
|
|
214
|
+
${threadHistory || '(Thread just started)'}
|
|
173
215
|
|
|
174
|
-
##
|
|
175
|
-
|
|
176
|
-
-
|
|
177
|
-
-
|
|
178
|
-
-
|
|
179
|
-
-
|
|
180
|
-
- If you
|
|
181
|
-
- If you have
|
|
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
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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, "
|
|
387
|
-
await sleep(
|
|
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
|
-
|
|
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${
|
|
427
|
-
await sleep(
|
|
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}
|
|
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
|
-
|
|
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,
|
|
481
|
-
await sleep(
|
|
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,
|
|
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
|
-
|
|
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
|
|
668
|
+
const actor = carlos?.name ?? 'Night Watch';
|
|
519
669
|
if (carlos) {
|
|
520
|
-
await this._slackClient.postAsAgent(discussion.channelId, `Sending
|
|
521
|
-
await sleep(
|
|
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,
|
|
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
|
-
(
|
|
553
|
-
(
|
|
554
|
-
`
|
|
555
|
-
`
|
|
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
|
}
|