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

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 (84) hide show
  1. package/dist/shared/types.d.ts +2 -0
  2. package/dist/shared/types.d.ts.map +1 -1
  3. package/dist/src/cli.js +3 -0
  4. package/dist/src/cli.js.map +1 -1
  5. package/dist/src/commands/audit.d.ts +19 -0
  6. package/dist/src/commands/audit.d.ts.map +1 -0
  7. package/dist/src/commands/audit.js +98 -0
  8. package/dist/src/commands/audit.js.map +1 -0
  9. package/dist/src/commands/dashboard.js +1 -1
  10. package/dist/src/commands/dashboard.js.map +1 -1
  11. package/dist/src/commands/init.d.ts.map +1 -1
  12. package/dist/src/commands/init.js +1 -6
  13. package/dist/src/commands/init.js.map +1 -1
  14. package/dist/src/commands/install.d.ts +4 -0
  15. package/dist/src/commands/install.d.ts.map +1 -1
  16. package/dist/src/commands/install.js +25 -20
  17. package/dist/src/commands/install.js.map +1 -1
  18. package/dist/src/commands/logs.js +3 -3
  19. package/dist/src/commands/logs.js.map +1 -1
  20. package/dist/src/commands/prs.js +2 -2
  21. package/dist/src/commands/prs.js.map +1 -1
  22. package/dist/src/commands/review.d.ts.map +1 -1
  23. package/dist/src/commands/review.js +13 -5
  24. package/dist/src/commands/review.js.map +1 -1
  25. package/dist/src/commands/uninstall.d.ts.map +1 -1
  26. package/dist/src/commands/uninstall.js +3 -22
  27. package/dist/src/commands/uninstall.js.map +1 -1
  28. package/dist/src/config.d.ts.map +1 -1
  29. package/dist/src/config.js +30 -1
  30. package/dist/src/config.js.map +1 -1
  31. package/dist/src/constants.d.ts +10 -3
  32. package/dist/src/constants.d.ts.map +1 -1
  33. package/dist/src/constants.js +15 -2
  34. package/dist/src/constants.js.map +1 -1
  35. package/dist/src/server/index.d.ts.map +1 -1
  36. package/dist/src/server/index.js +50 -3
  37. package/dist/src/server/index.js.map +1 -1
  38. package/dist/src/slack/client.d.ts +9 -1
  39. package/dist/src/slack/client.d.ts.map +1 -1
  40. package/dist/src/slack/client.js +18 -4
  41. package/dist/src/slack/client.js.map +1 -1
  42. package/dist/src/slack/deliberation.d.ts +22 -1
  43. package/dist/src/slack/deliberation.d.ts.map +1 -1
  44. package/dist/src/slack/deliberation.js +663 -51
  45. package/dist/src/slack/deliberation.js.map +1 -1
  46. package/dist/src/slack/interaction-listener.d.ts +33 -9
  47. package/dist/src/slack/interaction-listener.d.ts.map +1 -1
  48. package/dist/src/slack/interaction-listener.js +393 -197
  49. package/dist/src/slack/interaction-listener.js.map +1 -1
  50. package/dist/src/storage/repositories/index.d.ts.map +1 -1
  51. package/dist/src/storage/repositories/index.js +2 -0
  52. package/dist/src/storage/repositories/index.js.map +1 -1
  53. package/dist/src/storage/repositories/interfaces.d.ts +1 -0
  54. package/dist/src/storage/repositories/interfaces.d.ts.map +1 -1
  55. package/dist/src/storage/repositories/sqlite/agent-persona-repository.d.ts +6 -0
  56. package/dist/src/storage/repositories/sqlite/agent-persona-repository.d.ts.map +1 -1
  57. package/dist/src/storage/repositories/sqlite/agent-persona-repository.js +37 -1
  58. package/dist/src/storage/repositories/sqlite/agent-persona-repository.js.map +1 -1
  59. package/dist/src/types.d.ts +13 -0
  60. package/dist/src/types.d.ts.map +1 -1
  61. package/dist/src/utils/avatar-generator.d.ts.map +1 -1
  62. package/dist/src/utils/avatar-generator.js +7 -2
  63. package/dist/src/utils/avatar-generator.js.map +1 -1
  64. package/dist/src/utils/notify.d.ts.map +1 -1
  65. package/dist/src/utils/notify.js +5 -1
  66. package/dist/src/utils/notify.js.map +1 -1
  67. package/dist/src/utils/status-data.d.ts +2 -2
  68. package/dist/src/utils/status-data.d.ts.map +1 -1
  69. package/dist/src/utils/status-data.js +78 -123
  70. package/dist/src/utils/status-data.js.map +1 -1
  71. package/package.json +3 -1
  72. package/scripts/night-watch-audit-cron.sh +149 -0
  73. package/scripts/night-watch-cron.sh +33 -14
  74. package/scripts/night-watch-helpers.sh +10 -2
  75. package/scripts/night-watch-pr-reviewer-cron.sh +224 -18
  76. package/web/dist/assets/index-BiJf9LFT.js +458 -0
  77. package/web/dist/assets/index-OpSgvsYu.css +1 -0
  78. package/web/dist/avatars/carlos.webp +0 -0
  79. package/web/dist/avatars/dev.webp +0 -0
  80. package/web/dist/avatars/maya.webp +0 -0
  81. package/web/dist/avatars/priya.webp +0 -0
  82. package/web/dist/index.html +2 -2
  83. package/web/dist/assets/index-CndIPm_F.js +0 -473
  84. package/web/dist/assets/index-w6Q6gxCS.css +0 -1
@@ -5,11 +5,18 @@
5
5
  */
6
6
  import { compileSoul } from "../agents/soul-compiler.js";
7
7
  import { getRepositories } from "../storage/repositories/index.js";
8
- const MAX_ROUNDS = 3;
9
- const MESSAGE_DELAY_MS = 1500; // Rate limit: 1.5s between posts
8
+ import { loadConfig } from "../config.js";
9
+ import { createBoardProvider } from "../board/factory.js";
10
+ import { execFileSync } from "node:child_process";
11
+ const MAX_ROUNDS = 2;
12
+ const MAX_CONTRIBUTIONS_PER_ROUND = 2;
13
+ const MAX_AGENT_THREAD_REPLIES = 4;
14
+ const HUMAN_DELAY_MIN_MS = 20_000; // Minimum pause between agent replies (20s)
15
+ const HUMAN_DELAY_MAX_MS = 60_000; // Maximum pause between agent replies (60s)
10
16
  const DISCUSSION_RESUME_DELAY_MS = 60_000;
11
17
  const DISCUSSION_REPLAY_GUARD_MS = 30 * 60_000;
12
18
  const MAX_HUMANIZED_SENTENCES = 2;
19
+ const MAX_HUMANIZED_CHARS = 220;
13
20
  const inFlightDiscussionStarts = new Map();
