@jonit-dev/night-watch-cli 1.7.27 → 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 (72) hide show
  1. package/dist/shared/types.d.ts +1 -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 +3 -2
  39. package/dist/src/slack/client.d.ts.map +1 -1
  40. package/dist/src/slack/client.js +5 -6
  41. package/dist/src/slack/client.js.map +1 -1
  42. package/dist/src/slack/deliberation.d.ts +13 -1
  43. package/dist/src/slack/deliberation.d.ts.map +1 -1
  44. package/dist/src/slack/deliberation.js +585 -71
  45. package/dist/src/slack/deliberation.js.map +1 -1
  46. package/dist/src/slack/interaction-listener.d.ts +27 -9
  47. package/dist/src/slack/interaction-listener.d.ts.map +1 -1
  48. package/dist/src/slack/interaction-listener.js +357 -197
  49. package/dist/src/slack/interaction-listener.js.map +1 -1
  50. package/dist/src/storage/repositories/sqlite/agent-persona-repository.d.ts +3 -2
  51. package/dist/src/storage/repositories/sqlite/agent-persona-repository.d.ts.map +1 -1
  52. package/dist/src/storage/repositories/sqlite/agent-persona-repository.js +14 -11
  53. package/dist/src/storage/repositories/sqlite/agent-persona-repository.js.map +1 -1
  54. package/dist/src/types.d.ts +13 -0
  55. package/dist/src/types.d.ts.map +1 -1
  56. package/dist/src/utils/notify.d.ts.map +1 -1
  57. package/dist/src/utils/notify.js +5 -1
  58. package/dist/src/utils/notify.js.map +1 -1
  59. package/dist/src/utils/status-data.d.ts +2 -2
  60. package/dist/src/utils/status-data.d.ts.map +1 -1
  61. package/dist/src/utils/status-data.js +78 -123
  62. package/dist/src/utils/status-data.js.map +1 -1
  63. package/package.json +3 -1
  64. package/scripts/night-watch-audit-cron.sh +149 -0
  65. package/scripts/night-watch-cron.sh +33 -14
  66. package/scripts/night-watch-helpers.sh +10 -2
  67. package/scripts/night-watch-pr-reviewer-cron.sh +224 -18
  68. package/web/dist/assets/index-BiJf9LFT.js +458 -0
  69. package/web/dist/assets/index-OpSgvsYu.css +1 -0
  70. package/web/dist/index.html +2 -2
  71. package/web/dist/assets/index-CndIPm_F.js +0 -473
  72. package/web/dist/assets/index-w6Q6gxCS.css +0 -1
@@ -5,13 +5,18 @@
5
5
  */
6
6
  import { compileSoul } from "../agents/soul-compiler.js";
7
7
  import { getRepositories } from "../storage/repositories/index.js";
8
+ import { loadConfig } from "../config.js";
8
9
  import { createBoardProvider } from "../board/factory.js";
9
- const MAX_ROUNDS = 3;
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;
10
14
  const HUMAN_DELAY_MIN_MS = 20_000; // Minimum pause between agent replies (20s)
11
15
  const HUMAN_DELAY_MAX_MS = 60_000; // Maximum pause between agent replies (60s)
12
16
  const DISCUSSION_RESUME_DELAY_MS = 60_000;
13
17
  const DISCUSSION_REPLAY_GUARD_MS = 30 * 60_000;
14
18
  const MAX_HUMANIZED_SENTENCES = 2;
19
+ const MAX_HUMANIZED_CHARS = 220;
15
20
  const inFlightDiscussionStarts = new Map();
