@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
@@ -4,7 +4,7 @@
4
4
  * and applies loop-protection safeguards.
5
5
  */
6
6
  import { SocketModeClient } from '@slack/socket-mode';
7
- import { spawn } from 'child_process';
7
+ import { execFileSync, spawn } from 'child_process';
8
8
  import * as fs from 'fs';
9
9
  import * as path from 'path';
10
10
  import { getDb } from '../storage/sqlite/client.js';
@@ -21,49 +21,13 @@ const PROACTIVE_IDLE_MS = 20 * 60_000; // 20 min
21
21
  const PROACTIVE_MIN_INTERVAL_MS = 90 * 60_000; // per channel
22
22
  const PROACTIVE_SWEEP_INTERVAL_MS = 60_000;
23
23
  const PROACTIVE_CODEWATCH_MIN_INTERVAL_MS = 3 * 60 * 60_000; // per project
24
- const PROACTIVE_CODEWATCH_REPEAT_COOLDOWN_MS = 24 * 60 * 60_000; // per issue signature
25
24
  const MAX_JOB_OUTPUT_CHARS = 12_000;
26
25
  const HUMAN_REACTION_PROBABILITY = 0.65;
27
26
  const REACTION_DELAY_MIN_MS = 180;
28
27
  const REACTION_DELAY_MAX_MS = 1200;
29
28
  const RESPONSE_DELAY_MIN_MS = 700;
30
29
  const RESPONSE_DELAY_MAX_MS = 3400;