14
21
  function discussionStartKey(trigger) {
15
22
  return `${trigger.projectPath}:${trigger.type}:${trigger.ref}`;
@@ -20,6 +27,13 @@ function discussionStartKey(trigger) {
20
27
  function sleep(ms) {
21
28
  return new Promise(resolve => setTimeout(resolve, ms));
22
29
  }
30
+ /**
31
+ * Return a random delay in the human-like range so replies don't arrive
32
+ * in an obviously robotic cadence.
33
+ */
34
+ function humanDelay() {
35
+ return HUMAN_DELAY_MIN_MS + Math.random() * (HUMAN_DELAY_MAX_MS - HUMAN_DELAY_MIN_MS);
36
+ }
23
37
  /**
24
38
  * Determine which Slack channel to use for a trigger type
25
39
  */
@@ -159,12 +173,73 @@ function buildOpeningMessage(trigger) {
159
173
  return `Build broke on ${trigger.ref}. Looking into it.\n\n${trigger.context.slice(0, 500)}`;
160
174
  case 'prd_kickoff':
161
175
  return `Picking up ${trigger.ref}. Going to start carving out the implementation.`;
162
- case 'code_watch':
163
- return `Something caught my eye during a scan want to get a second opinion on this.\n\n${trigger.context.slice(0, 600)}`;
176
+ case 'code_watch': {
177
+ // Parse context fields to compose a natural message rather than dumping structured data.
178
+ const locationMatch = trigger.context.match(/^Location: (.+)$/m);
179
+ const signalMatch = trigger.context.match(/^Signal: (.+)$/m);
180
+ const snippetMatch = trigger.context.match(/^Snippet: (.+)$/m);
181
+ const location = locationMatch?.[1]?.trim() ?? '';
182
+ const signal = signalMatch?.[1]?.trim() ?? '';
183
+ const snippet = snippetMatch?.[1]?.trim() ?? '';
184
+ if (location && signal) {
185
+ const DETAIL_OPENERS = [
186
+ `${location} — ${signal}.`,
187
+ `Flagging ${location}: ${signal}.`,
188
+ `Caught something in ${location}: ${signal}.`,
189
+ `${location} pinged the scanner — ${signal}.`,
190
+ `Noticed this in ${location}: ${signal}.`,
191
+ ];
192
+ const hash = trigger.ref.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0);
193
+ const opener = DETAIL_OPENERS[hash % DETAIL_OPENERS.length];
194
+ return snippet ? `${opener}\n\`\`\`\n${snippet}\n\`\`\`` : opener;
195
+ }
196
+ return trigger.context.slice(0, 600);
197
+ }
164
198
  default:
165
199
  return trigger.context.slice(0, 500);
166
200
  }
167
201
  }
202
+ /**
203
+ * Parse the structured code_watch context string and derive a git-style issue title.
204
+ */
205
+ function buildIssueTitleFromTrigger(trigger) {
206
+ const signalMatch = trigger.context.match(/^Signal: (.+)$/m);
207
+ const locationMatch = trigger.context.match(/^Location: (.+)$/m);
208
+ const signal = signalMatch?.[1] ?? 'code signal';
209
+ const location = locationMatch?.[1] ?? 'unknown location';
210
+ return `fix: ${signal} at ${location}`;
211
+ }
212
+ function hasConcreteCodeContext(context) {
213
+ return (/```/.test(context)
214
+ || /(^|\s)(src|test|scripts|web)\/[^\s:]+\.[A-Za-z0-9]+(?::\d+)?/.test(context)
215
+ || /\bdiff --git\b/.test(context)
216
+ || /@@\s[-+]\d+/.test(context)
217
+ || /\b(function|class|const|let|if\s*\(|try\s*{|catch\s*\()/.test(context));
218
+ }
219
+ function loadPrDiffExcerpt(projectPath, ref) {
220
+ const prNumber = Number.parseInt(ref, 10);
221
+ if (Number.isNaN(prNumber))
222
+ return '';
223
+ try {
224
+ const diff = execFileSync('gh', ['pr', 'diff', String(prNumber), '--color=never'], {
225
+ cwd: projectPath,
226
+ encoding: 'utf8',
227
+ stdio: ['ignore', 'pipe', 'ignore'],
228
+ maxBuffer: 2 * 1024 * 1024,
229
+ });
230
+ const excerpt = diff
231
+ .split('\n')
232
+ .slice(0, 160)
233
+ .join('\n')
234
+ .trim();
235
+ if (!excerpt)
236
+ return '';
237
+ return `PR diff excerpt (first 160 lines):\n\`\`\`diff\n${excerpt}\n\`\`\``;
238
+ }
239
+ catch {
240
+ return '';
241
+ }
242
+ }
168
243
  /**
169
244
  * Build the contribution prompt for an agent's AI call.
170
245
  * This is what gets sent to the AI provider to generate the agent's message.
@@ -185,12 +260,17 @@ ${trigger.context.slice(0, 2000)}
185
260
  ${threadHistory || '(Thread just started)'}
186
261
 
187
262
  ## How to respond
188
- Write a short Slack message — 1 to 2 sentences. This is chat, not documentation.
263
+ Write a short Slack message — 1 to 2 sentences max, under ~180 chars when possible.
189
264
  ${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.'}
265
+ - React to one specific point already in the thread (use teammate names when available).
266
+ - Never repeat a point that's already been made in similar words.
267
+ - Back your take with one concrete artifact from context (file path, symbol, diff hunk, or log line).
268
+ - If context lacks concrete code evidence, ask for the exact file/diff and use SKIP.
269
+ - If you have no new signal to add, reply with exactly: SKIP
190
270
  - Talk like a teammate, not an assistant. No pleasantries, no filler.
191
271
  - Stay in your lane — only comment on your domain unless something crosses into it.
192
272
  - You can name-drop teammates when handing off ("Maya should look at the auth here").
193
- - If nothing concerns you, a brief "nothing from me" or a short acknowledgment is fine.
273
+ - If nothing concerns you, use SKIP instead of posting filler.
194
274
  - If you have a concern, name it specifically and suggest a direction.
195
275
  - No markdown formatting. No bullet lists. No headings. Just a message.
196
276
  - Emojis: use one only if it genuinely fits. Default to none.
@@ -205,11 +285,16 @@ Write ONLY your message. No name prefix, no labels.`;
205
285
  * Uses the persona's model config or falls back to global config.
206
286
  * Returns the generated text.
207
287
  */