16
21
  function discussionStartKey(trigger) {
17
22
  return `${trigger.projectPath}:${trigger.type}:${trigger.ref}`;
@@ -169,16 +174,26 @@ function buildOpeningMessage(trigger) {
169
174
  case 'prd_kickoff':
170
175
  return `Picking up ${trigger.ref}. Going to start carving out the implementation.`;
171
176
  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)}`;
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);
182
197
  }
183
198
  default:
184
199
  return trigger.context.slice(0, 500);
@@ -194,6 +209,37 @@ function buildIssueTitleFromTrigger(trigger) {
194
209
  const location = locationMatch?.[1] ?? 'unknown location';
195
210
  return `fix: ${signal} at ${location}`;
196
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
+ }
197
243
  /**
198
244
  * Build the contribution prompt for an agent's AI call.
199
245
  * This is what gets sent to the AI provider to generate the agent's message.
@@ -214,12 +260,17 @@ ${trigger.context.slice(0, 2000)}
214
260
  ${threadHistory || '(Thread just started)'}
215
261
 
216
262
  ## How to respond
217
- 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.
218
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
219
270
  - Talk like a teammate, not an assistant. No pleasantries, no filler.
220
271
  - Stay in your lane — only comment on your domain unless something crosses into it.
221
272
  - 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.
273
+ - If nothing concerns you, use SKIP instead of posting filler.
223
274
  - If you have a concern, name it specifically and suggest a direction.
224
275
  - No markdown formatting. No bullet lists. No headings. Just a message.
225
276
  - Emojis: use one only if it genuinely fits. Default to none.
@@ -234,11 +285,16 @@ Write ONLY your message. No name prefix, no labels.`;
234
285
  * Uses the persona's model config or falls back to global config.
235
286
  * Returns the generated text.
236
287
  */
