@doist/doistbot-cli 1.0.8 → 1.0.9

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 (26) hide show
  1. package/dist/actions/auth.js +5 -3
  2. package/dist/auth.js +67 -35
  3. package/dist/config.js +3 -1
  4. package/dist/input.js +11 -11
  5. package/package.json +5 -4
  6. package/sandbox/dist/core/pi.js +9 -3
  7. package/sandbox/dist/core/shared.js +9 -9
  8. package/sandbox/dist/core/span-test-helpers.js +13 -0
  9. package/sandbox/dist/main.js +3 -0
  10. package/sandbox/dist/tasks/issue-triage/autofix-capabilities.js +22 -0
  11. package/sandbox/dist/tasks/issue-triage/fix-attempt.js +6 -0
  12. package/sandbox/dist/tasks/issue-triage/routing.js +124 -15
  13. package/sandbox/dist/tasks/issue-triage/triage.js +2 -0
  14. package/sandbox/dist/tasks/review/engines/multi-focus.js +16 -8
  15. package/sandbox/dist/tasks/review/multi-focus-prompt.js +2 -0
  16. package/sandbox/dist/tasks/review/review-summary.js +1 -1
  17. package/sandbox/dist/tasks/review-slice-plan/context.js +69 -0
  18. package/sandbox/dist/tasks/review-slice-plan/plan.js +209 -0
  19. package/sandbox/dist/tasks/review-slice-plan/prompt.js +59 -0
  20. package/sandbox/dist/tasks/review-slice-plan/review-slice-plan.js +470 -0
  21. package/sandbox/src/tasks/review/prompts/review-focus-prompts/efficiency.md +10 -12
  22. package/sandbox/src/tasks/review/prompts/review-focus-prompts/general.md +24 -7
  23. package/sandbox/src/tasks/review/prompts/review-focus-prompts/quality.md +14 -22
  24. package/sandbox/src/tasks/review/prompts/review-focus-prompts/reuse.md +13 -0
  25. package/sandbox/src/tasks/review/prompts/review-focus-prompts/security.md +18 -0
  26. package/sandbox/src/tasks/review/prompts/review-focus-prompts/tests.md +12 -22
@@ -1,5 +1,5 @@
1
1
  import chalk from 'chalk';
2
- import { deleteGeminiApiKey, deleteOpenRouterApiKey, getAuthStatus, loginOpenAICodex, logoutOpenAICodex, saveGeminiApiKey, saveOpenRouterApiKey, } from '../auth.js';
2
+ import { createPiAuthRuntime, deleteGeminiApiKey, deleteOpenRouterApiKey, getAuthStatus, loginOpenAICodex, logoutOpenAICodex, saveGeminiApiKey, saveOpenRouterApiKey, } from '../auth.js';
3
3
  import { getEnvApiKeys } from '../env.js';
4
4
  import { FILE_STORE_PATH } from '../file-store.js';
5
5
  import { promptHiddenInput, promptMultiSelect } from '../input.js';
@@ -58,6 +58,7 @@ function formatSkipInstruction() {
58
58
  }