208
- async function callAIForContribution(persona, config, contributionPrompt) {
288
+ async function callAIForContribution(persona, config, contributionPrompt, maxTokensOverride) {
209
289
  const soulPrompt = compileSoul(persona);
210
290
  const resolved = resolvePersonaAIConfig(persona, config);
291
+ const maxTokens = maxTokensOverride ?? resolved.maxTokens;
211
292
  if (resolved.provider === 'anthropic') {
212
- const apiKey = resolved.envVars['ANTHROPIC_API_KEY'] ?? process.env.ANTHROPIC_API_KEY ?? '';
293
+ const apiKey = resolved.envVars['ANTHROPIC_API_KEY']
294
+ ?? resolved.envVars['ANTHROPIC_AUTH_TOKEN']
295
+ ?? process.env.ANTHROPIC_API_KEY
296
+ ?? process.env.ANTHROPIC_AUTH_TOKEN
297
+ ?? '';
213
298
  const response = await fetch(joinBaseUrl(resolved.baseUrl, '/v1/messages'), {
214
299
  method: 'POST',
215
300
  headers: {
@@ -219,7 +304,7 @@ async function callAIForContribution(persona, config, contributionPrompt) {
219
304
  },
220
305
  body: JSON.stringify({
221
306
  model: resolved.model,
222
- max_tokens: resolved.maxTokens,
307
+ max_tokens: maxTokens,
223
308
  system: soulPrompt,
224
309
  messages: [{ role: 'user', content: contributionPrompt }],
225
310
  }),
@@ -241,7 +326,7 @@ async function callAIForContribution(persona, config, contributionPrompt) {
241
326
  },
242
327
  body: JSON.stringify({
243
328
  model: resolved.model,
244
- max_tokens: resolved.maxTokens,
329
+ max_tokens: maxTokens,
245
330
  temperature: resolved.temperature,
246
331
  messages: [
247
332
  { role: 'system', content: soulPrompt },
@@ -258,6 +343,167 @@ async function callAIForContribution(persona, config, contributionPrompt) {
258
343
  }
259
344
  return `[${persona.name}: No AI provider configured]`;
260
345
  }
346
+ /**
347
+ * Returns Anthropic tool definitions for board operations.
348
+ */
349
+ function buildBoardTools() {
350
+ const columnEnum = ["Draft", "Ready", "In Progress", "Review", "Done"];
351
+ return [
352
+ {
353
+ name: "open_github_issue",
354
+ description: "Create a new GitHub issue on the project board.",
355
+ input_schema: {
356
+ type: "object",
357
+ properties: {
358
+ title: { type: "string", description: "Short, descriptive issue title." },
359
+ body: { type: "string", description: "Detailed issue description in Markdown." },
360
+ column: { type: "string", enum: columnEnum, description: "Board column to place the issue in. Defaults to 'Ready'." },
361
+ },
362
+ required: ["title", "body"],
363
+ },
364
+ },
365
+ {
366
+ name: "list_issues",
367
+ description: "List issues on the project board, optionally filtered by column.",
368
+ input_schema: {
369
+ type: "object",
370
+ properties: {
371
+ column: { type: "string", enum: columnEnum, description: "Filter by column. Omit to list all issues." },
372
+ },
373
+ },
374
+ },
375
+ {
376
+ name: "move_issue",
377
+ description: "Move a GitHub issue to a different column on the board.",
378
+ input_schema: {
379
+ type: "object",
380
+ properties: {
381
+ issue_number: { type: "number", description: "The GitHub issue number." },
382
+ column: { type: "string", enum: columnEnum, description: "Target column." },
383
+ },
384
+ required: ["issue_number", "column"],
385
+ },
386
+ },
387
+ {
388
+ name: "comment_on_issue",
389
+ description: "Add a comment to an existing GitHub issue.",
390
+ input_schema: {
391
+ type: "object",
392
+ properties: {
393
+ issue_number: { type: "number", description: "The GitHub issue number." },
394
+ body: { type: "string", description: "Comment text in Markdown." },
395
+ },
396
+ required: ["issue_number", "body"],
397
+ },
398
+ },
399
+ {
400
+ name: "close_issue",
401
+ description: "Close a GitHub issue.",
402
+ input_schema: {
403
+ type: "object",
404
+ properties: {
405
+ issue_number: { type: "number", description: "The GitHub issue number." },
406
+ },
407
+ required: ["issue_number"],
408
+ },
409
+ },
410
+ ];
411
+ }
412
+ /**
413
+ * Execute a single board tool call and return a human-readable result string.
414
+ */
415
+ async function executeBoardTool(name, input, boardConfig, projectPath) {
416
+ const provider = createBoardProvider(boardConfig, projectPath);
417
+ switch (name) {
418
+ case "open_github_issue": {
419
+ const issue = await provider.createIssue({
420
+ title: String(input.title ?? ''),
421
+ body: String(input.body ?? ''),
422
+ column: input.column ?? 'Ready',
423
+ });
424
+ return JSON.stringify({ number: issue.number, url: issue.url, title: issue.title });
425
+ }
426
+ case "list_issues": {
427
+ const issues = input.column
428
+ ? await provider.getIssuesByColumn(input.column)
429
+ : await provider.getAllIssues();
430
+ return JSON.stringify(issues.map(i => ({ number: i.number, title: i.title, column: i.column, url: i.url })));
431
+ }
432
+ case "move_issue": {
433
+ await provider.moveIssue(Number(input.issue_number), input.column);
434
+ return `Issue #${input.issue_number} moved to ${String(input.column)}.`;
435
+ }
436
+ case "comment_on_issue": {
437
+ await provider.commentOnIssue(Number(input.issue_number), String(input.body ?? ''));
438
+ return `Comment added to issue #${input.issue_number}.`;
439
+ }
440
+ case "close_issue": {
441
+ await provider.closeIssue(Number(input.issue_number));
442
+ return `Issue #${input.issue_number} closed.`;
443
+ }
444
+ default:
445
+ return `Unknown tool: ${name}`;
446
+ }
447
+ }
448
+ /**
449
+ * Agentic loop for Anthropic with tool use.
450
+ * Calls the AI, executes any tool_use blocks, and loops until a final text reply is produced.
451
+ */
452
+ async function callAIWithTools(persona, config, prompt, tools, boardConfig, projectPath) {
453
+ const soulPrompt = compileSoul(persona);
454
+ const resolved = resolvePersonaAIConfig(persona, config);
455
+ const apiKey = resolved.envVars['ANTHROPIC_API_KEY']
456
+ ?? resolved.envVars['ANTHROPIC_AUTH_TOKEN']
457
+ ?? process.env.ANTHROPIC_API_KEY
458
+ ?? process.env.ANTHROPIC_AUTH_TOKEN
459
+ ?? '';
460
+ const messages = [{ role: 'user', content: prompt }];
461
+ const MAX_TOOL_ITERATIONS = 3;
462
+ for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
463
+ const response = await fetch(joinBaseUrl(resolved.baseUrl, '/v1/messages'), {
464
+ method: 'POST',
465
+ headers: {
466
+ 'Content-Type': 'application/json',
467
+ 'x-api-key': apiKey,
468
+ 'anthropic-version': '2023-06-01',
469
+ },
470
+ body: JSON.stringify({
471
+ model: resolved.model,
472
+ max_tokens: 1024,
473
+ system: soulPrompt,
474
+ tools,
475
+ messages,
476
+ }),
477
+ });
478
+ if (!response.ok) {
479
+ const error = await response.text();
480
+ throw new Error(`Anthropic API error: ${response.status} ${error}`);
481
+ }
482
+ const data = await response.json();
483
+ if (data.stop_reason !== 'tool_use') {
484
+ // Final reply — extract text
485
+ const textBlock = data.content.find(b => b.type === 'text');
486
+ return textBlock?.text?.trim() ?? '';
487
+ }
488
+ // Execute all tool_use blocks
489
+ const toolUseBlocks = data.content.filter(b => b.type === 'tool_use');
490
+ const toolResults = [];
491
+ for (const block of toolUseBlocks) {
492
+ let result;
493
+ try {
494
+ result = await executeBoardTool(block.name, block.input, boardConfig, projectPath);
495
+ }
496
+ catch (err) {
497
+ result = `Error: ${String(err)}`;
498
+ }
499
+ toolResults.push({ type: 'tool_result', tool_use_id: block.id, content: result });
500
+ }
501
+ // Append assistant turn and tool results to message history
502
+ messages.push({ role: 'assistant', content: data.content });
503
+ messages.push({ role: 'user', content: toolResults });
504
+ }
505
+ return `[${persona.name}: tool loop exceeded max iterations]`;
506
+ }
261
507
  const CANNED_PHRASE_PREFIXES = [
262
508
  /^great question[,.! ]*/i,
263
509
  /^of course[,.! ]*/i,
@@ -265,6 +511,59 @@ const CANNED_PHRASE_PREFIXES = [
265
511
  /^you['’]re absolutely right[,.! ]*/i,
266
512
  /^i hope this helps[,.! ]*/i,
267
513
  ];
514
+ function isSkipMessage(text) {
515
+ return text.trim().toUpperCase() === 'SKIP';
516
+ }
517
+ function normalizeForComparison(text) {
518
+ return text
519
+ .toLowerCase()
520
+ .replace(/[^a-z0-9\s]/g, ' ')
521
+ .replace(/\s+/g, ' ')
522
+ .trim();
523
+ }
524
+ function formatThreadHistory(messages) {
525
+ return messages
526
+ .map((message) => {
527
+ const body = message.text.replace(/\s+/g, ' ').trim();
528
+ if (!body)
529
+ return '';
530
+ const speaker = message.username?.trim() || 'Teammate';
531
+ return `${speaker}: ${body}`;
532
+ })
533
+ .filter(Boolean)
534
+ .join('\n');
535
+ }
536
+ function countThreadReplies(messages) {
537
+ return Math.max(0, messages.length - 1);
538
+ }
539
+ function chooseRoundContributors(personas, maxCount) {
540
+ if (maxCount <= 0)
541
+ return [];
542
+ const lead = findCarlos(personas);
543
+ if (!lead)
544
+ return personas.slice(0, maxCount);
545
+ const nonLead = personas.filter((persona) => persona.id !== lead.id);
546
+ const candidates = nonLead.length >= 2 ? nonLead : personas;
547
+ return candidates.slice(0, maxCount);
548
+ }
549
+ function dedupeRepeatedSentences(text) {
550
+ const parts = text
551
+ .split(/(?<=[.!?])\s+/)
552
+ .map((part) => part.trim())
553
+ .filter(Boolean);
554
+ if (parts.length <= 1)
555
+ return text;
556
+ const unique = [];
557
+ const seen = new Set();
558
+ for (const part of parts) {
559
+ const normalized = normalizeForComparison(part);
560
+ if (!normalized || seen.has(normalized))
561
+ continue;
562
+ seen.add(normalized);
563
+ unique.push(part);
564
+ }
565
+ return unique.join(' ');
566
+ }
268
567
  function limitEmojiCount(text, maxEmojis) {
269
568
  let seen = 0;
270
569
  return text.replace(/[\p{Extended_Pictographic}]/gu, (m) => {
@@ -310,6 +609,8 @@ export function humanizeSlackReply(raw, options = {}) {
310
609
  let text = raw.trim();
311
610
  if (!text)
312
611
  return text;
612
+ if (isSkipMessage(text))
613
+ return 'SKIP';
313
614
  // Remove markdown formatting artifacts that look templated in chat.
314
615
  text = text
315
616
  .replace(/^#{1,6}\s+/gm, '')
@@ -321,11 +622,12 @@ export function humanizeSlackReply(raw, options = {}) {
321
622
  for (const pattern of CANNED_PHRASE_PREFIXES) {
322
623
  text = text.replace(pattern, '').trim();
323
624
  }
625
+ text = dedupeRepeatedSentences(text);
324
626
  text = applyEmojiPolicy(text, allowEmoji, allowNonFacialEmoji);
325
627
  text = limitEmojiCount(text, 1);
326
628
  text = trimToSentences(text, maxSentences);
327
- if (text.length > 260) {
328
- text = `${text.slice(0, 257).trimEnd()}...`;
629
+ if (text.length > MAX_HUMANIZED_CHARS) {
630
+ text = `${text.slice(0, MAX_HUMANIZED_CHARS - 3).trimEnd()}...`;
329
631
  }
330
632
  return text;
331
633
  }
@@ -347,6 +649,33 @@ export class DeliberationEngine {
347
649
  this._slackClient = slackClient;
348
650
  this._config = config;
349
651
  }
652
+ _resolveReplyProjectPath(channel, threadTs) {
653
+ const repos = getRepositories();
654
+ const activeDiscussions = repos.slackDiscussion.getActive('');
655
+ const discussion = activeDiscussions.find((d) => d.channelId === channel && d.threadTs === threadTs);
656
+ if (discussion?.projectPath) {
657
+ return discussion.projectPath;
658
+ }
659
+ const projects = repos.projectRegistry.getAll();
660
+ const channelProject = projects.find((p) => p.slackChannelId === channel);
661
+ if (channelProject?.path) {
662
+ return channelProject.path;
663
+ }
664
+ return projects.length === 1 ? projects[0].path : null;
665
+ }
666
+ _resolveBoardConfig(projectPath) {
667
+ try {
668
+ const config = loadConfig(projectPath);
669
+ const boardConfig = config.boardProvider;
670
+ if (boardConfig?.enabled && typeof boardConfig.projectNumber === 'number') {
671
+ return boardConfig;
672
+ }
673
+ }
674
+ catch {
675
+ // Ignore config loading failures and treat as board-not-configured.
676
+ }
677
+ return null;
678
+ }
350
679
  _humanizeForPost(channel, threadTs, persona, raw) {
351
680
  const key = `${channel}:${threadTs}:${persona.id}`;
352
681
  const count = (this._emojiCadenceCounter.get(key) ?? 0) + 1;
@@ -403,6 +732,12 @@ export class DeliberationEngine {
403
732
  resolvedTrigger.channelId = project.slackChannelId;
404
733
  }
405
734
  }
735
+ if (resolvedTrigger.type === 'pr_review' && !hasConcreteCodeContext(resolvedTrigger.context)) {
736
+ const diffExcerpt = loadPrDiffExcerpt(resolvedTrigger.projectPath, resolvedTrigger.ref);
737
+ if (diffExcerpt) {
738
+ resolvedTrigger.context = `${resolvedTrigger.context}\n\n${diffExcerpt}`.slice(0, 5000);
739
+ }
740
+ }
406
741
  const channel = getChannelForTrigger(resolvedTrigger, this._config);
407
742
  if (!channel) {
408
743
  throw new Error(`No Slack channel configured for trigger type: ${trigger.type}`);
@@ -413,9 +748,9 @@ export class DeliberationEngine {
413
748
  throw new Error('No active agent personas found');
414
749
  }
415
750
  // Post opening message to start the thread
416
- const openingText = buildOpeningMessage(trigger);
751
+ const openingText = trigger.openingMessage ?? buildOpeningMessage(resolvedTrigger);
417
752
  const openingMsg = await this._slackClient.postAsAgent(channel, openingText, devPersona);
418
- await sleep(MESSAGE_DELAY_MS);
753
+ await sleep(humanDelay());
419
754
  // Create discussion record
420
755
  const discussion = repos.slackDiscussion.create({
421
756
  projectPath: trigger.projectPath,
@@ -430,9 +765,9 @@ export class DeliberationEngine {
430
765
  });
431
766
  // Run first round of contributions (excluding Dev who already posted)
432
767
  const reviewers = participants.filter((p) => p.id !== devPersona.id);
433
- await this._runContributionRound(discussion.id, reviewers, trigger, openingText);
768
+ await this._runContributionRound(discussion.id, reviewers, resolvedTrigger, openingText);
434
769
  // Check consensus after first round
435
- await this._evaluateConsensus(discussion.id, trigger);
770
+ await this._evaluateConsensus(discussion.id, resolvedTrigger);
436
771
  return repos.slackDiscussion.getById(discussion.id);
437
772
  }
438
773
  /**
@@ -445,7 +780,8 @@ export class DeliberationEngine {
445
780
  return;
446
781
  // Get thread history for context
447
782
  const history = await this._slackClient.getChannelHistory(discussion.channelId, discussion.threadTs, 10);
448
- const historyText = history.map(m => m.text).join('\n---\n');
783
+ const historyText = formatThreadHistory(history);
784
+ const historySet = new Set(history.map((m) => normalizeForComparison(m.text)).filter(Boolean));
449
785
  // Rebuild trigger context from discussion record
450
786
  const trigger = {
451
787
  type: discussion.triggerType,
@@ -458,14 +794,20 @@ export class DeliberationEngine {
458
794
  try {
459
795
  message = await callAIForContribution(persona, this._config, contributionPrompt);
460
796
  }
461
- catch (_err) {
797
+ catch (err) {
798
+ console.error(`[deliberation] callAIForContribution failed for ${persona.name}:`, err);
462
799
  message = `[Contribution from ${persona.name} unavailable — AI provider not configured]`;
463
800
  }
464
801
  if (message) {
465
802
  const finalMessage = this._humanizeForPost(discussion.channelId, discussion.threadTs, persona, message);
803
+ if (isSkipMessage(finalMessage))
804
+ return;
805
+ const normalized = normalizeForComparison(finalMessage);
806
+ if (!normalized || historySet.has(normalized))
807
+ return;
466
808
  await this._slackClient.postAsAgent(discussion.channelId, finalMessage, persona, discussion.threadTs);
467
809
  repos.slackDiscussion.addParticipant(discussionId, persona.id);
468
- await sleep(MESSAGE_DELAY_MS);
810
+ await sleep(humanDelay());
469
811
  }
470
812
  }
471
813
  /**
@@ -495,7 +837,7 @@ export class DeliberationEngine {
495
837
  if (!updated || updated.status !== 'active')
496
838
  return;
497
839
  await this._slackClient.postAsAgent(channel, "Ok, picking this back up. Let me see where we landed.", carlos, threadTs);
498
- await sleep(MESSAGE_DELAY_MS);
840
+ await sleep(humanDelay());
499
841
  await this._evaluateConsensus(discussion.id, {
500
842
  type: discussion.triggerType,
501
843
  projectPath: discussion.projectPath,
@@ -517,9 +859,18 @@ export class DeliberationEngine {
517
859
  if (!discussion)
518
860
  return;
519
861
  // Get current thread history
520
- const history = await this._slackClient.getChannelHistory(discussion.channelId, discussion.threadTs, 10);
521
- let historyText = history.map(m => m.text).join('\n---\n') || currentContext;
522
- for (const persona of personas) {
862
+ let history = await this._slackClient.getChannelHistory(discussion.channelId, discussion.threadTs, 10);
863
+ let historyText = formatThreadHistory(history) || currentContext;
864
+ const seenMessages = new Set(history.map((message) => normalizeForComparison(message.text)).filter(Boolean));
865
+ const repliesUsed = countThreadReplies(history);
866
+ const reviewerBudget = Math.max(0, MAX_AGENT_THREAD_REPLIES - repliesUsed - 1);
867
+ if (reviewerBudget <= 0)
868
+ return;
869
+ const contributors = chooseRoundContributors(personas, Math.min(MAX_CONTRIBUTIONS_PER_ROUND, reviewerBudget));
870
+ let posted = 0;
871
+ for (const persona of contributors) {
872
+ if (posted >= reviewerBudget)
873
+ break;
523
874
  const updatedDiscussion = repos.slackDiscussion.getById(discussionId);
524
875
  if (!updatedDiscussion || updatedDiscussion.status !== 'active')
525
876
  break;
@@ -531,13 +882,29 @@ export class DeliberationEngine {
531
882
  catch (_err) {
532
883
  message = '';
533
884
  }
534
- if (message) {
535
- const finalMessage = this._humanizeForPost(discussion.channelId, discussion.threadTs, persona, message);
536
- await this._slackClient.postAsAgent(discussion.channelId, finalMessage, persona, discussion.threadTs);
537
- repos.slackDiscussion.addParticipant(discussionId, persona.id);
538
- historyText = historyText ? `${historyText}\n---\n${finalMessage}` : finalMessage;
539
- await sleep(MESSAGE_DELAY_MS);
540
- }
885
+ if (!message || isSkipMessage(message))
886
+ continue;
887
+ const finalMessage = this._humanizeForPost(discussion.channelId, discussion.threadTs, persona, message);
888
+ if (!finalMessage || isSkipMessage(finalMessage))
889
+ continue;
890
+ const normalized = normalizeForComparison(finalMessage);
891
+ if (!normalized || seenMessages.has(normalized))
892
+ continue;
893
+ await this._slackClient.postAsAgent(discussion.channelId, finalMessage, persona, discussion.threadTs);
894
+ repos.slackDiscussion.addParticipant(discussionId, persona.id);
895
+ seenMessages.add(normalized);
896
+ posted += 1;
897
+ history = [
898
+ ...history,
899
+ {
900
+ ts: `${Date.now()}-${persona.id}`,
901
+ channel: discussion.channelId,
902
+ text: finalMessage,
903
+ username: persona.name,
904
+ },
905
+ ];
906
+ historyText = formatThreadHistory(history) || historyText;
907
+ await sleep(humanDelay());
541
908
  }
542
909
  }
543
910
  /**
@@ -560,18 +927,26 @@ export class DeliberationEngine {
560
927
  }
561
928
  // Get thread history and let Carlos evaluate
562
929
  const history = await this._slackClient.getChannelHistory(discussion.channelId, discussion.threadTs, 20);
563
- const historyText = history.map(m => m.text).join('\n---\n');
930
+ const historyText = formatThreadHistory(history);
931
+ const repliesUsed = countThreadReplies(history);
932
+ const repliesLeft = Math.max(0, MAX_AGENT_THREAD_REPLIES - repliesUsed);
933
+ if (repliesLeft <= 0) {
934
+ repos.slackDiscussion.updateStatus(discussionId, 'blocked', 'human_needed');
935
+ return;
936
+ }
564
937
  const consensusPrompt = `You are ${carlos.name}, ${carlos.role}. You're wrapping up a team discussion.
565
938
 
566
939
  Thread:
567
- ${historyText}
940
+ ${historyText || '(No thread history available)'}
568
941
 
569
942
  Round: ${discussion.round}/${MAX_ROUNDS}
570
943
 
571
- Make the call. Are we done, do we need another pass, or does a human need to weigh in?
944
+ Make the call. Are we done, do we need one more pass, or does a human need to weigh in?
945
+ - Keep it brief and decisive. No recap of the whole thread.
946
+ - If you approve, do not restate prior arguments.
572
947
 
573
948
  Respond with EXACTLY one of these formats (include the prefix):
574
- - APPROVE: [short closing message in your voice — e.g., "Clean. Let's ship it."]
949
+ - APPROVE: [one short closing message in your voice — e.g., "Clean. Let's ship it."]
575
950
  - CHANGES: [what specifically still needs work — be concrete, not vague]
576
951
  - HUMAN: [why this needs a human decision — be specific about what's ambiguous]
577
952
 
@@ -584,15 +959,27 @@ Write the prefix and your message. Nothing else.`;
584
959
  decision = 'HUMAN: AI evaluation failed — needs manual review';
585
960
  }
586
961
  if (decision.startsWith('APPROVE')) {
587
- const message = decision.replace(/^APPROVE:\s*/, '').trim() || 'Clean. Ship it.';
588
- await this._slackClient.postAsAgent(discussion.channelId, message, carlos, discussion.threadTs);
962
+ const message = humanizeSlackReply(decision.replace(/^APPROVE:\s*/, '').trim() || 'Clean. Ship it.', { allowEmoji: false, maxSentences: 1 });
963
+ if (!isSkipMessage(message)) {
964
+ await this._slackClient.postAsAgent(discussion.channelId, message, carlos, discussion.threadTs);
965
+ }
589
966
  repos.slackDiscussion.updateStatus(discussionId, 'consensus', 'approved');
967
+ if (trigger.type === 'code_watch') {
968
+ await this.triggerIssueOpener(discussionId, trigger)
969
+ .catch((e) => console.warn('Issue opener failed:', String(e)));
970
+ }
590
971
  return;
591
972
  }
592
- if (decision.startsWith('CHANGES') && discussion.round < MAX_ROUNDS) {
973
+ if (decision.startsWith('CHANGES') && discussion.round < MAX_ROUNDS && repliesLeft >= 3) {
593
974
  const changes = decision.replace(/^CHANGES:\s*/, '').trim();
594
- await this._slackClient.postAsAgent(discussion.channelId, changes, carlos, discussion.threadTs);
595
- await sleep(MESSAGE_DELAY_MS);
975
+ const changesMessage = humanizeSlackReply(changes || 'Need one more pass on a couple items.', {
976
+ allowEmoji: false,
977
+ maxSentences: 1,
978
+ });
979
+ if (!isSkipMessage(changesMessage)) {
980
+ await this._slackClient.postAsAgent(discussion.channelId, changesMessage, carlos, discussion.threadTs);
981
+ }
982
+ await sleep(humanDelay());
596
983
  // Increment round and start another contribution round, then loop back.
597
984
  const nextRound = discussion.round + 1;
598
985
  repos.slackDiscussion.updateRound(discussionId, nextRound);
@@ -602,10 +989,14 @@ Write the prefix and your message. Nothing else.`;
602
989
  await this._runContributionRound(discussionId, reviewers, trigger, changes);
603
990
  continue;
604
991
  }
605
- if (decision.startsWith('CHANGES') && discussion.round >= MAX_ROUNDS) {
606
- // Max rounds reached — set changes_requested and optionally trigger PR refinement
992
+ if (decision.startsWith('CHANGES')) {
607
993
  const changesSummary = decision.replace(/^CHANGES:\s*/, '').trim();
608
- 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);
994
+ const summaryMessage = humanizeSlackReply(changesSummary
995
+ ? `Need changes before merge: ${changesSummary}`
996
+ : 'Need changes before merge. Please address the thread notes.', { allowEmoji: false, maxSentences: 2 });
997
+ if (!isSkipMessage(summaryMessage)) {
998
+ await this._slackClient.postAsAgent(discussion.channelId, summaryMessage, carlos, discussion.threadTs);
999
+ }
609
1000
  repos.slackDiscussion.updateStatus(discussionId, 'consensus', 'changes_requested');
610
1001
  if (discussion.triggerType === 'pr_review') {
611
1002
  await this.triggerPRRefinement(discussionId, changesSummary, discussion.triggerRef).catch(e => console.warn('PR refinement trigger failed:', e));
@@ -614,9 +1005,12 @@ Write the prefix and your message. Nothing else.`;
614
1005
  }
615
1006
  // HUMAN or fallback
616
1007
  const humanReason = decision.replace(/^HUMAN:\s*/, '').trim();
617
- await this._slackClient.postAsAgent(discussion.channelId, humanReason
618
- ? `Need a human on this one — ${humanReason}`
619
- : 'This needs a human call. Flagging it.', carlos, discussion.threadTs);
1008
+ const humanMessage = humanizeSlackReply(humanReason
1009
+ ? `Need a human decision: ${humanReason}`
1010
+ : 'Need a human decision on this one.', { allowEmoji: false, maxSentences: 1 });
1011
+ if (!isSkipMessage(humanMessage)) {
1012
+ await this._slackClient.postAsAgent(discussion.channelId, humanMessage, carlos, discussion.threadTs);
1013
+ }
620
1014
  repos.slackDiscussion.updateStatus(discussionId, 'blocked', 'human_needed');
621
1015
  return;
622
1016
  }
@@ -635,7 +1029,7 @@ Write the prefix and your message. Nothing else.`;
635
1029
  const actor = carlos?.name ?? 'Night Watch';
636
1030
  if (carlos) {
637
1031
  await this._slackClient.postAsAgent(discussion.channelId, `Sending PR #${prNumber} back through with the notes.`, carlos, discussion.threadTs);
638
- await sleep(MESSAGE_DELAY_MS);
1032
+ await sleep(humanDelay());
639
1033
  }
640
1034
  // Set NW_SLACK_FEEDBACK and trigger reviewer
641
1035
  const feedback = JSON.stringify({ discussionId, prNumber, changes: changesSummary });
@@ -669,7 +1063,8 @@ Write the prefix and your message. Nothing else.`;
669
1063
  catch {
670
1064
  // Ignore — reply with just the incoming text as context
671
1065
  }
672
- const historyText = history.map((m) => m.text).join('\n---\n');
1066
+ const historyText = formatThreadHistory(history);
1067
+ const historySet = new Set(history.map((m) => normalizeForComparison(m.text)).filter(Boolean));
673
1068
  const prompt = `You are ${persona.name}, ${persona.role}.\n` +
674
1069
  `Your teammates: Dev (implementer), Carlos (tech lead), Maya (security), Priya (QA).\n\n` +
675
1070
  (projectContext ? `Project context: ${projectContext}\n\n` : '') +
@@ -681,18 +1076,42 @@ Write the prefix and your message. Nothing else.`;
681
1076
  `- No markdown formatting, headings, or bullet lists.\n` +
682
1077
  `- Emojis: one max, only if it fits naturally. Default to none.\n` +
683
1078
  `- If the question is outside your domain, say so briefly and point to the right person.\n` +
684
- `- If you disagree, say why in one line. If you agree, keep it short.\n\n` +
1079
+ `- If you disagree, say why in one line. If you agree, keep it short.\n` +
1080
+ `- Base opinions on concrete code evidence from context (file path, symbol, diff, or stack/log detail).\n` +
1081
+ `- If there is no concrete code evidence, ask for the exact file/diff before giving an opinion.\n` +
1082
+ `- You have board tools available. If asked to open, update, or list issues, use them — don't just say you will.\n\n` +
685
1083
  `Write only your reply. No name prefix.`;
1084
+ const projectPathForTools = this._resolveReplyProjectPath(channel, threadTs);
1085
+ const boardConfig = projectPathForTools
1086
+ ? this._resolveBoardConfig(projectPathForTools)
1087
+ : null;
1088
+ const resolved = resolvePersonaAIConfig(persona, this._config);
1089
+ const useTools = Boolean(projectPathForTools && boardConfig && resolved.provider === 'anthropic');
686
1090
  let message;
687
1091
  try {
688
- message = await callAIForContribution(persona, this._config, prompt);
1092
+ if (useTools) {
1093
+ message = await callAIWithTools(persona, this._config, prompt, buildBoardTools(), boardConfig, projectPathForTools);
1094
+ }
1095
+ else {
1096
+ // Allow up to 1024 tokens for ad-hoc replies so agents can write substantive responses
1097
+ message = await callAIForContribution(persona, this._config, prompt, 1024);
1098
+ }
689
1099
  }
690
- catch {
1100
+ catch (err) {
1101
+ console.error(`[deliberation] reply failed for ${persona.name}:`, err);
691
1102
  message = `[Reply from ${persona.name} unavailable — AI provider not configured]`;
692
1103
  }
693
1104
  if (message) {
694
- await this._slackClient.postAsAgent(channel, this._humanizeForPost(channel, threadTs, persona, message), persona, threadTs);
1105
+ const finalMessage = this._humanizeForPost(channel, threadTs, persona, message);
1106
+ if (isSkipMessage(finalMessage))
1107
+ return '';
1108
+ const normalized = normalizeForComparison(finalMessage);
1109
+ if (!normalized || historySet.has(normalized))
1110
+ return '';
1111
+ await this._slackClient.postAsAgent(channel, finalMessage, persona, threadTs);
1112
+ return finalMessage;
695
1113
  }
1114
+ return '';
696
1115
  }
697
1116
  /**
698
1117
  * Generate and post a proactive message from a persona.
@@ -738,5 +1157,198 @@ Write the prefix and your message. Nothing else.`;
738
1157
  await this._slackClient.postAsAgent(channel, finalMessage, persona);
739
1158
  }
740
1159
  }
1160
+ /**
1161
+ * Generate a structured GitHub issue body written by the Dev persona.
1162
+ */
1163
+ async _generateIssueBody(trigger, devPersona) {
1164
+ const prompt = `You are ${devPersona.name}, ${devPersona.role}.
1165
+ Use the PRD rigor from ~/.claude/skills/prd-creator/SKILL.md:
1166
+ - explicit implementation plan
1167
+ - testable phases
1168
+ - concrete verification steps
1169
+ - no vague filler
1170
+
1171
+ Write a concise GitHub issue body for this code scan finding.
1172
+ Use this structure exactly (GitHub Markdown):
1173
+
1174
+ ## Context
1175
+ - Problem: one sentence
1176
+ - Current behavior: one sentence
1177
+ - Risk if ignored: one sentence
1178
+
1179
+ ## Proposed Fix
1180
+ - Primary approach
1181
+ - Files likely touched (max 5, include paths when possible)
1182
+
1183
+ ## Execution Plan
1184
+ ### Phase 1: [name]
1185
+ - [ ] Implementation step
1186
+ - [ ] Tests to add/update
1187
+
1188
+ ### Phase 2: [name]
1189
+ - [ ] Implementation step
1190
+ - [ ] Tests to add/update
1191
+
1192
+ ## Verification
1193
+ - [ ] Automated: specific tests or commands to run
1194
+ - [ ] Manual: one concrete validation step
1195
+
1196
+ ## Done Criteria
1197
+ - [ ] Bug condition is no longer reproducible
1198
+ - [ ] Regression coverage is added
1199
+ - [ ] Error handling/logging is clear and non-silent
1200
+
1201
+ Keep it under ~450 words. No fluff, no greetings, no generic "future work" sections.
1202
+
1203
+ Context:
1204
+ ${trigger.context}`;
1205
+ const raw = await callAIForContribution(devPersona, this._config, prompt);
1206
+ return raw.trim();
1207
+ }
1208
+ /**
1209
+ * Have Dev read the actual code and decide whether a scanner finding is worth raising.
1210
+ * Returns Dev's Slack-ready observation, or null if Dev thinks it's not worth posting.
1211
+ */
1212
+ async analyzeCodeCandidate(fileContext, signalSummary, location) {
1213
+ const repos = getRepositories();
1214
+ const personas = repos.agentPersona.getActive();
1215
+ const devPersona = findDev(personas);
1216
+ if (!devPersona)
1217
+ return null;
1218
+ const prompt = `You are ${devPersona.name}, ${devPersona.role}.\n` +
1219
+ `Your scanner flagged something. Before you bring it up with the team, read the actual code and decide if it's genuinely worth raising.\n\n` +
1220
+ `Signal: ${signalSummary}\n` +
1221
+ `Location: ${location}\n\n` +
1222
+ `Code:\n\`\`\`\n${fileContext.slice(0, 3000)}\n\`\`\`\n\n` +
1223
+ `Is this a real concern? Give your honest take in 1-2 sentences as a Slack message to the team.\n\n` +
1224
+ `Rules:\n` +
1225
+ `- If it's clearly fine (intentional, test code, well-handled, noise) → respond with exactly: SKIP\n` +
1226
+ `- If it's worth flagging, write what you'd drop in Slack in your own voice. Name the specific risk.\n` +
1227
+ `- Sound like a teammate noticing something, not a scanner filing a report.\n` +
1228
+ `- No markdown, no bullet points. No "I noticed" or "The code has".\n` +
1229
+ `- Never start with "Great question", "Of course", or similar.\n\n` +
1230
+ `Write only your message or SKIP.`;
1231
+ try {
1232
+ const result = await callAIForContribution(devPersona, this._config, prompt);
1233
+ if (!result || result.trim().toUpperCase() === 'SKIP')
1234
+ return null;
1235
+ return humanizeSlackReply(result, { allowEmoji: false, maxSentences: 2 });
1236
+ }
1237
+ catch {
1238
+ return null;
1239
+ }
1240
+ }
1241
+ /**
1242
+ * Triage an audit report, file a GitHub issue if warranted, and post a short Slack ping.
1243
+ * No discussion thread — Dev just drops a link in the channel and moves on.
1244
+ */
1245
+ async handleAuditReport(report, projectName, projectPath, channel) {
1246
+ if (!report || report.trim() === 'NO_ISSUES_FOUND')
1247
+ return;
1248
+ const repos = getRepositories();
1249
+ const personas = repos.agentPersona.getActive();
1250
+ const devPersona = findDev(personas);
1251
+ if (!devPersona)
1252
+ return;
1253
+ // Step 1: Dev triages the report — worth filing? If yes, give a one-liner for Slack.
1254
+ const triagePrompt = `You are ${devPersona.name}, ${devPersona.role}.\n` +
1255
+ `The code auditor just finished scanning ${projectName} and wrote this report:\n\n` +
1256
+ `${report.slice(0, 3000)}\n\n` +
1257
+ `Should this be filed as a GitHub issue for the team to track?\n\n` +
1258
+ `Rules:\n` +
1259
+ `- If the findings are genuinely worth tracking (medium or high severity, real risk) → reply with:\n` +
1260
+ ` FILE: [one short sentence you'd drop in Slack — specific about what was found, no filler]\n` +
1261
+ `- If everything is minor, intentional, or noise → reply with exactly: SKIP\n` +
1262
+ `- Be honest. Don't file issues for trivial noise.\n\n` +
1263
+ `Write only FILE: [sentence] or SKIP.`;
1264
+ let triage;
1265
+ try {
1266
+ triage = await callAIForContribution(devPersona, this._config, triagePrompt, 256);
1267
+ }
1268
+ catch {
1269
+ return;
1270
+ }
1271
+ if (!triage || triage.trim().toUpperCase() === 'SKIP' || !/^FILE:/i.test(triage.trim())) {
1272
+ console.log(`[deliberation][audit] Dev skipped filing for ${projectName}`);
1273
+ return;
1274
+ }
1275
+ const slackOneliner = triage.replace(/^FILE:\s*/i, '').trim();
1276
+ if (!slackOneliner)
1277
+ return;
1278
+ // Step 2: Generate a proper GitHub issue body via Dev
1279
+ const fakeTrigger = {
1280
+ type: 'code_watch',
1281
+ projectPath,
1282
+ ref: `audit-${Date.now()}`,
1283
+ context: `Project: ${projectName}\n\nAudit report:\n${report.slice(0, 2000)}`,
1284
+ };
1285
+ const issueTitle = `fix: ${slackOneliner
1286
+ .toLowerCase()
1287
+ .replace(/[.!?]+$/, '')
1288
+ .replace(/^(found|noticed|flagging|caught)\s+/i, '')
1289
+ .slice(0, 80)}`;
1290
+ const issueBody = await this._generateIssueBody(fakeTrigger, devPersona).catch(() => report.slice(0, 1200));
1291
+ // Step 3: Create GitHub issue (if board is configured for this project)
1292
+ const boardConfig = this._resolveBoardConfig(projectPath);
1293
+ let issueUrl = null;
1294
+ if (boardConfig) {
1295
+ try {
1296
+ const provider = createBoardProvider(boardConfig, projectPath);
1297
+ const issue = await provider.createIssue({ title: issueTitle, body: issueBody, column: 'Ready' });
1298
+ issueUrl = issue.url;
1299
+ console.log(`[deliberation][audit] filed issue #${issue.number} for ${projectName}: ${issueUrl}`);
1300
+ }
1301
+ catch (err) {
1302
+ console.warn('[deliberation][audit] failed to create GitHub issue:', err);
1303
+ }
1304
+ }
1305
+ // Step 4: Post brief Slack notification — just a link drop, no thread
1306
+ const slackMsg = issueUrl
1307
+ ? `${slackOneliner} → ${issueUrl}`
1308
+ : humanizeSlackReply(slackOneliner, { allowEmoji: false, maxSentences: 2 });
1309
+ try {
1310
+ await this._slackClient.postAsAgent(channel, slackMsg, devPersona);
1311
+ }
1312
+ catch (err) {
1313
+ const msg = err instanceof Error ? err.message : String(err);
1314
+ console.warn(`[deliberation][audit] failed to post Slack notification: ${msg}`);
1315
+ }
1316
+ }
1317
+ /**
1318
+ * Open a GitHub issue from a code_watch finding and post back to the thread.
1319
+ * Called automatically after an approved code_watch consensus.
1320
+ */
1321
+ async triggerIssueOpener(discussionId, trigger) {
1322
+ const repos = getRepositories();
1323
+ const discussion = repos.slackDiscussion.getById(discussionId);
1324
+ if (!discussion)
1325
+ return;
1326
+ const devPersona = findDev(repos.agentPersona.getActive());
1327
+ if (!devPersona)
1328
+ return;
1329
+ // Acknowledge before doing async work
1330
+ await this._slackClient.postAsAgent(discussion.channelId, 'Agreed. Writing up an issue for this.', devPersona, discussion.threadTs);
1331
+ const title = buildIssueTitleFromTrigger(trigger);
1332
+ const body = await this._generateIssueBody(trigger, devPersona);
1333
+ const boardConfig = this._resolveBoardConfig(trigger.projectPath);
1334
+ if (boardConfig) {
1335
+ try {
1336
+ const provider = createBoardProvider(boardConfig, trigger.projectPath);
1337
+ const issue = await provider.createIssue({ title, body, column: 'In Progress' });
1338
+ if (issue.column !== 'In Progress') {
1339
+ await provider.moveIssue(issue.number, 'In Progress').catch(() => undefined);
1340
+ }
1341
+ await this._slackClient.postAsAgent(discussion.channelId, `Opened #${issue.number}: ${issue.title} — ${issue.url}\nTaking first pass now. It's in In Progress.`, devPersona, discussion.threadTs);
1342
+ }
1343
+ catch (err) {
1344
+ console.warn('[issue_opener] board createIssue failed:', err);
1345
+ 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);
1346
+ }
1347
+ }
1348
+ else {
1349
+ // No board configured — post the writeup in thread so it's not lost
1350
+ await this._slackClient.postAsAgent(discussion.channelId, `No board configured, dropping the writeup here:\n\n${body.slice(0, 600)}`, devPersona, discussion.threadTs);
1351
+ }
1352
+ }
741
1353
  }
742
1354
  //# sourceMappingURL=deliberation.js.map