237
- async function callAIForContribution(persona, config, contributionPrompt) {
288
+ async function callAIForContribution(persona, config, contributionPrompt, maxTokensOverride) {
238
289
  const soulPrompt = compileSoul(persona);
239
290
  const resolved = resolvePersonaAIConfig(persona, config);
291
+ const maxTokens = maxTokensOverride ?? resolved.maxTokens;
240
292
  if (resolved.provider === 'anthropic') {
241
- 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
+ ?? '';
242
298
  const response = await fetch(joinBaseUrl(resolved.baseUrl, '/v1/messages'), {
243
299
  method: 'POST',
244
300
  headers: {
@@ -248,7 +304,7 @@ async function callAIForContribution(persona, config, contributionPrompt) {
248
304
  },
249
305
  body: JSON.stringify({
250
306
  model: resolved.model,
251
- max_tokens: resolved.maxTokens,
307
+ max_tokens: maxTokens,
252
308
  system: soulPrompt,
253
309
  messages: [{ role: 'user', content: contributionPrompt }],
254
310
  }),
@@ -270,7 +326,7 @@ async function callAIForContribution(persona, config, contributionPrompt) {
270
326
  },
271
327
  body: JSON.stringify({
272
328
  model: resolved.model,
273
- max_tokens: resolved.maxTokens,
329
+ max_tokens: maxTokens,
274
330
  temperature: resolved.temperature,
275
331
  messages: [
276
332
  { role: 'system', content: soulPrompt },
@@ -287,6 +343,167 @@ async function callAIForContribution(persona, config, contributionPrompt) {
287
343
  }
288
344
  return `[${persona.name}: No AI provider configured]`;
289
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
+ }
290
507
  const CANNED_PHRASE_PREFIXES = [
291
508
  /^great question[,.! ]*/i,
292
509
  /^of course[,.! ]*/i,
@@ -294,6 +511,59 @@ const CANNED_PHRASE_PREFIXES = [
294
511
  /^you['’]re absolutely right[,.! ]*/i,
295
512
  /^i hope this helps[,.! ]*/i,
296
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
+ }
297
567
  function limitEmojiCount(text, maxEmojis) {
298
568
  let seen = 0;
299
569
  return text.replace(/[\p{Extended_Pictographic}]/gu, (m) => {
@@ -339,6 +609,8 @@ export function humanizeSlackReply(raw, options = {}) {
339
609
  let text = raw.trim();
340
610
  if (!text)
341
611
  return text;
612
+ if (isSkipMessage(text))
613
+ return 'SKIP';
342
614
  // Remove markdown formatting artifacts that look templated in chat.
343
615
  text = text
344
616
  .replace(/^#{1,6}\s+/gm, '')
@@ -350,11 +622,12 @@ export function humanizeSlackReply(raw, options = {}) {
350
622
  for (const pattern of CANNED_PHRASE_PREFIXES) {
351
623
  text = text.replace(pattern, '').trim();
352
624
  }
625
+ text = dedupeRepeatedSentences(text);
353
626
  text = applyEmojiPolicy(text, allowEmoji, allowNonFacialEmoji);
354
627
  text = limitEmojiCount(text, 1);
355
628
  text = trimToSentences(text, maxSentences);
356
- if (text.length > 260) {
357
- text = `${text.slice(0, 257).trimEnd()}...`;
629
+ if (text.length > MAX_HUMANIZED_CHARS) {
630
+ text = `${text.slice(0, MAX_HUMANIZED_CHARS - 3).trimEnd()}...`;
358
631
  }
359
632
  return text;
360
633
  }
@@ -376,6 +649,33 @@ export class DeliberationEngine {
376
649
  this._slackClient = slackClient;
377
650
  this._config = config;
378
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
+ }
379
679
  _humanizeForPost(channel, threadTs, persona, raw) {
380
680
  const key = `${channel}:${threadTs}:${persona.id}`;
381
681
  const count = (this._emojiCadenceCounter.get(key) ?? 0) + 1;
@@ -432,6 +732,12 @@ export class DeliberationEngine {
432
732
  resolvedTrigger.channelId = project.slackChannelId;
433
733
  }
434
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
+ }
435
741
  const channel = getChannelForTrigger(resolvedTrigger, this._config);
436
742
  if (!channel) {
437
743
  throw new Error(`No Slack channel configured for trigger type: ${trigger.type}`);
@@ -442,7 +748,7 @@ export class DeliberationEngine {
442
748
  throw new Error('No active agent personas found');
443
749
  }
444
750
  // Post opening message to start the thread
445
- const openingText = buildOpeningMessage(trigger);
751
+ const openingText = trigger.openingMessage ?? buildOpeningMessage(resolvedTrigger);
446
752
  const openingMsg = await this._slackClient.postAsAgent(channel, openingText, devPersona);
447
753
  await sleep(humanDelay());
448
754
  // Create discussion record
@@ -459,9 +765,9 @@ export class DeliberationEngine {
459
765
  });
460
766
  // Run first round of contributions (excluding Dev who already posted)
461
767
  const reviewers = participants.filter((p) => p.id !== devPersona.id);
462
- await this._runContributionRound(discussion.id, reviewers, trigger, openingText);
768
+ await this._runContributionRound(discussion.id, reviewers, resolvedTrigger, openingText);
463
769
  // Check consensus after first round
464
- await this._evaluateConsensus(discussion.id, trigger);
770
+ await this._evaluateConsensus(discussion.id, resolvedTrigger);
465
771
  return repos.slackDiscussion.getById(discussion.id);
466
772
  }
467
773
  /**
@@ -474,7 +780,8 @@ export class DeliberationEngine {
474
780
  return;
475
781
  // Get thread history for context
476
782
  const history = await this._slackClient.getChannelHistory(discussion.channelId, discussion.threadTs, 10);
477
- 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));
478
785
  // Rebuild trigger context from discussion record
479
786
  const trigger = {
480
787
  type: discussion.triggerType,
@@ -487,11 +794,17 @@ export class DeliberationEngine {
487
794
  try {
488
795
  message = await callAIForContribution(persona, this._config, contributionPrompt);
489
796
  }
490
- catch (_err) {
797
+ catch (err) {
798
+ console.error(`[deliberation] callAIForContribution failed for ${persona.name}:`, err);
491
799
  message = `[Contribution from ${persona.name} unavailable — AI provider not configured]`;
492
800
  }
493
801
  if (message) {
494
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;
495
808
  await this._slackClient.postAsAgent(discussion.channelId, finalMessage, persona, discussion.threadTs);
496
809
  repos.slackDiscussion.addParticipant(discussionId, persona.id);
497
810
  await sleep(humanDelay());
@@ -546,9 +859,18 @@ export class DeliberationEngine {
546
859
  if (!discussion)
547
860
  return;
548
861
  // Get current thread history
549
- const history = await this._slackClient.getChannelHistory(discussion.channelId, discussion.threadTs, 10);
550
- let historyText = history.map(m => m.text).join('\n---\n') || currentContext;
551
- 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;
552
874
  const updatedDiscussion = repos.slackDiscussion.getById(discussionId);
553
875
  if (!updatedDiscussion || updatedDiscussion.status !== 'active')
554
876
  break;
@@ -560,13 +882,29 @@ export class DeliberationEngine {
560
882
  catch (_err) {
561
883
  message = '';
562
884
  }
563
- if (message) {
564
- const finalMessage = this._humanizeForPost(discussion.channelId, discussion.threadTs, persona, message);
565
- await this._slackClient.postAsAgent(discussion.channelId, finalMessage, persona, discussion.threadTs);
566
- repos.slackDiscussion.addParticipant(discussionId, persona.id);
567
- historyText = historyText ? `${historyText}\n---\n${finalMessage}` : finalMessage;
568
- await sleep(humanDelay());
569
- }
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());
570
908
  }
571
909
  }
572
910
  /**
@@ -589,18 +927,26 @@ export class DeliberationEngine {
589
927
  }
590
928
  // Get thread history and let Carlos evaluate
591
929
  const history = await this._slackClient.getChannelHistory(discussion.channelId, discussion.threadTs, 20);
592
- 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
+ }
593
937
  const consensusPrompt = `You are ${carlos.name}, ${carlos.role}. You're wrapping up a team discussion.
594
938
 
595
939
  Thread:
596
- ${historyText}
940
+ ${historyText || '(No thread history available)'}
597
941
 
598
942
  Round: ${discussion.round}/${MAX_ROUNDS}
599
943
 
600
- 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.
601
947
 
602
948
  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."]
949
+ - APPROVE: [one short closing message in your voice — e.g., "Clean. Let's ship it."]
604
950
  - CHANGES: [what specifically still needs work — be concrete, not vague]
605
951
  - HUMAN: [why this needs a human decision — be specific about what's ambiguous]
606
952
 
@@ -613,8 +959,10 @@ Write the prefix and your message. Nothing else.`;
613
959
  decision = 'HUMAN: AI evaluation failed — needs manual review';
614
960
  }
615
961
  if (decision.startsWith('APPROVE')) {
616
- const message = decision.replace(/^APPROVE:\s*/, '').trim() || 'Clean. Ship it.';
617
- 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
+ }
618
966
  repos.slackDiscussion.updateStatus(discussionId, 'consensus', 'approved');
619
967
  if (trigger.type === 'code_watch') {
620
968
  await this.triggerIssueOpener(discussionId, trigger)
@@ -622,9 +970,15 @@ Write the prefix and your message. Nothing else.`;
622
970
  }
623
971
  return;
624
972
  }
625
- if (decision.startsWith('CHANGES') && discussion.round < MAX_ROUNDS) {
973
+ if (decision.startsWith('CHANGES') && discussion.round < MAX_ROUNDS && repliesLeft >= 3) {
626
974
  const changes = decision.replace(/^CHANGES:\s*/, '').trim();
627
- await this._slackClient.postAsAgent(discussion.channelId, changes, carlos, discussion.threadTs);
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
+ }
628
982
  await sleep(humanDelay());
629
983
  // Increment round and start another contribution round, then loop back.
630
984
  const nextRound = discussion.round + 1;
@@ -635,10 +989,14 @@ Write the prefix and your message. Nothing else.`;
635
989
  await this._runContributionRound(discussionId, reviewers, trigger, changes);
636
990
  continue;
637
991
  }
638
- if (decision.startsWith('CHANGES') && discussion.round >= MAX_ROUNDS) {
639
- // Max rounds reached — set changes_requested and optionally trigger PR refinement
992
+ if (decision.startsWith('CHANGES')) {
640
993
  const changesSummary = decision.replace(/^CHANGES:\s*/, '').trim();
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);
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
+ }
642
1000
  repos.slackDiscussion.updateStatus(discussionId, 'consensus', 'changes_requested');
643
1001
  if (discussion.triggerType === 'pr_review') {
644
1002
  await this.triggerPRRefinement(discussionId, changesSummary, discussion.triggerRef).catch(e => console.warn('PR refinement trigger failed:', e));
@@ -647,9 +1005,12 @@ Write the prefix and your message. Nothing else.`;
647
1005
  }
648
1006
  // HUMAN or fallback
649
1007
  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);
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
+ }
653
1014
  repos.slackDiscussion.updateStatus(discussionId, 'blocked', 'human_needed');
654
1015
  return;
655
1016
  }
@@ -702,7 +1063,8 @@ Write the prefix and your message. Nothing else.`;
702
1063
  catch {
703
1064
  // Ignore — reply with just the incoming text as context
704
1065
  }
705
- 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));
706
1068
  const prompt = `You are ${persona.name}, ${persona.role}.\n` +
707
1069
  `Your teammates: Dev (implementer), Carlos (tech lead), Maya (security), Priya (QA).\n\n` +
708
1070
  (projectContext ? `Project context: ${projectContext}\n\n` : '') +
@@ -714,18 +1076,42 @@ Write the prefix and your message. Nothing else.`;
714
1076
  `- No markdown formatting, headings, or bullet lists.\n` +
715
1077
  `- Emojis: one max, only if it fits naturally. Default to none.\n` +
716
1078
  `- 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` +
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` +
718
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');
719
1090
  let message;
720
1091
  try {
721
- 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
+ }
722
1099
  }
723
- catch {
1100
+ catch (err) {
1101
+ console.error(`[deliberation] reply failed for ${persona.name}:`, err);
724
1102
  message = `[Reply from ${persona.name} unavailable — AI provider not configured]`;
725
1103
  }
726
1104
  if (message) {
727
- 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;
728
1113
  }
1114
+ return '';
729
1115
  }
730
1116
  /**
731
1117
  * Generate and post a proactive message from a persona.
@@ -776,33 +1162,158 @@ Write the prefix and your message. Nothing else.`;
776
1162
  */
777
1163
  async _generateIssueBody(trigger, devPersona) {
778
1164
  const prompt = `You are ${devPersona.name}, ${devPersona.role}.
779
- Write a concise GitHub issue body for the following code scan finding.
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.
780
1172
  Use this structure exactly (GitHub Markdown):
781
1173
 
782
- ## Problem
783
- One sentence describing what was detected and why it's risky.
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)
784
1182
 
785
- ## Location
786
- File and line where the issue exists.
1183
+ ## Execution Plan
1184
+ ### Phase 1: [name]
1185
+ - [ ] Implementation step
1186
+ - [ ] Tests to add/update
787
1187
 
788
- ## Code
789
- \`\`\`
790
- The offending snippet
791
- \`\`\`
1188
+ ### Phase 2: [name]
1189
+ - [ ] Implementation step
1190
+ - [ ] Tests to add/update
792
1191
 
793
- ## Suggested Fix
794
- 2-3 bullet points on how to address it.
1192
+ ## Verification
1193
+ - [ ] Automated: specific tests or commands to run
1194
+ - [ ] Manual: one concrete validation step
795
1195
 
796
- ## Acceptance Criteria
797
- - [ ] Checkbox items describing what "done" looks like
1196
+ ## Done Criteria
1197
+ - [ ] Bug condition is no longer reproducible
1198
+ - [ ] Regression coverage is added
1199
+ - [ ] Error handling/logging is clear and non-silent
798
1200
 
799
- Keep it tight this is a bug report, not a spec. No fluff, no greetings.
1201
+ Keep it under ~450 words. No fluff, no greetings, no generic "future work" sections.
800
1202
 
801
1203
  Context:
802
1204
  ${trigger.context}`;
803
1205
  const raw = await callAIForContribution(devPersona, this._config, prompt);
804
1206
  return raw.trim();
805
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
+ }
806
1317
  /**
807
1318
  * Open a GitHub issue from a code_watch finding and post back to the thread.
808
1319
  * Called automatically after an approved code_watch consensus.
@@ -819,12 +1330,15 @@ ${trigger.context}`;
819
1330
  await this._slackClient.postAsAgent(discussion.channelId, 'Agreed. Writing up an issue for this.', devPersona, discussion.threadTs);
820
1331
  const title = buildIssueTitleFromTrigger(trigger);
821
1332
  const body = await this._generateIssueBody(trigger, devPersona);
822
- const boardConfig = this._config.boardProvider;
823
- if (boardConfig?.enabled) {
1333
+ const boardConfig = this._resolveBoardConfig(trigger.projectPath);
1334
+ if (boardConfig) {
824
1335
  try {
825
1336
  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);
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);
828
1342
  }
829
1343
  catch (err) {
830
1344
  console.warn('[issue_opener] board createIssue failed:', err);