59
59
  export async function runAuthSetup(dependencies = {}) {
60
60
  const deps = {
61
+ createPiAuthRuntime,
61
62
  getAuthStatus,
62
63
  isInteractive: process.stdin.isTTY === true,
63
64
  loginOpenAICodex,
@@ -67,7 +68,8 @@ export async function runAuthSetup(dependencies = {}) {
67
68
  writeLine,
68
69
  ...dependencies,
69
70
  };
70
- const status = await deps.getAuthStatus();
71
+ const runtime = await deps.createPiAuthRuntime();
72
+ const status = await deps.getAuthStatus({ runtime });
71
73
  deps.writeLine('1. OpenAI Codex');
72
74
  deps.writeLine();
73
75
  if (status.openai.configured && !status.openai.refreshFailed) {
@@ -80,7 +82,7 @@ export async function runAuthSetup(dependencies = {}) {
80
82
  if (status.openai.refreshFailed) {
81
83
  deps.writeLine('OpenAI session could not be refreshed (expired, revoked, or offline). Reconnecting.');
82
84
  }
83
- await deps.loginOpenAICodex();
85
+ await deps.loginOpenAICodex(runtime);
84
86
  deps.writeLine();
85
87
  deps.writeLine('✓ OpenAI configured.');
86
88
  }
package/dist/auth.js CHANGED
@@ -1,10 +1,12 @@
1
1
  import { spawn } from 'child_process';
2
2
  import { homedir } from 'os';
3
3
  import path from 'path';
4
+ import {} from '@earendil-works/pi-ai';
4
5
  import { getEnvApiKeys } from './env.js';
5
6
  import { createFileStore } from './file-store.js';
6
7
  import { promptInput } from './input.js';
7
8
  import { createCompositeStore, createKeyringStore, SecureStoreUnavailableError, } from './secure-store.js';
9
+ import { writeLine } from './terminal.js';
8
10
  const GEMINI_ACCOUNT_NAME = 'gemini-api-key';
9
11
  const OPENROUTER_ACCOUNT_NAME = 'openrouter-api-key';
10
12
  export function createGeminiKeyStore() {
@@ -15,9 +17,20 @@ export function createOpenRouterKeyStore() {
15
17
  }
16
18
  export const OPENAI_CODEX_PROVIDER_ID = 'openai-codex';
17
19
  export const PI_AUTH_API_KEY = `pi-auth://${OPENAI_CODEX_PROVIDER_ID}`;
18
- export async function createPiAuthStorage() {
19
- const { AuthStorage } = await import('@earendil-works/pi-coding-agent');
20
- return AuthStorage.create(path.join(homedir(), '.pi', 'agent', 'auth.json'));
20
+ const OPENAI_CODEX_BROWSER_LOGIN_METHOD = 'browser';
21
+ export async function createPiAuthRuntime() {
22
+ // Pi's own CLI installs its npm Undici implementation before initializing
23
+ // ModelRuntime. SDK consumers must do the same on Node 26 so compressed OAuth
24
+ // responses are decoded before response.json() parses them.
25
+ const { install } = await import('undici');
26
+ install();
27
+ const { ModelRuntime } = await import('@earendil-works/pi-coding-agent');
28
+ return await ModelRuntime.create({
29
+ authPath: path.join(homedir(), '.pi', 'agent', 'auth.json'),
30
+ allowModelNetwork: false,
31
+ modelsPath: null,
32
+ refreshOnCreate: false,
33
+ });
21
34
  }
22
35
  export function getBrowserOpenCommand(platform, url) {
23
36
  if (platform === 'darwin') {
@@ -30,33 +43,38 @@ export function getBrowserOpenCommand(platform, url) {
30
43
  }
31
44
  /**
32
45
  * Check that the stored OAuth session is usable, not just present.
33
- * getApiKey returns the cached access token when unexpired; when expired it
34
- * attempts one cross-process-locked refresh, persists the result, and returns
35
- * undefined when the refresh fails (revoked session, no network) while
36
- * preserving the stored credentials. Unexpected storage errors propagate.
46
+ * The Pi model runtime returns the cached access token when unexpired; when
47
+ * expired it attempts one cross-process-locked refresh and persists the result.
48
+ * Refresh failures preserve the stored credentials. Unexpected storage errors
49
+ * propagate.
37
50
  */
38
- export async function getOpenAICodexAuthState(storage) {
39
- const resolvedStorage = storage ?? (await createPiAuthStorage());
40
- if (resolvedStorage.get(OPENAI_CODEX_PROVIDER_ID)?.type !== 'oauth') {
51
+ export async function getOpenAICodexAuthState(runtime) {
52
+ const resolvedRuntime = runtime ?? (await createPiAuthRuntime());
53
+ const credentials = await resolvedRuntime.listCredentials();
54
+ if (!credentials.some(({ providerId, type }) => providerId === OPENAI_CODEX_PROVIDER_ID && type === 'oauth')) {
41
55
  return 'missing';
42
56
  }
43
- const apiKey = await resolvedStorage.getApiKey(OPENAI_CODEX_PROVIDER_ID, {
44
- includeFallback: false,
45
- });
46
- return apiKey ? 'ready' : 'refresh-failed';
47
- }
48
- export async function loginOpenAICodex(storage) {
49
- const resolvedStorage = storage ?? (await createPiAuthStorage());
50
- const { loginOpenAICodex: loginInBrowser } = await import('@earendil-works/pi-ai/oauth');
51
- const credentials = await loginInBrowser(createOpenAICodexBrowserLoginOptions());
52
- saveOpenAICodexCredentials(resolvedStorage, credentials);
57
+ try {
58
+ const auth = await resolvedRuntime.getAuth(OPENAI_CODEX_PROVIDER_ID);
59
+ return auth?.auth.apiKey ? 'ready' : 'refresh-failed';
60
+ }
61
+ catch (error) {
62
+ if (typeof error === 'object' &&
63
+ error !== null &&
64
+ 'code' in error &&
65
+ error.code === 'oauth') {
66
+ return 'refresh-failed';
67
+ }
68
+ throw error;
69
+ }
53
70
  }
54
- export function saveOpenAICodexCredentials(storage, credentials) {
55
- storage.set(OPENAI_CODEX_PROVIDER_ID, { type: 'oauth', ...credentials });
71
+ export async function loginOpenAICodex(runtime) {
72
+ const resolvedRuntime = runtime ?? (await createPiAuthRuntime());
73
+ await resolvedRuntime.login(OPENAI_CODEX_PROVIDER_ID, 'oauth', createOpenAICodexBrowserLoginInteraction());
56
74
  }
57
- export async function logoutOpenAICodex(storage) {
58
- const resolvedStorage = storage ?? (await createPiAuthStorage());
59
- resolvedStorage.logout(OPENAI_CODEX_PROVIDER_ID);
75
+ export async function logoutOpenAICodex(runtime) {
76
+ const resolvedRuntime = runtime ?? (await createPiAuthRuntime());
77
+ await resolvedRuntime.logout(OPENAI_CODEX_PROVIDER_ID);
60
78
  }
61
79
  export async function readStoredGeminiApiKey(store = createGeminiKeyStore()) {
62
80
  const result = await store.getSecret();
@@ -110,7 +128,7 @@ export async function deleteOpenRouterApiKey(store = createOpenRouterKeyStore())
110
128
  export async function deleteGeminiApiKey(store = createGeminiKeyStore()) {
111
129
  return await store.deleteSecret();
112
130
  }
113
- export async function getAuthStatus({ env = process.env, storage, geminiStore = createGeminiKeyStore(), openRouterStore = createOpenRouterKeyStore(), } = {}) {
131
+ export async function getAuthStatus({ env = process.env, runtime, geminiStore = createGeminiKeyStore(), openRouterStore = createOpenRouterKeyStore(), } = {}) {
114
132
  const envApiKeys = getEnvApiKeys(env);
115
133
  const geminiStored = envApiKeys.gemini
116
134
  ? { value: undefined }
@@ -118,7 +136,7 @@ export async function getAuthStatus({ env = process.env, storage, geminiStore =
118
136
  const openRouterStored = envApiKeys.openrouter
119
137
  ? { value: undefined }
120
138
  : await readStoredOpenRouterApiKey(openRouterStore).catch(() => ({ value: undefined }));
121
- const openAICodexAuthState = await getOpenAICodexAuthState(storage);
139
+ const openAICodexAuthState = await getOpenAICodexAuthState(runtime);
122
140
  return {
123
141
  openai: openAICodexAuthState === 'missing'
124
142
  ? { configured: false, source: undefined }
@@ -149,17 +167,31 @@ export async function getAuthStatus({ env = process.env, storage, geminiStore =
149
167
  },
150
168
  };
151
169
  }
152
- function createOpenAICodexBrowserLoginOptions() {
170
+ function createOpenAICodexBrowserLoginInteraction() {
153
171
  return {
154
- onAuth: (info) => {
155
- openUrlInBrowser(info.url);
156
- process.stdout.write('Open this URL in your browser:\n');
157
- process.stdout.write(`${info.url}\n`);
158
- if (info.instructions) {
159
- process.stdout.write(`${info.instructions}\n`);
172
+ notify: (event) => {
173
+ if (event.type === 'auth_url') {
174
+ openUrlInBrowser(event.url);
175
+ writeLine('Open this URL in your browser:');
176
+ writeLine(event.url);
177
+ if (event.instructions) {
178
+ writeLine(event.instructions);
179
+ }
180
+ }
181
+ else if (event.type === 'info' || event.type === 'progress') {
182
+ writeLine(event.message);
183
+ }
184
+ },
185
+ prompt: async (prompt) => {
186
+ if (prompt.type === 'select') {
187
+ const browserOption = prompt.options.find(({ id }) => id === OPENAI_CODEX_BROWSER_LOGIN_METHOD);
188
+ if (!browserOption) {
189
+ throw new Error('OpenAI Codex did not provide a browser login option');
190
+ }
191
+ return browserOption.id;
160
192
  }
193
+ return await promptInput(`${prompt.message} `, prompt.signal);
161
194
  },
162
- onPrompt: async (prompt) => await promptInput(`${prompt.message} `),
163
195
  };
164
196
  }
165
197
  function openUrlInBrowser(url) {
package/dist/config.js CHANGED
@@ -3,13 +3,15 @@ import { getEnvApiKeys } from './env.js';
3
3
  const REQUIRED_CLI_AUTH_PROVIDERS = [{ provider: 'openai', label: 'OpenAI' }];
4
4
  const ALL_REVIEW_FOCUSES = [
5
5
  'general',
6
+ 'security',
7
+ 'reuse',
6
8
  'quality',
7
9
  'efficiency',
8
10
  'tests',
9
11
  'standards',
10
12
  ];
11
13
  const OPENROUTER_REVIEW_MODELS = ['deepseek', 'zai'];
12
- const CLI_GEMINI_REVIEW_MODEL = 'gemini-3.5-flash';
14
+ const CLI_GEMINI_REVIEW_MODEL = 'gemini-3.7-flash';
13
15
  function createCliGeminiCredential(apiKey) {
14
16
  return {
15
17
  apiKey,
package/dist/input.js CHANGED
@@ -1,15 +1,15 @@
1
- import { createInterface } from 'node:readline';
2
- export function promptInput(prompt) {
3
- return new Promise((resolve) => {
4
- const rl = createInterface({
5
- input: process.stdin,
6
- output: process.stdout,
7
- });
8
- rl.question(prompt, (answer) => {
9
- rl.close();
10
- resolve(answer);
11
- });
1
+ import { createInterface } from 'node:readline/promises';
2
+ export async function promptInput(prompt, signal) {
3
+ const rl = createInterface({
4
+ input: process.stdin,
5
+ output: process.stdout,
12
6
  });
7
+ try {
8
+ return await rl.question(prompt, { signal });
9
+ }
10
+ finally {
11
+ rl.close();
12
+ }
13
13
  }
14
14
  export function promptHiddenInput(prompt) {
15
15
  if (!process.stdin.isTTY) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doist/doistbot-cli",
3
- "version": "1.0.8",
3
+ "version": "1.0.9",
4
4
  "description": "Local self-review CLI for Doistbot",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -42,14 +42,15 @@
42
42
  "dependencies": {
43
43
  "@inquirer/checkbox": "5.2.1",
44
44
  "@inquirer/password": "5.1.1",
45
- "@earendil-works/pi-ai": "0.80.6",
46
- "@earendil-works/pi-coding-agent": "0.80.6",
45
+ "@earendil-works/pi-ai": "0.84.2",
46
+ "@earendil-works/pi-coding-agent": "0.84.2",
47
47
  "@napi-rs/keyring": "1.3.0",
48
48
  "ajv": "8.20.0",
49
49
  "chalk": "4.1.2",
50
50
  "commander": "14.0.3",
51
- "js-yaml": "4.2.0",
51
+ "js-yaml": "4.3.1",
52
52
  "octokit": "5.0.5",
53
+ "undici": "8.9.0",
53
54
  "yocto-spinner": "1.2.0"
54
55
  },
55
56
  "devDependencies": {
@@ -16,12 +16,12 @@ export const DEFAULT_PI_MODELS = {
16
16
  openai: 'gpt-5.6-terra',
17
17
  };
18
18
  export const DEFAULT_OPENROUTER_MODELS = {
19
- deepseek: 'deepseek/deepseek-v4-pro',
19
+ deepseek: 'deepseek/deepseek-v4-pro-0813',
20
20
  gemini: 'google/gemini-3.1-pro-preview',
21
21
  zai: 'z-ai/glm-5.2',
22
22
  };
23
23
  // Gemini flash fallback model.
24
- export const DEFAULT_GEMINI_FLASH_MODEL = 'gemini-3.5-flash';
24
+ export const DEFAULT_GEMINI_FLASH_MODEL = 'gemini-3.7-flash';
25
25
  export const MAX_PI_THINKING_BY_PROVIDER = {
26
26
  deepseek: 'high',
27
27
  gemini: 'high',
@@ -334,8 +334,14 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
334
334
  ...openRouterSessionExtension.args,
335
335
  ...imageArgs,
336
336
  ];
337
- const childEnv = {
337
+ const inheritedEnv = {
338
338
  ...process.env,
339
+ };
340
+ // Repository-scoped credentials must be passed explicitly through
341
+ // extraEnv by an authorized task invocation.
342
+ delete inheritedEnv.GH_PACKAGES_TOKEN;
343
+ const childEnv = {
344
+ ...inheritedEnv,
339
345
  HOME: process.env.HOME,
340
346
  PATH: process.env.PATH,
341
347
  GIT_TERMINAL_PROMPT: '0',
@@ -40,16 +40,16 @@ export function optionalPositiveInt(value) {
40
40
  /**
41
41
  * Parses the base environment variables common to all sandbox tasks.
42
42
  */
43
- export function parseBaseContext() {
43
+ export function parseBaseContext(env = process.env) {
44
44
  return {
45
- owner: requiredEnv('REPO_OWNER'),
46
- repo: requiredEnv('REPO_NAME'),
47
- prNumber: requiredPositiveInt('PR_NUMBER'),
48
- installationId: requiredPositiveInt('INSTALLATION_ID'),
49
- appId: requiredEnv('APP_ID'),
50
- privateKey: requiredEnv('PRIVATE_KEY'),
51
- githubToken: process.env.GITHUB_TOKEN?.trim() || undefined,
52
- deliveryId: process.env.DELIVERY_ID,
45
+ owner: requiredEnv('REPO_OWNER', env),
46
+ repo: requiredEnv('REPO_NAME', env),
47
+ prNumber: requiredPositiveInt('PR_NUMBER', env),
48
+ installationId: requiredPositiveInt('INSTALLATION_ID', env),
49
+ appId: requiredEnv('APP_ID', env),
50
+ privateKey: requiredEnv('PRIVATE_KEY', env),
51
+ githubToken: env.GITHUB_TOKEN?.trim() || undefined,
52
+ deliveryId: env.DELIVERY_ID?.trim() || undefined,
53
53
  };
54
54
  }
55
55
  export function resolveWriteOctokit({ githubToken, fallbackOctokit, }) {
@@ -0,0 +1,13 @@
1
+ import { vi } from 'vitest';
2
+ /**
3
+ * Shared assertion helper for the model/phase span tests, which all spy on
4
+ * `logger.info` and assert on the same `phase.*` envelope shape emitted by the
5
+ * LoggerSpanProcessor.
6
+ *
7
+ * Returns the `phase.end` envelopes for the given span name, in call order.
8
+ */
9
+ export function phaseEnds(infoSpy, name) {
10
+ return infoSpy.mock.calls
11
+ .map((call) => call[1])
12
+ .filter((meta) => meta?.event === 'phase.end' && meta?.name === name);
13
+ }
@@ -32,6 +32,9 @@ switch (taskType) {
32
32
  case 'review':
33
33
  await import('./tasks/review/review.js');
34
34
  break;
35
+ case 'review-slice-plan':
36
+ await import('./tasks/review-slice-plan/review-slice-plan.js');
37
+ break;
35
38
  default:
36
39
  process.stderr.write(`Unknown TASK_TYPE: ${taskType}\n`);
37
40
  process.exit(1);
@@ -0,0 +1,22 @@
1
+ const REPOSITORY_CAPABILITIES = {
2
+ 'doist/todoist-web': ['github-packages-read'],
3
+ };
4
+ export function resolveAutofixCapabilities(repository) {
5
+ return REPOSITORY_CAPABILITIES[repository.toLowerCase()] ?? [];
6
+ }
7
+ export function resolveAutofixCapabilityEnv(repository, env = process.env) {
8
+ const capabilityEnv = {};
9
+ for (const capability of resolveAutofixCapabilities(repository)) {
10
+ if (capability === 'github-packages-read') {
11
+ const token = env.GH_PACKAGES_TOKEN?.trim();
12
+ if (!token) {
13
+ return {
14
+ ok: false,
15
+ reason: `Missing GH_PACKAGES_TOKEN for required autofix capability "${capability}" on ${repository}.`,
16
+ };
17
+ }
18
+ capabilityEnv.GH_PACKAGES_TOKEN = token;
19
+ }
20
+ }
21
+ return { ok: true, env: capabilityEnv };
22
+ }
@@ -1,6 +1,7 @@
1
1
  import { execFileSync } from 'child_process';
2
2
  import { logger } from '../../core/logging.js';
3
3
  import { fillPromptTemplate, loadPromptTemplateFile, resolvePromptFile, } from '../../core/prompt-template.js';
4
+ import { resolveAutofixCapabilityEnv } from './autofix-capabilities.js';
4
5
  import { validateDiff } from './diff-validator.js';
5
6
  import { CODEX_CANNOT_FIX_REASON, FIX_LOOP_FAILED_REASON_PREFIX } from './fix-failure-reasons.js';
6
7
  import { CX_HERO_MANUAL_HANDLE, resolveHeroGroup } from './hero-group-map.js';
@@ -325,6 +326,10 @@ export async function runFixCore(input) {
325
326
  reason: buildFixAssessmentSkipReason(fixAssessment),
326
327
  };
327
328
  }
329
+ const capabilityEnvResult = resolveAutofixCapabilityEnv(targetRepo.repository.fullName);
330
+ if (!capabilityEnvResult.ok) {
331
+ return capabilityEnvResult;
332
+ }
328
333
  // Reset repo to clean state before fix attempt (triage Codex may have modified files).
329
334
  // Use `git reset --hard` to clear both the working tree and the index, since
330
335
  // captureGitDiff stages files with `git add -A`.
@@ -387,6 +392,7 @@ export async function runFixCore(input) {
387
392
  timeoutMs,
388
393
  extraEnv: {
389
394
  GH_TOKEN: input.ghToken,
395
+ ...capabilityEnvResult.env,
390
396
  },
391
397
  });
392
398
  if (!codexResult.ok) {
@@ -15,21 +15,51 @@ function hasProductLabel(labels) {
15
15
  function stripMarkdownHeading(line) {
16
16
  return line.replace(/^#{1,6}\s+/, '').trim();
17
17
  }
18
- export function summarizeTriageReasoning(triageOutput, maxChars = 220) {
19
- const summaryHeading = triageOutput.match(/^## Summary\s*$/im);
20
- const summaryBody = summaryHeading?.index === undefined
21
- ? triageOutput
22
- : triageOutput.slice(summaryHeading.index + summaryHeading[0].length);
23
- const nextHeading = summaryBody.search(/^##\s/m);
24
- const candidate = nextHeading >= 0 ? summaryBody.slice(0, nextHeading) : summaryBody;
18
+ function stripListMarker(line) {
19
+ return line.replace(/^\s*(?:\d+[.)]|[-*])\s+/, '').trim();
20
+ }
21
+ // `undefined` means the heading is absent; `''` means it is present but empty.
22
+ // Callers depend on the difference — an empty `## Summary` has its own fallback.
23
+ function sectionBody(triageOutput, heading) {
24
+ const match = triageOutput.match(new RegExp(`^## ${heading}\\s*$`, 'im'));
25
+ if (match?.index === undefined) {
26
+ return undefined;
27
+ }
28
+ const rest = triageOutput.slice(match.index + match[0].length);
29
+ const nextHeading = rest.search(/^##\s/m);
30
+ return (nextHeading >= 0 ? rest.slice(0, nextHeading) : rest).trim();
31
+ }
32
+ // The single sentence-boundary heuristic. Returns whole sentences or nothing —
33
+ // a half-sentence of context is what made the old routing comment worthless, so
34
+ // detail that doesn't fit is dropped, not clipped. Any complete sentence is worth
35
+ // keeping, however short: the boundary check already rules out fragments.
36
+ function sentencesWithin(text, maxChars) {
37
+ if (text.length <= maxChars) {
38
+ return text;
39
+ }
40
+ const clipped = text.slice(0, maxChars);
41
+ const sentenceEnd = Math.max(clipped.lastIndexOf('. '), clipped.lastIndexOf('! '), clipped.lastIndexOf('? '));
42
+ return sentenceEnd > 0 ? clipped.slice(0, sentenceEnd + 1) : undefined;
43
+ }
44
+ // As above, but for text we would rather shorten than lose. Falls back to a word
45
+ // boundary + ellipsis when the budget holds no complete sentence at all.
46
+ function trimToSentence(text, maxChars) {
47
+ const whole = sentencesWithin(text, maxChars);
48
+ if (whole !== undefined) {
49
+ return whole;
50
+ }
51
+ const capped = text.slice(0, maxChars - 3);
52
+ const wordEnd = capped.lastIndexOf(' ');
53
+ return `${(wordEnd > 0 ? capped.slice(0, wordEnd) : capped).trimEnd()}...`;
54
+ }
55
+ export function summarizeTriageReasoning(triageOutput, maxChars = 320) {
56
+ const candidate = sectionBody(triageOutput, 'Summary') ?? triageOutput;
25
57
  const firstParagraph = candidate
26
58
  .trim()
27
59
  .split(/\n\s*\n/)
28
60
  .map((paragraph) => paragraph
29
61
  .split('\n')
30
- .map((line) => stripMarkdownHeading(line)
31
- .replace(/^[-*]\s+/, '')
32
- .trim())
62
+ .map((line) => stripListMarker(stripMarkdownHeading(line)))
33
63
  .filter(Boolean)
34
64
  .join(' '))
35
65
  .find(Boolean) ?? '';
@@ -37,12 +67,89 @@ export function summarizeTriageReasoning(triageOutput, maxChars = 220) {
37
67
  if (!normalized) {
38
68
  return 'See the triage summary above for details.';
39
69
  }
40
- if (normalized.length <= maxChars) {
41
- return normalized;
70
+ return trimToSentence(normalized, maxChars);
71
+ }
72
+ const NEXT_STEP_MAX_CHARS = 300;
73
+ // Anchored on purpose: the verdict is the leading word of the line ("Medium — ...").
74
+ // A loose substring search matches "low" inside "low-volume", "workflow" and "flow",
75
+ // which turned ~25% of agreeing issues into false mismatch alerts.
76
+ const SEVERITY_VERDICT = /^\W*(?:priority[:\s]+)?(critical|high|medium|low)\b/i;
77
+ function severityRank(value) {
78
+ return value.match(SEVERITY_VERDICT)?.[1]?.toLowerCase();
79
+ }
80
+ function labelSeverity(labels) {
81
+ const label = labels.find((l) => l.trim().toLowerCase().startsWith('severity:'));
82
+ return label ? severityRank(label.split(':')[1] ?? '') : undefined;
83
+ }
84
+ function firstLineOf(triageOutput, heading) {
85
+ return sectionBody(triageOutput, heading)
86
+ ?.split('\n')
87
+ .map((line) => stripListMarker(line))
88
+ .find(Boolean);
89
+ }
90
+ // Triage agrees with the `Severity:*` label ~93% of the time, so restating the
91
+ // assessed priority is noise. The 7% where it disagrees is the part worth reading
92
+ // — and it skews toward triage rating the issue *worse* than the label.
93
+ function buildSeverityMismatchLine(triageOutput, labels, maxChars) {
94
+ const priority = firstLineOf(triageOutput, 'Issue Priority Assessment');
95
+ const assessed = priority ? severityRank(priority) : undefined;
96
+ const labelled = labelSeverity(labels);
97
+ if (!priority || !assessed || !labelled || assessed === labelled) {
98
+ return undefined;
99
+ }
100
+ const alert = `Labelled \`Severity:${labelled}\`, triage assessed ${assessed}`;
101
+ const rationale = sentencesWithin(priority.replace(/^\W*\w+\s*[—–-]\s*/, '').trim(), maxChars);
102
+ return rationale ? `- ⚠️ **${alert}** — ${rationale}` : `- ⚠️ **${alert}.**`;
103
+ }
104
+ // The ping only fires when the fix pipeline declined the issue, but the team can't
105
+ // see that from the outside. Naming the state plus why no PR exists saves them from
106
+ // re-deriving it, and turns `insufficient-context` into an explicit ask.
107
+ function buildStatusLine(assessment, maxChars) {
108
+ if (!assessment) {
109
+ return undefined;
42
110
  }
43
- return `${normalized.slice(0, maxChars - 3).trimEnd()}...`;
111
+ const state = assessment.classification === 'insufficient-context'
112
+ ? 'Blocked, no PR opened'
113
+ : assessment.classification === 'needs-human'
114
+ ? 'Needs a human, no PR opened'
115
+ : `No PR opened (${assessment.confidence} confidence)`;
116
+ const scope = sentencesWithin(assessment.scope.replace(/\s+/g, ' ').trim(), maxChars);
117
+ return scope ? `- **${state}** — ${scope}` : `- **${state}.**`;
44
118
  }
45
- export function buildHeroRoutingComment({ labels, primaryRepo, triageOutput, }) {
119
+ // ~1 in 6 triages concludes the code lives outside the repos the labels resolved to.
120
+ // Without this the pinged team opens the issue, decides it isn't theirs, and bounces it.
121
+ function buildRepoMismatchLine(assessment, resolvedRepos) {
122
+ const targetRepo = assessment?.targetRepo?.trim();
123
+ if (!targetRepo || resolvedRepos.length === 0) {
124
+ return undefined;
125
+ }
126
+ const matches = resolvedRepos.some((repo) => repo.toLowerCase() === targetRepo.toLowerCase());
127
+ if (matches) {
128
+ return undefined;
129
+ }
130
+ return `- ⚠️ **Likely in \`${targetRepo}\`**, not ${resolvedRepos.map((repo) => `\`${repo}\``).join(' / ')} — may belong to another team.`;
131
+ }
132
+ /**
133
+ * Compact recap for the hero routing ping. The full triage comment sits directly
134
+ * above it with `## Summary` visible, so this deliberately carries only what that
135
+ * summary doesn't: the exceptions worth alerting on, and the next concrete action.
136
+ */
137
+ export function buildTriageRecap({ triageOutput, assessment, labels = [], resolvedRepos = [], maxChars = 220, }) {
138
+ const nextStep = firstLineOf(triageOutput, 'Suggested Triage Action');
139
+ const lines = [
140
+ buildSeverityMismatchLine(triageOutput, labels, maxChars),
141
+ buildRepoMismatchLine(assessment, resolvedRepos),
142
+ buildStatusLine(assessment, maxChars),
143
+ // The action is the payload, so it gets a wider budget than supporting
144
+ // detail and keeps ellipsis as a last resort rather than being dropped.
145
+ nextStep && `- **Next step:** ${trimToSentence(nextStep, NEXT_STEP_MAX_CHARS)}`,
146
+ ].filter(Boolean);
147
+ if (lines.length === 0) {
148
+ return summarizeTriageReasoning(triageOutput);
149
+ }
150
+ return lines.join('\n');
151
+ }
152
+ export function buildHeroRoutingComment({ labels, primaryRepo, triageOutput, assessment, resolvedRepos, }) {
46
153
  if (!hasProductLabel(labels)) {
47
154
  return CX_NO_PRODUCT_LABEL_COMMENT;
48
155
  }
@@ -52,7 +159,9 @@ export function buildHeroRoutingComment({ labels, primaryRepo, triageOutput, })
52
159
  }
53
160
  return `${HERO_ROUTING_MARKER}
54
161
 
55
- Routing to ${heroGroup.mention} for triage. ${summarizeTriageReasoning(triageOutput)}`;
162
+ Routing to ${heroGroup.mention} for triage.
163
+
164
+ ${buildTriageRecap({ triageOutput, assessment, labels, resolvedRepos })}`;
56
165
  }
57
166
  export function shouldPostHeroRoutingComment(fixAssessment) {
58
167
  if (!fixAssessment) {
@@ -289,6 +289,8 @@ async function runIssueTriagePipeline(ctx, mode) {
289
289
  ? resolution.repositories[0]
290
290
  : undefined,
291
291
  triageOutput: validated.output,
292
+ assessment: fixAssessment,
293
+ resolvedRepos: resolution.repositories.map((repo) => repo.fullName),
292
294
  });
293
295
  await postTriageRouting({
294
296
  octokit: writeOctokit,
@@ -11,6 +11,8 @@ import { dedupeMultiFocusFindings } from './dedupe.js';
11
11
  import { buildGeminiFlashCredential, getApiKeys, getAvailableModels, getEnabledReviewModels, getOpenRouterReviewCredential, getProviderFailures, requestModelReview, } from './shared.js';
12
12
  const DEFAULT_REVIEW_MODELS_BY_FOCUS = {
13
13
  general: ['deepseek', 'zai', 'openai'],
14
+ security: ['deepseek', 'zai', 'openai'],
15
+ reuse: ['deepseek', 'zai', 'openai'],
14
16
  quality: ['deepseek', 'zai', 'openai'],
15
17
  efficiency: ['deepseek', 'zai', 'openai'],
16
18
  tests: ['deepseek', 'zai'],
@@ -20,12 +22,18 @@ const DEFAULT_REVIEW_MODELS_BY_FOCUS = {
20
22
  // a caller opts into a wider fallback set.
21
23
  const DEFAULT_GEMINI_FLASH_FALLBACK_FOCUSES = [
22
24
  'general',
25
+ 'security',
26
+ 'reuse',
23
27
  'quality',
24
28
  'efficiency',
25
29
  ];
26
30
  // Open models whose failed pass triggers that fallback.
27
31
  const GEMINI_FLASH_FALLBACK_MODELS = ['deepseek', 'zai'];
28
- const LOW_PRIORITY_SUMMARY_NOTICE = 'I also included a few optional follow-up notes in the details below.';
32
+ function lowPrioritySummaryNotice(count) {
33
+ return count === 1
34
+ ? 'I also left one optional follow-up note in the details below.'
35
+ : 'I also included a few optional follow-up notes in the details below.';
36
+ }
29
37
  // Workflow phase orchestrating the council fan-out; one model span per pass.
30
38
  const MULTI_FOCUS_PHASE = 'review.multi-focus';
31
39
  const FOCUS_PASS_PHASE = 'review.focus-pass';
@@ -203,18 +211,18 @@ function appendSummaryOnlyFindings(summary, summaryFindings) {
203
211
  return summary;
204
212
  }
205
213
  const title = `Optional follow-up note${notes.length === 1 ? '' : 's'} (${notes.length})`;
206
- return `${summary.trim()}\n\n${LOW_PRIORITY_SUMMARY_NOTICE}\n\n<details>\n<summary>${title}</summary>\n\n${notes.join('\n')}\n\n</details>`;
214
+ return `${summary.trim()}\n\n${lowPrioritySummaryNotice(notes.length)}\n\n<details>\n<summary>${title}</summary>\n\n${notes.join('\n')}\n\n</details>`;
207
215
  }
208
- function buildSummarySynthesisFindings({ inlineFindings, summaryFindings, }) {
209
- return Array.from(new Set([
210
- ...inlineFindings.map((finding) => finding.body.trim()),
211
- ...summaryFindings.map((finding) => formatSummaryOnlyFinding(finding)),
212
- ].filter(Boolean)));
216
+ // Summary-only (P3) findings are deliberately excluded: they already render in
217
+ // the "Optional follow-up note" details block, and feeding them here made the
218
+ // summary restate a single P3 nit under "Few things worth tightening:".
219
+ function buildSummarySynthesisFindings(inlineFindings) {
220
+ return Array.from(new Set(inlineFindings.map((finding) => finding.body.trim()).filter(Boolean)));
213
221
  }
214
222
  async function buildMultiFocusReviewOutput({ inlineFindings, summaryFindings, lineLookup, prTitle, prBody, reviewObservations, summaryModel, summaryFallbackModels, workspaceDir, }) {
215
223
  const comments = convertFindingsToComments(inlineFindings, lineLookup);
216
224
  const inlineCommentFindings = Array.from(new Set(comments.map((comment) => comment.body.trim()).filter(Boolean)));
217
- const findings = buildSummarySynthesisFindings({ inlineFindings, summaryFindings });
225
+ const findings = buildSummarySynthesisFindings(inlineFindings);
218
226
  const hasReviewFindings = inlineFindings.length > 0 || summaryFindings.length > 0;
219
227
  if (!hasReviewFindings) {
220
228
  logger.info('Review summary synthesis skipped for clean review', {
@@ -14,6 +14,8 @@ const MULTI_FOCUS_BASE_PROMPT_FILE = `${REVIEW_PROMPTS_DIR}/review-multi-focus-b
14
14
  const REVIEW_FOCUS_PROMPTS_DIR = `${REVIEW_PROMPTS_DIR}/review-focus-prompts`;
15
15
  export const REVIEW_FOCUS_DEFINITIONS = [
16
16
  { name: 'general', title: 'General', fileName: 'general.md' },
17
+ { name: 'security', title: 'Security', fileName: 'security.md' },
18
+ { name: 'reuse', title: 'Reuse', fileName: 'reuse.md' },
17
19
  { name: 'quality', title: 'Quality', fileName: 'quality.md' },
18
20
  { name: 'efficiency', title: 'Efficiency', fileName: 'efficiency.md' },
19
21
  { name: 'tests', title: 'Tests', fileName: 'tests.md' },