31
- const CODEWATCH_MAX_FILES = 250;
32
- const CODEWATCH_MAX_FILE_BYTES = 256_000;
33
- const CODEWATCH_INCLUDE_EXTENSIONS = new Set([
34
- '.ts',
35
- '.tsx',
36
- '.js',
37
- '.jsx',
38
- '.mjs',
39
- '.cjs',
40
- '.py',
41
- '.go',
42
- '.rb',
43
- '.java',
44
- '.kt',
45
- '.rs',
46
- '.php',
47
- '.cs',
48
- '.swift',
49
- '.scala',
50
- '.sh',
51
- ]);
52
- const CODEWATCH_IGNORE_DIRS = new Set([
53
- '.git',
54
- 'node_modules',
55
- 'dist',
56
- 'build',
57
- '.next',
58
- 'coverage',
59
- '.turbo',
60
- '.cache',
61
- 'logs',
62
- '.yarn',
63
- 'vendor',
64
- 'tmp',
65
- 'temp',
66
- ]);
30
+ const SOCKET_DISCONNECT_TIMEOUT_MS = 5_000;
67
31
  const JOB_STOPWORDS = new Set([
68
32
  'and',
69
33
  'or',
@@ -128,126 +92,6 @@ function normalizeForParsing(text) {
128
92
  .replace(/\s+/g, ' ')
129
93
  .trim();
130
94
  }
131
- function isCodeWatchSourceFile(filePath) {
132
- return CODEWATCH_INCLUDE_EXTENSIONS.has(path.extname(filePath).toLowerCase());
133
- }
134
- function lineNumberAt(content, index) {
135
- if (index <= 0)
136
- return 1;
137
- let line = 1;
138
- for (let i = 0; i < index && i < content.length; i += 1) {
139
- if (content.charCodeAt(i) === 10) {
140
- line += 1;
141
- }
142
- }
143
- return line;
144
- }
145
- function extractLineSnippet(content, index) {
146
- const clamped = Math.max(0, Math.min(index, content.length));
147
- const before = content.lastIndexOf('\n', clamped);
148
- const after = content.indexOf('\n', clamped);
149
- const start = before === -1 ? 0 : before + 1;
150
- const end = after === -1 ? content.length : after;
151
- return content.slice(start, end).trim().slice(0, 220);
152
- }
153
- function walkProjectFilesForCodeWatch(projectPath) {
154
- const files = [];
155
- const stack = [projectPath];
156
- while (stack.length > 0 && files.length < CODEWATCH_MAX_FILES) {
157
- const dir = stack.pop();
158
- if (!dir)
159
- break;
160
- let entries;
161
- try {
162
- entries = fs.readdirSync(dir, { withFileTypes: true });
163
- }
164
- catch {
165
- continue;
166
- }
167
- for (const entry of entries) {
168
- if (files.length >= CODEWATCH_MAX_FILES)
169
- break;
170
- const fullPath = path.join(dir, entry.name);
171
- if (entry.isDirectory()) {
172
- if (CODEWATCH_IGNORE_DIRS.has(entry.name)) {
173
- continue;
174
- }
175
- stack.push(fullPath);
176
- continue;
177
- }
178
- if (!entry.isFile()) {
179
- continue;
180
- }
181
- if (isCodeWatchSourceFile(fullPath)) {
182
- files.push(fullPath);
183
- }
184
- }
185
- }
186
- return files;
187
- }
188
- export function findCodeWatchSignal(content) {
189
- if (!content || content.trim().length === 0)
190
- return null;
191
- const emptyCatchMatch = /catch\s*(?:\([^)]*\))?\s*\{\s*(?:(?:\/\/[^\n]*|\/\*[\s\S]*?\*\/)\s*)*\}/gm.exec(content);
192
- const criticalTodoMatch = /\b(?:TODO|FIXME|HACK)\b[^\n]{0,140}\b(?:bug|security|race|leak|crash|hotfix|rollback|unsafe)\b/gi.exec(content);
193
- const emptyCatchIndex = emptyCatchMatch?.index ?? Number.POSITIVE_INFINITY;
194
- const criticalTodoIndex = criticalTodoMatch?.index ?? Number.POSITIVE_INFINITY;
195
- if (!Number.isFinite(emptyCatchIndex) && !Number.isFinite(criticalTodoIndex)) {
196
- return null;
197
- }
198
- if (emptyCatchIndex <= criticalTodoIndex) {
199
- return {
200
- type: 'empty_catch',
201
- index: emptyCatchIndex,
202
- summary: 'empty catch block may hide runtime failures',
203
- snippet: extractLineSnippet(content, emptyCatchIndex),
204
- };
205
- }
206
- return {
207
- type: 'critical_todo',
208
- index: criticalTodoIndex,
209
- summary: 'high-risk TODO/FIXME likely indicates unresolved bug or security concern',
210
- snippet: (criticalTodoMatch?.[0] ?? extractLineSnippet(content, criticalTodoIndex)).trim().slice(0, 220),
211
- };
212
- }
213
- function detectCodeWatchCandidate(projectPath) {
214
- const files = walkProjectFilesForCodeWatch(projectPath);
215
- for (const filePath of files) {
216
- let stat;
217
- try {
218
- stat = fs.statSync(filePath);
219
- }
220
- catch {
221
- continue;
222
- }
223
- if (stat.size <= 0 || stat.size > CODEWATCH_MAX_FILE_BYTES) {
224
- continue;
225
- }
226
- let content;
227
- try {
228
- content = fs.readFileSync(filePath, 'utf-8');
229
- }
230
- catch {
231
- continue;
232
- }
233
- if (content.includes('\u0000')) {
234
- continue;
235
- }
236
- const signal = findCodeWatchSignal(content);
237
- if (!signal)
238
- continue;
239
- const relativePath = path.relative(projectPath, filePath).replace(/\\/g, '/');
240
- const line = lineNumberAt(content, signal.index);
241
- const signature = `${signal.type}:${relativePath}:${line}`;
242
- return {
243
- ...signal,
244
- relativePath,
245
- line,
246
- signature,
247
- };
248
- }
249
- return null;
250
- }
251
95
  export function isAmbientTeamMessage(text) {
252
96
  const normalized = normalizeForParsing(stripSlackUserMentions(text));
253
97
  if (!normalized)
@@ -294,6 +138,78 @@ export function parseSlackJobRequest(text) {
294
138
  request.fixConflicts = true;
295
139
  return request;
296
140
  }
141
+ export function parseSlackIssuePickupRequest(text) {
142
+ const withoutMentions = stripSlackUserMentions(text);
143
+ const normalized = normalizeForParsing(withoutMentions);
144
+ if (!normalized)
145
+ return null;
146
+ // Extract GitHub issue URL — NOT pull requests (those handled by parseSlackJobRequest)
147
+ const compactForUrl = withoutMentions.replace(/\s+/g, '');
148
+ let issueUrl;
149
+ let issueNumber;
150
+ let repo;
151
+ // Standard format: github.com/{owner}/{repo}/issues/{number}
152
+ const directIssueMatch = compactForUrl.match(/https?:\/\/github\.com\/([^/\s<>]+)\/([^/\s<>]+)\/issues\/(\d+)/i);
153
+ if (directIssueMatch) {
154
+ [issueUrl, , repo, issueNumber] = directIssueMatch;
155
+ repo = repo.toLowerCase();
156
+ }
157
+ else {
158
+ // Project board format: github.com/...?...&issue={owner}%7C{repo}%7C{number}
159
+ // e.g. github.com/users/jonit-dev/projects/41/views/2?pane=issue&issue=jonit-dev%7Cnight-watch-cli%7C12
160
+ const boardMatch = compactForUrl.match(/https?:\/\/github\.com\/[^<>\s]*[?&]issue=([^<>\s&]+)/i);
161
+ if (!boardMatch)
162
+ return null;
163
+ const rawParam = boardMatch[1].replace(/%7[Cc]/g, '|');
164
+ const parts = rawParam.split('|');
165
+ if (parts.length < 3 || !/^\d+$/.test(parts[parts.length - 1]))
166
+ return null;
167
+ issueNumber = parts[parts.length - 1];
168
+ repo = parts[parts.length - 2].toLowerCase();
169
+ issueUrl = boardMatch[0];
170
+ }
171
+ // Requires pickup-intent language or "this issue" + request language
172
+ // "pickup" (one word) is also accepted alongside "pick up" (two words)
173
+ const pickupSignal = /\b(pick\s+up|pickup|work\s+on|implement|tackle|start\s+on|grab|handle\s+this|ship\s+this)\b/i.test(normalized);
174
+ const requestSignal = /\b(please|can\s+someone|anyone)\b/i.test(normalized) && /\bthis\s+issue\b/i.test(normalized);
175
+ if (!pickupSignal && !requestSignal)
176
+ return null;
177
+ return {
178
+ issueNumber,
179
+ issueUrl,
180
+ repoHint: repo,
181
+ };
182
+ }
183
+ export function parseSlackProviderRequest(text) {
184
+ const withoutMentions = stripSlackUserMentions(text);
185
+ if (!withoutMentions.trim())
186
+ return null;
187
+ // Explicit direct-provider invocation from Slack, e.g.:
188
+ // "claude fix the flaky tests", "run codex on repo-x: investigate CI failures"
189
+ const prefixMatch = withoutMentions.match(/^\s*(?:can\s+(?:you|someone|anyone)\s+)?(?:please\s+)?(?:(?:run|use|invoke|trigger|ask)\s+)?(claude|codex)\b[\s:,-]*/i);
190
+ if (!prefixMatch)
191
+ return null;
192
+ const provider = prefixMatch[1].toLowerCase();
193
+ let remainder = withoutMentions.slice(prefixMatch[0].length).trim();
194
+ if (!remainder)
195
+ return null;
196
+ let projectHint;
197
+ const projectMatch = remainder.match(/^(?:for|on)\s+([a-z0-9./_-]+)\b[\s:,-]*/i);
198
+ if (projectMatch) {
199
+ const candidate = projectMatch[1].toLowerCase();
200
+ if (!JOB_STOPWORDS.has(candidate)) {
201
+ projectHint = candidate;
202
+ }
203
+ remainder = remainder.slice(projectMatch[0].length).trim();
204
+ }
205
+ if (!remainder)
206
+ return null;
207
+ return {
208
+ provider,
209
+ prompt: remainder,
210
+ ...(projectHint ? { projectHint } : {}),
211
+ };
212
+ }
297
213
  function getPersonaDomain(persona) {
298
214
  const role = persona.role.toLowerCase();
299
215
  const expertise = (persona.soul?.expertise ?? []).join(' ').toLowerCase();
@@ -442,6 +358,46 @@ export function shouldIgnoreInboundSlackEvent(event, botUserId) {
442
358
  return true;
443
359
  return false;
444
360
  }
361
+ /**
362
+ * Extract GitHub issue or PR URLs from a message string.
363
+ */
364
+ export function extractGitHubIssueUrls(text) {
365
+ const matches = text.match(/https?:\/\/github\.com\/[^\s<>]+/g) ?? [];
366
+ return matches.filter((u) => /\/(issues|pull)\/\d+/.test(u));
367
+ }
368
+ /**
369
+ * Fetch GitHub issue/PR content via `gh api` for agent context.
370
+ * Returns a formatted string, or '' on failure.
371
+ */
372
+ async function fetchGitHubIssueContext(urls) {
373
+ if (urls.length === 0)
374
+ return '';
375
+ const parts = [];
376
+ for (const url of urls.slice(0, 3)) {
377
+ const match = url.match(/github\.com\/([^/]+)\/([^/]+)\/(issues|pull)\/(\d+)/);
378
+ if (!match)
379
+ continue;
380
+ const [, owner, repo, type, number] = match;
381
+ const endpoint = type === 'pull'
382
+ ? `/repos/${owner}/${repo}/pulls/${number}`
383
+ : `/repos/${owner}/${repo}/issues/${number}`;
384
+ try {
385
+ const raw = execFileSync('gh', ['api', endpoint, '--jq', '{title: .title, state: .state, body: .body, labels: [.labels[].name]}'], {
386
+ timeout: 10_000,
387
+ encoding: 'utf-8',
388
+ stdio: ['ignore', 'pipe', 'pipe'],
389
+ });
390
+ const data = JSON.parse(raw);
391
+ const labelStr = data.labels.length > 0 ? ` [${data.labels.join(', ')}]` : '';
392
+ const body = (data.body ?? '').trim().slice(0, 1200);
393
+ parts.push(`GitHub ${type === 'pull' ? 'PR' : 'Issue'} #${number}${labelStr}: ${data.title} (${data.state})\n${body}`);
394
+ }
395
+ catch {
396
+ // gh not available or not authenticated — skip
397
+ }
398
+ }
399
+ return parts.join('\n\n---\n\n');
400
+ }
445
401
  export class SlackInteractionListener {
446
402
  _config;
447
403
  _slackClient;
@@ -455,7 +411,6 @@ export class SlackInteractionListener {
455
411
  _lastChannelActivityAt = new Map();
456
412
  _lastProactiveAt = new Map();
457
413
  _lastCodeWatchAt = new Map();
458
- _lastCodeWatchSignatureAt = new Map();
459
414
  _proactiveTimer = null;
460
415
  constructor(config) {
461
416
  this._config = config;
@@ -513,14 +468,21 @@ export class SlackInteractionListener {
513
468
  const socket = this._socketClient;
514
469
  this._socketClient = null;
515
470
  try {
516
- socket.removeAllListeners();
517
- await socket.disconnect();
471
+ await Promise.race([
472
+ socket.disconnect(),
473
+ sleep(SOCKET_DISCONNECT_TIMEOUT_MS).then(() => {
474
+ throw new Error(`timed out after ${SOCKET_DISCONNECT_TIMEOUT_MS}ms`);
475
+ }),
476
+ ]);
518
477
  console.log('Slack interaction listener stopped');
519
478
  }
520
479
  catch (err) {
521
480
  const msg = err instanceof Error ? err.message : String(err);
522
481
  console.warn(`Slack interaction listener shutdown failed: ${msg}`);
523
482
  }
483
+ finally {
484
+ socket.removeAllListeners();
485
+ }
524
486
  }
525
487
  /**
526
488
  * Join all configured channels, generate avatars for personas that need them,
@@ -672,6 +634,27 @@ export class SlackInteractionListener {
672
634
  expiresAt: Date.now() + AD_HOC_THREAD_MEMORY_MS,
673
635
  });
674
636
  }
637
+ /**
638
+ * After an agent posts a reply, check if the text mentions other personas by plain name.
639
+ * If so, trigger those personas to respond once (no further cascading — depth 1 only).
640
+ * This enables natural agent-to-agent handoffs like "Carlos, what's the priority here?"
641
+ */
642
+ async _followAgentMentions(postedText, channel, threadTs, personas, projectContext, skipPersonaId) {
643
+ if (!postedText)
644
+ return;
645
+ const mentioned = resolvePersonasByPlainName(postedText, personas).filter((p) => p.id !== skipPersonaId && !this._isPersonaOnCooldown(channel, threadTs, p.id));
646
+ if (mentioned.length === 0)
647
+ return;
648
+ console.log(`[slack] agent mention follow-up: ${mentioned.map((p) => p.name).join(', ')}`);
649
+ for (const persona of mentioned) {
650
+ // Small human-like delay before the tagged persona responds
651
+ await sleep(this._randomInt(RESPONSE_DELAY_MIN_MS * 2, RESPONSE_DELAY_MAX_MS * 3));
652
+ // replyAsAgent fetches thread history internally so Carlos sees Dev's message
653
+ await this._engine.replyAsAgent(channel, threadTs, postedText, persona, projectContext);
654
+ this._markPersonaReply(channel, threadTs, persona.id);
655
+ this._rememberAdHocThreadPersona(channel, threadTs, persona.id);
656
+ }
657
+ }
675
658
  /**
676
659
  * Recover the persona that last replied in a thread by scanning its history.
677
660
  * Used as a fallback when in-memory state was lost (e.g. after a server restart).
@@ -844,6 +827,7 @@ export class SlackInteractionListener {
844
827
  ...process.env,
845
828
  NW_EXECUTION_CONTEXT: 'agent',
846
829
  ...(opts?.prNumber ? { NW_TARGET_PR: opts.prNumber } : {}),
830
+ ...(opts?.issueNumber ? { NW_TARGET_ISSUE: opts.issueNumber } : {}),
847
831
  ...(opts?.fixConflicts
848
832
  ? {
849
833
  NW_SLACK_FEEDBACK: JSON.stringify({
@@ -903,6 +887,96 @@ export class SlackInteractionListener {
903
887
  this._markPersonaReply(channel, threadTs, persona.id);
904
888
  });
905
889
  }
890
+ async _spawnDirectProviderRequest(request, project, channel, threadTs, persona) {
891
+ const providerLabel = request.provider === 'claude' ? 'Claude' : 'Codex';
892
+ const args = request.provider === 'claude'
893
+ ? ['-p', request.prompt, '--dangerously-skip-permissions']
894
+ : ['--quiet', '--yolo', '--prompt', request.prompt];
895
+ console.log(`[slack][provider] persona=${persona.name} provider=${request.provider} project=${project.name} spawn=${formatCommandForLog(request.provider, args)}`);
896
+ const child = spawn(request.provider, args, {
897
+ cwd: project.path,
898
+ env: {
899
+ ...process.env,
900
+ ...(this._config.providerEnv ?? {}),
901
+ NW_EXECUTION_CONTEXT: 'agent',
902
+ },
903
+ stdio: ['ignore', 'pipe', 'pipe'],
904
+ });
905
+ console.log(`[slack][provider] ${persona.name} spawned ${request.provider} for ${project.name} pid=${child.pid ?? 'unknown'}`);
906
+ let output = '';
907
+ let errored = false;
908
+ const appendOutput = (chunk) => {
909
+ output += chunk.toString();
910
+ if (output.length > MAX_JOB_OUTPUT_CHARS) {
911
+ output = output.slice(-MAX_JOB_OUTPUT_CHARS);
912
+ }
913
+ };
914
+ child.stdout?.on('data', appendOutput);
915
+ child.stderr?.on('data', appendOutput);
916
+ child.on('error', async (err) => {
917
+ errored = true;
918
+ console.warn(`[slack][provider] ${persona.name} ${request.provider} spawn error for ${project.name}: ${err.message}`);
919
+ await this._slackClient.postAsAgent(channel, `Couldn't start ${providerLabel}. Error logged — looking into it.`, persona, threadTs);
920
+ this._markChannelActivity(channel);
921
+ this._markPersonaReply(channel, threadTs, persona.id);
922
+ });
923
+ child.on('close', async (code) => {
924
+ if (errored)
925
+ return;
926
+ console.log(`[slack][provider] ${persona.name} ${request.provider} finished for ${project.name} exit=${code ?? 'unknown'}`);
927
+ const detail = extractLastMeaningfulLines(output);
928
+ if (code === 0) {
929
+ await this._slackClient.postAsAgent(channel, `${providerLabel} command finished.`, persona, threadTs);
930
+ }
931
+ else {
932
+ if (detail) {
933
+ console.warn(`[slack][provider] ${persona.name} ${request.provider} failure detail: ${detail}`);
934
+ }
935
+ await this._slackClient.postAsAgent(channel, `${providerLabel} hit a snag. Logged the details — looking into it.`, persona, threadTs);
936
+ }
937
+ this._markChannelActivity(channel);
938
+ this._markPersonaReply(channel, threadTs, persona.id);
939
+ });
940
+ }
941
+ async _triggerDirectProviderIfRequested(event, channel, threadTs, messageTs, personas) {
942
+ const request = parseSlackProviderRequest(event.text ?? '');
943
+ if (!request)
944
+ return false;
945
+ const addressedToBot = this._isMessageAddressedToBot(event);
946
+ const normalized = normalizeForParsing(stripSlackUserMentions(event.text ?? ''));
947
+ const startsWithProviderCommand = /^(?:can\s+(?:you|someone|anyone)\s+)?(?:please\s+)?(?:(?:run|use|invoke|trigger|ask)\s+)?(?:claude|codex)\b/i.test(normalized);
948
+ if (!addressedToBot && !startsWithProviderCommand) {
949
+ return false;
950
+ }
951
+ const repos = getRepositories();
952
+ const projects = repos.projectRegistry.getAll();
953
+ const persona = this._findPersonaByName(personas, 'Dev')
954
+ ?? this._pickRandomPersona(personas, channel, threadTs)
955
+ ?? personas[0];
956
+ if (!persona)
957
+ return false;
958
+ const targetProject = this._resolveTargetProject(channel, projects, request.projectHint);
959
+ if (!targetProject) {
960
+ const projectNames = projects.map((p) => p.name).join(', ') || '(none registered)';
961
+ await this._slackClient.postAsAgent(channel, `Which project? Registered: ${projectNames}.`, persona, threadTs);
962
+ this._markChannelActivity(channel);
963
+ this._markPersonaReply(channel, threadTs, persona.id);
964
+ return true;
965
+ }
966
+ console.log(`[slack][provider] routing provider=${request.provider} to persona=${persona.name} project=${targetProject.name}`);
967
+ const providerLabel = request.provider === 'claude' ? 'Claude' : 'Codex';
968
+ const compactPrompt = request.prompt.replace(/\s+/g, ' ').trim();
969
+ const promptPreview = compactPrompt.length > 120
970
+ ? `${compactPrompt.slice(0, 117)}...`
971
+ : compactPrompt;
972
+ await this._applyHumanResponseTiming(channel, messageTs, persona);
973
+ await this._slackClient.postAsAgent(channel, `Running ${providerLabel} directly${request.projectHint ? ` on ${targetProject.name}` : ''}: "${promptPreview}"`, persona, threadTs);
974
+ this._markChannelActivity(channel);
975
+ this._markPersonaReply(channel, threadTs, persona.id);
976
+ this._rememberAdHocThreadPersona(channel, threadTs, persona.id);
977
+ await this._spawnDirectProviderRequest(request, targetProject, channel, threadTs, persona);
978
+ return true;
979
+ }
906
980
  async _triggerSlackJobIfRequested(event, channel, threadTs, messageTs, personas) {
907
981
  const request = parseSlackJobRequest(event.text ?? '');
908
982
  if (!request)
@@ -950,12 +1024,104 @@ export class SlackInteractionListener {
950
1024
  await this._spawnNightWatchJob(request.job, targetProject, channel, threadTs, persona, { prNumber: request.prNumber, fixConflicts: request.fixConflicts });
951
1025
  return true;
952
1026
  }
1027
+ async _triggerIssuePickupIfRequested(event, channel, threadTs, messageTs, personas) {
1028
+ const request = parseSlackIssuePickupRequest(event.text ?? '');
1029
+ if (!request)
1030
+ return false;
1031
+ const addressedToBot = this._isMessageAddressedToBot(event);
1032
+ const normalized = normalizeForParsing(stripSlackUserMentions(event.text ?? ''));
1033
+ const teamRequestLanguage = /\b(can someone|someone|anyone|please|need)\b/i.test(normalized);
1034
+ if (!addressedToBot && !teamRequestLanguage)
1035
+ return false;
1036
+ const repos = getRepositories();
1037
+ const projects = repos.projectRegistry.getAll();
1038
+ const persona = this._findPersonaByName(personas, 'Dev')
1039
+ ?? this._pickRandomPersona(personas, channel, threadTs)
1040
+ ?? personas[0];
1041
+ if (!persona)
1042
+ return false;
1043
+ const targetProject = this._resolveTargetProject(channel, projects, request.repoHint);
1044
+ if (!targetProject) {
1045
+ const projectNames = projects.map((p) => p.name).join(', ') || '(none registered)';
1046
+ await this._slackClient.postAsAgent(channel, `Which project? Registered: ${projectNames}.`, persona, threadTs);
1047
+ this._markChannelActivity(channel);
1048
+ this._markPersonaReply(channel, threadTs, persona.id);
1049
+ return true;
1050
+ }
1051
+ console.log(`[slack][issue-pickup] routing issue=#${request.issueNumber} to persona=${persona.name} project=${targetProject.name}`);
1052
+ await this._applyHumanResponseTiming(channel, messageTs, persona);
1053
+ await this._slackClient.postAsAgent(channel, `On it — picking up #${request.issueNumber}. Starting the run now.`, persona, threadTs);
1054
+ this._markChannelActivity(channel);
1055
+ this._markPersonaReply(channel, threadTs, persona.id);
1056
+ this._rememberAdHocThreadPersona(channel, threadTs, persona.id);
1057
+ // Move issue to In Progress on board (best-effort, spawn via CLI subprocess)
1058
+ const boardArgs = buildCurrentCliInvocation([
1059
+ 'board', 'move-issue', request.issueNumber, '--column', 'In Progress',
1060
+ ]);
1061
+ if (boardArgs) {
1062
+ try {
1063
+ execFileSync(process.execPath, boardArgs, {
1064
+ cwd: targetProject.path,
1065
+ timeout: 15_000,
1066
+ stdio: ['ignore', 'pipe', 'pipe'],
1067
+ });
1068
+ console.log(`[slack][issue-pickup] moved #${request.issueNumber} to In Progress`);
1069
+ }
1070
+ catch {
1071
+ console.warn(`[slack][issue-pickup] failed to move #${request.issueNumber} to In Progress`);
1072
+ }
1073
+ }
1074
+ console.log(`[slack][issue-pickup] spawning run for #${request.issueNumber}`);
1075
+ await this._spawnNightWatchJob('run', targetProject, channel, threadTs, persona, {
1076
+ issueNumber: request.issueNumber,
1077
+ });
1078
+ return true;
1079
+ }
953
1080
  _resolveProactiveChannelForProject(project) {
954
1081
  const slack = this._config.slack;
955
1082
  if (!slack)
956
1083
  return null;
957
1084
  return project.slackChannelId || slack.channels.eng || null;
958
1085
  }
1086
+ _spawnCodeWatchAudit(project, channel) {
1087
+ const invocationArgs = buildCurrentCliInvocation(['audit']);
1088
+ if (!invocationArgs) {
1089
+ console.warn(`[slack][codewatch] audit spawn failed for ${project.name}: CLI entry path unavailable`);
1090
+ return;
1091
+ }
1092
+ console.log(`[slack][codewatch] spawning audit for ${project.name} → ${channel} cmd=${formatCommandForLog(process.execPath, invocationArgs)}`);
1093
+ const child = spawn(process.execPath, invocationArgs, {
1094
+ cwd: project.path,
1095
+ env: { ...process.env, NW_EXECUTION_CONTEXT: 'agent' },
1096
+ stdio: ['ignore', 'pipe', 'pipe'],
1097
+ });
1098
+ console.log(`[slack][codewatch] audit spawned for ${project.name} pid=${child.pid ?? 'unknown'}`);
1099
+ child.stdout?.on('data', () => { });
1100
+ child.stderr?.on('data', () => { });
1101
+ child.on('error', (err) => {
1102
+ console.warn(`[slack][codewatch] audit spawn error for ${project.name}: ${err.message}`);
1103
+ });
1104
+ child.on('close', async (code) => {
1105
+ console.log(`[slack][codewatch] audit finished for ${project.name} exit=${code ?? 'unknown'}`);
1106
+ const reportPath = path.join(project.path, 'logs', 'audit-report.md');
1107
+ let report;
1108
+ try {
1109
+ report = fs.readFileSync(reportPath, 'utf-8').trim();
1110
+ }
1111
+ catch {
1112
+ console.log(`[slack][codewatch] no audit report found at ${reportPath}`);
1113
+ return;
1114
+ }
1115
+ try {
1116
+ await this._engine.handleAuditReport(report, project.name, project.path, channel);
1117
+ this._markChannelActivity(channel);
1118
+ }
1119
+ catch (err) {
1120
+ const msg = err instanceof Error ? err.message : String(err);
1121
+ console.warn(`[slack][codewatch] handleAuditReport failed for ${project.name}: ${msg}`);
1122
+ }
1123
+ });
1124
+ }
959
1125
  async _runProactiveCodeWatch(projects, now) {
960
1126
  for (const project of projects) {
961
1127
  const channel = this._resolveProactiveChannelForProject(project);
@@ -966,38 +1132,7 @@ export class SlackInteractionListener {
966
1132
  continue;
967
1133
  }
968
1134
  this._lastCodeWatchAt.set(project.path, now);
969
- const candidate = detectCodeWatchCandidate(project.path);
970
- if (!candidate) {
971
- continue;
972
- }
973
- const signatureKey = `${project.path}:${candidate.signature}`;
974
- const lastSeen = this._lastCodeWatchSignatureAt.get(signatureKey) ?? 0;
975
- if (now - lastSeen < PROACTIVE_CODEWATCH_REPEAT_COOLDOWN_MS) {
976
- continue;
977
- }
978
- const ref = `codewatch-${candidate.signature}`;
979
- const context = `Project: ${project.name}\n` +
980
- `Signal: ${candidate.summary}\n` +
981
- `Location: ${candidate.relativePath}:${candidate.line}\n` +
982
- `Snippet: ${candidate.snippet}\n` +
983
- `Question: Is this intentional, or should we patch it now?`;
984
- console.log(`[slack][codewatch] project=${project.name} location=${candidate.relativePath}:${candidate.line} signal=${candidate.type}`);
985
- try {
986
- await this._engine.startDiscussion({
987
- type: 'code_watch',
988
- projectPath: project.path,
989
- ref,
990
- context,
991
- channelId: channel,
992
- });
993
- this._lastCodeWatchSignatureAt.set(signatureKey, now);
994
- this._markChannelActivity(channel);
995
- return;
996
- }
997
- catch (err) {
998
- const msg = err instanceof Error ? err.message : String(err);
999
- console.warn(`[slack][codewatch] failed for ${project.name}: ${msg}`);
1000
- }
1135
+ this._spawnCodeWatchAudit(project, channel);
1001
1136
  }
1002
1137
  }
1003
1138
  _startProactiveLoop() {
@@ -1072,9 +1207,20 @@ export class SlackInteractionListener {
1072
1207
  const personas = repos.agentPersona.getActive();
1073
1208
  const projects = repos.projectRegistry.getAll();
1074
1209
  const projectContext = this._buildProjectContext(channel, projects);
1210
+ // Fetch GitHub issue/PR content from URLs in the message so agents can inspect them.
1211
+ const githubUrls = extractGitHubIssueUrls(text);
1212
+ console.log(`[slack] processing message channel=${channel} thread=${threadTs} urls=${githubUrls.length}`);
1213
+ const githubContext = githubUrls.length > 0 ? await fetchGitHubIssueContext(githubUrls) : '';
1214
+ const fullContext = githubContext ? `${projectContext}\n\nReferenced GitHub content:\n${githubContext}` : projectContext;
1215
+ if (await this._triggerDirectProviderIfRequested(event, channel, threadTs, ts, personas)) {
1216
+ return;
1217
+ }
1075
1218
  if (await this._triggerSlackJobIfRequested(event, channel, threadTs, ts, personas)) {
1076
1219
  return;
1077
1220
  }
1221
+ if (await this._triggerIssuePickupIfRequested(event, channel, threadTs, ts, personas)) {
1222
+ return;
1223
+ }
1078
1224
  // @mention matching: "@maya ..."
1079
1225
  let mentionedPersonas = resolveMentionedPersonas(text, personas);
1080
1226
  // Also try plain-name matching (e.g. "Carlos, are you there?").
@@ -1092,6 +1238,8 @@ export class SlackInteractionListener {
1092
1238
  .slackDiscussion
1093
1239
  .getActive('')
1094
1240
  .find((d) => d.channelId === channel && d.threadTs === threadTs);
1241
+ let lastPosted = '';
1242
+ let lastPersonaId = '';
1095
1243
  for (const persona of mentionedPersonas) {
1096
1244
  if (this._isPersonaOnCooldown(channel, threadTs, persona.id)) {
1097
1245
  console.log(`[slack] ${persona.name} is on cooldown — skipping`);
@@ -1102,13 +1250,19 @@ export class SlackInteractionListener {
1102
1250
  await this._engine.contributeAsAgent(discussion.id, persona);
1103
1251
  }
1104
1252
  else {
1105
- await this._engine.replyAsAgent(channel, threadTs, text, persona, projectContext);
1253
+ console.log(`[slack] replying as ${persona.name} in ${channel}`);
1254
+ lastPosted = await this._engine.replyAsAgent(channel, threadTs, text, persona, fullContext);
1255
+ lastPersonaId = persona.id;
1106
1256
  }
1107
1257
  this._markPersonaReply(channel, threadTs, persona.id);
1108
1258
  }
1109
1259
  if (!discussion && mentionedPersonas[0]) {
1110
1260
  this._rememberAdHocThreadPersona(channel, threadTs, mentionedPersonas[0].id);
1111
1261
  }
1262
+ // Follow up if the last agent reply mentions other teammates by name.
1263
+ if (lastPosted && lastPersonaId) {
1264
+ await this._followAgentMentions(lastPosted, channel, threadTs, personas, fullContext, lastPersonaId);
1265
+ }
1112
1266
  return;
1113
1267
  }
1114
1268
  console.log(`[slack] no persona match — checking for active discussion in ${channel}:${threadTs}`);
@@ -1132,9 +1286,11 @@ export class SlackInteractionListener {
1132
1286
  console.log(`[slack] continuing ad-hoc thread with ${rememberedPersona.name}`);
1133
1287
  }
1134
1288
  await this._applyHumanResponseTiming(channel, ts, followUpPersona);
1135
- await this._engine.replyAsAgent(channel, threadTs, text, followUpPersona, projectContext);
1289
+ console.log(`[slack] replying as ${followUpPersona.name} in ${channel}`);
1290
+ const postedText = await this._engine.replyAsAgent(channel, threadTs, text, followUpPersona, fullContext);
1136
1291
  this._markPersonaReply(channel, threadTs, followUpPersona.id);
1137
1292
  this._rememberAdHocThreadPersona(channel, threadTs, followUpPersona.id);
1293
+ await this._followAgentMentions(postedText, channel, threadTs, personas, fullContext, followUpPersona.id);
1138
1294
  return;
1139
1295
  }
1140
1296
  // In-memory state was lost (e.g. server restart) — recover persona from thread history.
@@ -1144,9 +1300,11 @@ export class SlackInteractionListener {
1144
1300
  const followUpPersona = selectFollowUpPersona(recoveredPersona, personas, text);
1145
1301
  console.log(`[slack] recovered ad-hoc thread persona ${recoveredPersona.name} from history, replying as ${followUpPersona.name}`);
1146
1302
  await this._applyHumanResponseTiming(channel, ts, followUpPersona);
1147
- await this._engine.replyAsAgent(channel, threadTs, text, followUpPersona, projectContext);
1303
+ console.log(`[slack] replying as ${followUpPersona.name} in ${channel}`);
1304
+ const postedText = await this._engine.replyAsAgent(channel, threadTs, text, followUpPersona, fullContext);
1148
1305
  this._markPersonaReply(channel, threadTs, followUpPersona.id);
1149
1306
  this._rememberAdHocThreadPersona(channel, threadTs, followUpPersona.id);
1307
+ await this._followAgentMentions(postedText, channel, threadTs, personas, fullContext, followUpPersona.id);
1150
1308
  return;
1151
1309
  }
1152
1310
  }
@@ -1157,9 +1315,11 @@ export class SlackInteractionListener {
1157
1315
  if (randomPersona) {
1158
1316
  console.log(`[slack] auto-engaging via ${randomPersona.name}`);
1159
1317
  await this._applyHumanResponseTiming(channel, ts, randomPersona);
1160
- await this._engine.replyAsAgent(channel, threadTs, text, randomPersona, projectContext);
1318
+ console.log(`[slack] replying as ${randomPersona.name} in ${channel}`);
1319
+ const postedText = await this._engine.replyAsAgent(channel, threadTs, text, randomPersona, fullContext);
1161
1320
  this._markPersonaReply(channel, threadTs, randomPersona.id);
1162
1321
  this._rememberAdHocThreadPersona(channel, threadTs, randomPersona.id);
1322
+ await this._followAgentMentions(postedText, channel, threadTs, personas, fullContext, randomPersona.id);
1163
1323
  return;
1164
1324
  }
1165
1325
  }