@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.
- package/dist/shared/types.d.ts +1 -0
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/src/cli.js +3 -0
- package/dist/src/cli.js.map +1 -1
- package/dist/src/commands/audit.d.ts +19 -0
- package/dist/src/commands/audit.d.ts.map +1 -0
- package/dist/src/commands/audit.js +98 -0
- package/dist/src/commands/audit.js.map +1 -0
- package/dist/src/commands/dashboard.js +1 -1
- package/dist/src/commands/dashboard.js.map +1 -1
- package/dist/src/commands/init.d.ts.map +1 -1
- package/dist/src/commands/init.js +1 -6
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/install.d.ts +4 -0
- package/dist/src/commands/install.d.ts.map +1 -1
- package/dist/src/commands/install.js +25 -20
- package/dist/src/commands/install.js.map +1 -1
- package/dist/src/commands/logs.js +3 -3
- package/dist/src/commands/logs.js.map +1 -1
- package/dist/src/commands/prs.js +2 -2
- package/dist/src/commands/prs.js.map +1 -1
- package/dist/src/commands/review.d.ts.map +1 -1
- package/dist/src/commands/review.js +13 -5
- package/dist/src/commands/review.js.map +1 -1
- package/dist/src/commands/uninstall.d.ts.map +1 -1
- package/dist/src/commands/uninstall.js +3 -22
- package/dist/src/commands/uninstall.js.map +1 -1
- package/dist/src/config.d.ts.map +1 -1
- package/dist/src/config.js +30 -1
- package/dist/src/config.js.map +1 -1
- package/dist/src/constants.d.ts +10 -3
- package/dist/src/constants.d.ts.map +1 -1
- package/dist/src/constants.js +15 -2
- package/dist/src/constants.js.map +1 -1
- package/dist/src/server/index.d.ts.map +1 -1
- package/dist/src/server/index.js +50 -3
- package/dist/src/server/index.js.map +1 -1
- package/dist/src/slack/client.d.ts +3 -2
- package/dist/src/slack/client.d.ts.map +1 -1
- package/dist/src/slack/client.js +5 -6
- package/dist/src/slack/client.js.map +1 -1
- package/dist/src/slack/deliberation.d.ts +13 -1
- package/dist/src/slack/deliberation.d.ts.map +1 -1
- package/dist/src/slack/deliberation.js +585 -71
- package/dist/src/slack/deliberation.js.map +1 -1
- package/dist/src/slack/interaction-listener.d.ts +27 -9
- package/dist/src/slack/interaction-listener.d.ts.map +1 -1
- package/dist/src/slack/interaction-listener.js +357 -197
- package/dist/src/slack/interaction-listener.js.map +1 -1
- package/dist/src/storage/repositories/sqlite/agent-persona-repository.d.ts +3 -2
- package/dist/src/storage/repositories/sqlite/agent-persona-repository.d.ts.map +1 -1
- package/dist/src/storage/repositories/sqlite/agent-persona-repository.js +14 -11
- package/dist/src/storage/repositories/sqlite/agent-persona-repository.js.map +1 -1
- package/dist/src/types.d.ts +13 -0
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/utils/notify.d.ts.map +1 -1
- package/dist/src/utils/notify.js +5 -1
- package/dist/src/utils/notify.js.map +1 -1
- package/dist/src/utils/status-data.d.ts +2 -2
- package/dist/src/utils/status-data.d.ts.map +1 -1
- package/dist/src/utils/status-data.js +78 -123
- package/dist/src/utils/status-data.js.map +1 -1
- package/package.json +3 -1
- package/scripts/night-watch-audit-cron.sh +149 -0
- package/scripts/night-watch-cron.sh +33 -14
- package/scripts/night-watch-helpers.sh +10 -2
- package/scripts/night-watch-pr-reviewer-cron.sh +224 -18
- package/web/dist/assets/index-BiJf9LFT.js +458 -0
- package/web/dist/assets/index-OpSgvsYu.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-CndIPm_F.js +0 -473
- 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
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
];
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
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,
|
|
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']
|
|
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:
|
|
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:
|
|
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 >
|
|
357
|
-
text = `${text.slice(0,
|
|
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(
|
|
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,
|
|
768
|
+
await this._runContributionRound(discussion.id, reviewers, resolvedTrigger, openingText);
|
|
463
769
|
// Check consensus after first round
|
|
464
|
-
await this._evaluateConsensus(discussion.id,
|
|
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
|
|
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 (
|
|
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
|
-
|
|
550
|
-
let historyText = history
|
|
551
|
-
|
|
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
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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')
|
|
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
|
-
|
|
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
|
-
|
|
651
|
-
? `Need a human
|
|
652
|
-
: '
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
783
|
-
|
|
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
|
-
##
|
|
786
|
-
|
|
1183
|
+
## Execution Plan
|
|
1184
|
+
### Phase 1: [name]
|
|
1185
|
+
- [ ] Implementation step
|
|
1186
|
+
- [ ] Tests to add/update
|
|
787
1187
|
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
\`\`\`
|
|
1188
|
+
### Phase 2: [name]
|
|
1189
|
+
- [ ] Implementation step
|
|
1190
|
+
- [ ] Tests to add/update
|
|
792
1191
|
|
|
793
|
-
##
|
|
794
|
-
|
|
1192
|
+
## Verification
|
|
1193
|
+
- [ ] Automated: specific tests or commands to run
|
|
1194
|
+
- [ ] Manual: one concrete validation step
|
|
795
1195
|
|
|
796
|
-
##
|
|
797
|
-
- [ ]
|
|
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
|
|
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.
|
|
823
|
-
if (boardConfig
|
|
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: '
|
|
827
|
-
|
|
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);
|