@doist/doistbot-cli 1.0.6 → 1.0.7

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 (35) hide show
  1. package/dist/actions/auth.js +29 -9
  2. package/dist/actions/doctor.js +1 -1
  3. package/dist/actions/review.js +2 -10
  4. package/dist/config.js +49 -6
  5. package/package.json +1 -1
  6. package/sandbox/_node_modules/doistbot-repo-config/dist/index.d.ts +1 -1
  7. package/sandbox/_node_modules/doistbot-repo-config/dist/index.js +1 -1
  8. package/sandbox/dist/core/pi.js +31 -1
  9. package/sandbox/dist/core/prompt-template.js +45 -3
  10. package/sandbox/dist/core/repository-map.js +24 -0
  11. package/sandbox/dist/core/tracing.js +9 -0
  12. package/sandbox/dist/providers/helpers.js +51 -0
  13. package/sandbox/dist/providers/pi-review.js +7 -1
  14. package/sandbox/dist/tasks/chat/chat.js +13 -2
  15. package/sandbox/dist/tasks/issue-summarize/model.js +119 -48
  16. package/sandbox/dist/tasks/issue-summarize/prompt.js +2 -2
  17. package/sandbox/dist/tasks/issue-summarize/summarize.js +104 -88
  18. package/sandbox/dist/tasks/issue-triage/fix-attempt.js +2 -2
  19. package/sandbox/dist/tasks/issue-triage/hero-group-map.js +2 -0
  20. package/sandbox/dist/tasks/issue-triage/pr-creator.js +2 -2
  21. package/sandbox/dist/tasks/issue-triage/prompt.js +2 -2
  22. package/sandbox/dist/tasks/issue-triage/triage.js +2 -2
  23. package/sandbox/dist/tasks/persist-logs/llm-query-planner.js +3 -3
  24. package/sandbox/dist/tasks/review/engines/dedupe.js +107 -42
  25. package/sandbox/dist/tasks/review/engines/multi-focus.js +229 -119
  26. package/sandbox/dist/tasks/review/engines/shared.js +36 -1
  27. package/sandbox/dist/tasks/review/github-comment-format.js +12 -4
  28. package/sandbox/dist/tasks/review/local.js +6 -1
  29. package/sandbox/dist/tasks/review/review-summary.js +3 -0
  30. package/sandbox/dist/tasks/review/review.js +253 -186
  31. package/sandbox/dist/tasks/review/runner.js +10 -5
  32. package/sandbox/dist/tasks/review/summary-model.js +40 -18
  33. package/sandbox/node_modules/doistbot-repo-config/dist/index.d.ts +1 -1
  34. package/sandbox/node_modules/doistbot-repo-config/dist/index.js +1 -1
  35. package/sandbox/src/tasks/review/prompts/review-multi-focus-base-prompt.md +1 -0
@@ -1,3 +1,4 @@
1
+ import chalk from 'chalk';
1
2
  import { deleteGeminiApiKey, deleteOpenRouterApiKey, getAuthStatus, loginOpenAICodex, logoutOpenAICodex, saveGeminiApiKey, saveOpenRouterApiKey, } from '../auth.js';
2
3
  import { getEnvApiKeys } from '../env.js';
3
4
  import { FILE_STORE_PATH } from '../file-store.js';
@@ -43,6 +44,18 @@ const AUTH_LOGOUT_PROVIDER_CONFIGS = [
43
44
  ];
44
45
  const AUTH_LOGOUT_PROVIDERS = AUTH_LOGOUT_PROVIDER_CONFIGS.map(({ value }) => value);
45
46
  export const AUTH_LOGOUT_PROVIDER_USAGE = `${AUTH_LOGOUT_PROVIDERS.join('|')}|all`;
47
+ const OPTIONAL_LABEL = '(optional)';
48
+ const SKIP_INSTRUCTION = 'press Enter to skip';
49
+ const API_KEY_PROMPT = 'Paste your API Key: ';
50
+ function highlightOptionalText(value) {
51
+ return chalk.bold.yellow(value);
52
+ }
53
+ function formatOptionalLabel() {
54
+ return highlightOptionalText(OPTIONAL_LABEL);
55
+ }
56
+ function formatSkipInstruction() {
57
+ return highlightOptionalText(SKIP_INSTRUCTION);
58
+ }
46
59
  export async function runAuthSetup(dependencies = {}) {
47
60
  const deps = {
48
61
  getAuthStatus,
@@ -69,7 +82,7 @@ export async function runAuthSetup(dependencies = {}) {
69
82
  deps.writeLine('✓ OpenAI configured.');
70
83
  }
71
84
  deps.writeLine();
72
- deps.writeLine('2. Gemini API key');
85
+ deps.writeLine(`2. Gemini API key ${formatOptionalLabel()}`);
73
86
  deps.writeLine();
74
87
  if (status.gemini.source === 'env') {
75
88
  deps.writeLine('✓ Gemini already configured from GEMINI_API_KEY.');
@@ -85,8 +98,8 @@ export async function runAuthSetup(dependencies = {}) {
85
98
  deps.writeLine('Gemini not configured. Skipping optional Gemini provider.');
86
99
  }
87
100
  else {
88
- deps.writeLine('Doistbot can use Gemini via Google Vertex. Paste a Gemini API key, or press Enter to skip.');
89
- const geminiApiKey = (await deps.promptHiddenInput('Gemini API key (optional): ')).trim();
101
+ deps.writeLine(`Doistbot can use Gemini via Google Vertex. Paste a Gemini API key, or ${formatSkipInstruction()}.`);
102
+ const geminiApiKey = (await deps.promptHiddenInput(API_KEY_PROMPT)).trim();
90
103
  if (!geminiApiKey) {
91
104
  deps.writeLine();
92
105
  deps.writeLine('Gemini skipped. You can add it later with `doistbot auth`.');
@@ -102,7 +115,7 @@ export async function runAuthSetup(dependencies = {}) {
102
115
  }
103
116
  }
104
117
  deps.writeLine();
105
- deps.writeLine('3. OpenRouter API key');
118
+ deps.writeLine(`3. OpenRouter API key ${formatOptionalLabel()}`);
106
119
  deps.writeLine();
107
120
  if (status.openrouter.source === 'env') {
108
121
  deps.writeLine('✓ OpenRouter already configured from OPENROUTER_API_KEY.');
@@ -117,10 +130,17 @@ export async function runAuthSetup(dependencies = {}) {
117
130
  return;
118
131
  }
119
132
  if (!deps.isInteractive) {
120
- throw new Error('Cannot prompt for OpenRouter API key in non-interactive mode. Set OPENROUTER_API_KEY instead.');
133
+ deps.writeLine('OpenRouter not configured. Skipping optional OpenRouter provider.');
134
+ return;
135
+ }
136
+ deps.writeLine(`Doistbot can use OpenRouter for DeepSeek, Z.ai, and other open models. Paste an OpenRouter API key, or ${formatSkipInstruction()}.`);
137
+ const openRouterApiKey = (await deps.promptHiddenInput(API_KEY_PROMPT)).trim();
138
+ if (!openRouterApiKey) {
139
+ deps.writeLine();
140
+ deps.writeLine('OpenRouter skipped. You can add it later with `doistbot auth`.');
141
+ return;
121
142
  }
122
- deps.writeLine('Doistbot uses OpenRouter for DeepSeek, Z.ai, and other non-GPT review models. Please create an OpenRouter API key at https://openrouter.ai/keys and paste it here. (Use your Work Perk for it)');
123
- const backend = await deps.saveOpenRouterApiKey(await deps.promptHiddenInput('OpenRouter API key: '));
143
+ const backend = await deps.saveOpenRouterApiKey(openRouterApiKey);
124
144
  deps.writeLine();
125
145
  if (backend === 'file') {
126
146
  deps.writeLine(`Note: ${getSecureStoreLabel()} unavailable; key stored in ${FILE_STORE_PATH} with 0600 permissions.`);
@@ -130,8 +150,8 @@ export async function runAuthSetup(dependencies = {}) {
130
150
  export async function runAuthStatus() {
131
151
  const status = await getAuthStatus();
132
152
  writeLine(`OpenAI: ${status.openai.configured ? `configured (${status.openai.source ?? 'unknown'})` : 'not configured'}`);
133
- writeLine(`Gemini (optional): ${status.gemini.configured ? `configured (${status.gemini.source ?? 'unknown'})` : 'not configured'}`);
134
- writeLine(`OpenRouter: ${status.openrouter.configured ? `configured (${status.openrouter.source ?? 'unknown'})` : 'not configured'}`);
153
+ writeLine(`Gemini ${formatOptionalLabel()}: ${status.gemini.configured ? `configured (${status.gemini.source ?? 'unknown'})` : 'not configured'}`);
154
+ writeLine(`OpenRouter ${formatOptionalLabel()}: ${status.openrouter.configured ? `configured (${status.openrouter.source ?? 'unknown'})` : 'not configured'}`);
135
155
  }
136
156
  export async function runAuthLogout(target, dependencies = {}) {
137
157
  const deps = {
@@ -9,7 +9,7 @@ export function getConfiguredProviders({ status, }) {
9
9
  ? `Gemini optional (${status.gemini.source ?? 'configured'})`
10
10
  : undefined,
11
11
  status.openrouter.configured
12
- ? `OpenRouter (${status.openrouter.source ?? 'configured'})`
12
+ ? `OpenRouter optional (${status.openrouter.source ?? 'configured'})`
13
13
  : undefined,
14
14
  ].filter((provider) => provider !== undefined);
15
15
  }
@@ -1,4 +1,4 @@
1
- import { formatMissingRequiredCliAuthProviders, getApiKeys, getMissingRequiredCliReviewAuthProviders, } from '../config.js';
1
+ import { formatMissingRequiredCliAuthProviders, getApiKeys, getCliReviewRouting, getMissingRequiredCliReviewAuthProviders, } from '../config.js';
2
2
  import { getRepositoryRoot, loadSandboxFormatModule, loadSandboxReviewModule } from '../runtime.js';
3
3
  import { formatReviewProgress, renderAlert, renderMarkdown, withSpinner, writeLine, } from '../terminal.js';
4
4
  const LOCAL_REVIEW_SOURCE_STAGED = 'staged';
@@ -208,15 +208,6 @@ function getMissingAuthProviderFailures(missingProviders) {
208
208
  message: 'OpenAI not configured; run `doistbot auth`',
209
209
  });
210
210
  }
211
- if (missingProviders.includes('OpenRouter')) {
212
- failures.push({
213
- model: 'deepseek',
214
- message: 'OpenRouter not configured; run `doistbot auth`',
215
- }, {
216
- model: 'zai',
217
- message: 'OpenRouter not configured; run `doistbot auth`',
218
- });
219
- }
220
211
  return failures;
221
212
  }
222
213
  function formatMissingAuthForJson({ source, missingProviders, }) {
@@ -245,6 +236,7 @@ async function runLocalReviewForCli({ source, apiKeys, onProgress, }) {
245
236
  workspaceDir: getRepositoryRoot(),
246
237
  source,
247
238
  apiKeys,
239
+ ...getCliReviewRouting(apiKeys),
248
240
  suppressLogs: true,
249
241
  onProgress,
250
242
  });
package/dist/config.js CHANGED
@@ -1,9 +1,31 @@
1
1
  import { getStoredGeminiApiKey, getStoredOpenRouterApiKey, hasOpenAICodexAuth, PI_AUTH_API_KEY, } from './auth.js';
2
2
  import { getEnvApiKeys } from './env.js';
3
- const REQUIRED_CLI_AUTH_PROVIDERS = [
4
- { provider: 'openai', label: 'OpenAI' },
5
- { provider: 'openrouter', label: 'OpenRouter' },
3
+ const REQUIRED_CLI_AUTH_PROVIDERS = [{ provider: 'openai', label: 'OpenAI' }];
4
+ const ALL_REVIEW_FOCUSES = [
5
+ 'general',
6
+ 'quality',
7
+ 'efficiency',
8
+ 'tests',
9
+ 'standards',
6
10
  ];
11
+ const OPENROUTER_REVIEW_MODELS = ['deepseek', 'zai'];
12
+ const CLI_GEMINI_REVIEW_MODEL = 'gemini-3.5-flash';
13
+ function createCliGeminiCredential(apiKey) {
14
+ return {
15
+ apiKey,
16
+ modelName: CLI_GEMINI_REVIEW_MODEL,
17
+ };
18
+ }
19
+ function createCliGeminiSummaryModel(credential) {
20
+ if (!credential) {
21
+ return undefined;
22
+ }
23
+ return {
24
+ provider: 'gemini',
25
+ credential,
26
+ modelName: CLI_GEMINI_REVIEW_MODEL,
27
+ };
28
+ }
7
29
  export async function getApiKeys(env = process.env, auth = {
8
30
  getStoredGeminiApiKey,
9
31
  getStoredOpenRouterApiKey,
@@ -21,7 +43,7 @@ export async function getApiKeys(env = process.env, auth = {
21
43
  const apiKeys = {};
22
44
  const geminiApiKey = envApiKeys.gemini || storedGeminiApiKey;
23
45
  if (geminiApiKey) {
24
- apiKeys.gemini = geminiApiKey;
46
+ apiKeys.gemini = createCliGeminiCredential(geminiApiKey);
25
47
  }
26
48
  if (openRouterApiKey) {
27
49
  apiKeys.deepseek = { apiKey: openRouterApiKey, piProviderId: 'openrouter' };
@@ -40,8 +62,6 @@ export function getMissingRequiredCliReviewAuthProviders(apiKeys) {
40
62
  switch (provider) {
41
63
  case 'openai':
42
64
  return !apiKeys.openai;
43
- case 'openrouter':
44
- return !(apiKeys.deepseek && apiKeys.zai);
45
65
  }
46
66
  return false;
47
67
  }).map(({ label }) => label);
@@ -49,3 +69,26 @@ export function getMissingRequiredCliReviewAuthProviders(apiKeys) {
49
69
  export function formatMissingRequiredCliAuthProviders(missingProviders) {
50
70
  return `${missingProviders.join(' and ')} not configured; run \`doistbot auth\``;
51
71
  }
72
+ export function getCliReviewRouting(apiKeys) {
73
+ const hasGemini = Boolean(apiKeys.gemini);
74
+ const hasOpenRouter = Boolean(apiKeys.deepseek && apiKeys.zai);
75
+ if (hasGemini && hasOpenRouter) {
76
+ return {
77
+ reviewModelsEnabled: ['openai', ...OPENROUTER_REVIEW_MODELS],
78
+ geminiFallbackFocuses: ALL_REVIEW_FOCUSES,
79
+ };
80
+ }
81
+ const focusModels = [
82
+ 'openai',
83
+ ...(hasGemini ? ['gemini'] : []),
84
+ ...(hasOpenRouter ? OPENROUTER_REVIEW_MODELS : []),
85
+ ];
86
+ return {
87
+ reviewModelsEnabled: focusModels,
88
+ reviewModelsByFocus: Object.fromEntries(ALL_REVIEW_FOCUSES.map((focus) => [focus, focusModels])),
89
+ geminiFallbackFocuses: [],
90
+ ...(hasGemini && !hasOpenRouter
91
+ ? { reviewSummaryModel: createCliGeminiSummaryModel(apiKeys.gemini) }
92
+ : {}),
93
+ };
94
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doist/doistbot-cli",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
4
4
  "description": "Local self-review CLI for Doistbot",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,7 +1,7 @@
1
1
  import type { Logger } from 'doistbot-logging';
2
2
  import type { Octokit } from 'octokit';
3
3
  export declare const CONFIG_FILE_PATH = ".github/doistbot.yml";
4
- export declare const DEFAULT_REVIEW_MODELS: readonly ["gemini", "openai", "deepseek", "zai"];
4
+ export declare const DEFAULT_REVIEW_MODELS: readonly ["openai", "deepseek", "zai"];
5
5
  export type DefaultReviewModel = (typeof DEFAULT_REVIEW_MODELS)[number];
6
6
  export type RepoConfig = {
7
7
  review?: {
@@ -2,7 +2,7 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { load as yamlLoad } from 'js-yaml';
4
4
  export const CONFIG_FILE_PATH = '.github/doistbot.yml';
5
- export const DEFAULT_REVIEW_MODELS = ['gemini', 'openai', 'deepseek', 'zai'];
5
+ export const DEFAULT_REVIEW_MODELS = ['openai', 'deepseek', 'zai'];
6
6
  function parseRepoConfigContent(content) {
7
7
  return yamlLoad(content);
8
8
  }
@@ -13,13 +13,15 @@ export const DEFAULT_PI_MODELS = {
13
13
  deepseek: 'deepseek-v4-pro',
14
14
  gemini: 'gemini-3.1-pro-preview',
15
15
  zai: 'glm5.2',
16
- openai: 'gpt-5.5',
16
+ openai: 'gpt-5.4',
17
17
  };
18
18
  export const DEFAULT_OPENROUTER_MODELS = {
19
19
  deepseek: 'deepseek/deepseek-v4-pro',
20
20
  gemini: 'google/gemini-3.1-pro-preview',
21
21
  zai: 'z-ai/glm-5.2',
22
22
  };
23
+ // Gemini flash fallback model.
24
+ export const DEFAULT_GEMINI_FLASH_MODEL = 'gemini-3.5-flash';
23
25
  export const MAX_PI_THINKING_BY_PROVIDER = {
24
26
  deepseek: 'high',
25
27
  gemini: 'high',
@@ -205,6 +207,15 @@ function extractAssistantMessageResult(message) {
205
207
  ? { error: errorMessage.trim() }
206
208
  : null;
207
209
  }
210
+ function readStopReason(message) {
211
+ if (!message || typeof message !== 'object') {
212
+ return undefined;
213
+ }
214
+ const stopReason = message.stopReason;
215
+ // pi's stopReason follows the PiStopReason contract; cast rather than
216
+ // re-validate against a runtime list that would duplicate the union.
217
+ return typeof stopReason === 'string' ? stopReason : undefined;
218
+ }
208
219
  function maybeEmitProgress({ event, onProgress, lastThinkingAt, lastRespondingAt, lastToolUpdateAt, toolStartedAt, }) {
209
220
  if (!onProgress) {
210
221
  return;
@@ -352,6 +363,7 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
352
363
  let stderr = '';
353
364
  let lastAssistantText = '';
354
365
  let lastAssistantError = '';
366
+ let lastAssistantStopReason;
355
367
  let sessionHeaderSeen = false;
356
368
  const lastThinkingAt = { value: 0 };
357
369
  const lastRespondingAt = { value: 0 };
@@ -426,6 +438,14 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
426
438
  }
427
439
  }
428
440
  if (parsed.type === 'message_end') {
441
+ // Read the stop reason regardless of whether the
442
+ // message carried text or an error — a terminal
443
+ // "length"/"error"/"aborted" ending must surface
444
+ // even when there's no usable text.
445
+ const stopReason = readStopReason(parsed.message);
446
+ if (stopReason) {
447
+ lastAssistantStopReason = stopReason;
448
+ }
429
449
  const result = extractAssistantMessageResult(parsed.message);
430
450
  if (result?.text) {
431
451
  lastAssistantText = result.text;
@@ -437,6 +457,10 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
437
457
  }
438
458
  if (parsed.type === 'agent_end' && Array.isArray(parsed.messages)) {
439
459
  for (const message of parsed.messages) {
460
+ const stopReason = readStopReason(message);
461
+ if (stopReason) {
462
+ lastAssistantStopReason = stopReason;
463
+ }
440
464
  const result = extractAssistantMessageResult(message);
441
465
  if (result?.text) {
442
466
  lastAssistantText = result.text;
@@ -475,6 +499,7 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
475
499
  durationMs: Date.now() - startedAt,
476
500
  timedOut,
477
501
  toolUsage: toSortedToolUsage(toolUsageByName),
502
+ stopReason: lastAssistantStopReason,
478
503
  });
479
504
  });
480
505
  child.on('close', (code, signal) => {
@@ -489,6 +514,7 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
489
514
  durationMs: Date.now() - startedAt,
490
515
  timedOut: true,
491
516
  toolUsage: toSortedToolUsage(toolUsageByName),
517
+ stopReason: lastAssistantStopReason,
492
518
  });
493
519
  return;
494
520
  }
@@ -499,6 +525,7 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
499
525
  durationMs: Date.now() - startedAt,
500
526
  timedOut: false,
501
527
  toolUsage: toSortedToolUsage(toolUsageByName),
528
+ stopReason: lastAssistantStopReason,
502
529
  });
503
530
  return;
504
531
  }
@@ -509,6 +536,7 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
509
536
  durationMs: Date.now() - startedAt,
510
537
  timedOut: false,
511
538
  toolUsage: toSortedToolUsage(toolUsageByName),
539
+ stopReason: lastAssistantStopReason,
512
540
  });
513
541
  return;
514
542
  }
@@ -522,6 +550,7 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
522
550
  durationMs: Date.now() - startedAt,
523
551
  timedOut: false,
524
552
  toolUsage: toSortedToolUsage(toolUsageByName),
553
+ stopReason: lastAssistantStopReason,
525
554
  });
526
555
  return;
527
556
  }
@@ -530,6 +559,7 @@ export function createPiInvoker({ provider, apiKey, piProviderId, cwd, extraEnv,
530
559
  output,
531
560
  durationMs: Date.now() - startedAt,
532
561
  toolUsage: toSortedToolUsage(toolUsageByName),
562
+ stopReason: lastAssistantStopReason,
533
563
  });
534
564
  });
535
565
  });
@@ -1,15 +1,57 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
+ import { fileURLToPath } from 'url';
3
4
  import { logger } from './logging.js';
5
+ const promptCache = new Map();
6
+ const resolveCache = new Map();
7
+ /** Swap the `dist` directory nearest to the file (not just the first `/dist/` in the path) for `src`. */
8
+ function distDirToSrcDir(dir) {
9
+ const distSeg = `${path.sep}dist${path.sep}`;
10
+ const idx = dir.lastIndexOf(distSeg);
11
+ if (idx === -1) {
12
+ return dir;
13
+ }
14
+ return `${dir.slice(0, idx)}${path.sep}src${path.sep}${dir.slice(idx + distSeg.length)}`;
15
+ }
16
+ /**
17
+ * Resolve a prompt file living in `<module>/prompts/`, relative to the calling
18
+ * module. At runtime the module lives under `dist/`, but the prompts are shipped
19
+ * under `src/`, so we map `dist`->`src`. The `dist` candidate is tried first so a
20
+ * future build step that copies prompts into `dist/` works without code changes.
21
+ */
22
+ export function resolvePromptFile(moduleUrl, fileName) {
23
+ const byFileName = resolveCache.get(moduleUrl) ?? new Map();
24
+ resolveCache.set(moduleUrl, byFileName);
25
+ const cached = byFileName.get(fileName);
26
+ if (cached !== undefined) {
27
+ return cached;
28
+ }
29
+ const dir = path.dirname(fileURLToPath(moduleUrl));
30
+ const candidates = [
31
+ path.join(dir, 'prompts', fileName),
32
+ path.join(distDirToSrcDir(dir), 'prompts', fileName),
33
+ ];
34
+ const resolved = candidates.find(fs.existsSync) ?? candidates[candidates.length - 1];
35
+ byFileName.set(fileName, resolved);
36
+ return resolved;
37
+ }
4
38
  export function loadPromptTemplateFile({ fileName, promptLabel, }) {
5
- const promptPath = path.resolve(fileName);
39
+ if (!path.isAbsolute(fileName)) {
40
+ throw new Error(`${promptLabel} prompt template path must be absolute (resolve it with resolvePromptFile first), got: ${fileName}`);
41
+ }
42
+ const cached = promptCache.get(fileName);
43
+ if (cached !== undefined) {
44
+ return cached;
45
+ }
6
46
  try {
7
- return fs.readFileSync(promptPath, 'utf8');
47
+ const contents = fs.readFileSync(fileName, 'utf8');
48
+ promptCache.set(fileName, contents);
49
+ return contents;
8
50
  }
9
51
  catch (error) {
10
52
  logger.error(`Failed to load ${promptLabel} prompt template`, {
11
53
  error: error instanceof Error ? error.message : String(error),
12
- promptPath,
54
+ promptPath: fileName,
13
55
  });
14
56
  throw error;
15
57
  }
@@ -64,6 +64,16 @@ const TODOIST_REPOSITORIES = [
64
64
  product: 'Todoist',
65
65
  surfaces: ['android', 'ios'],
66
66
  }),
67
+ createRepositoryTarget({
68
+ repo: 'todoist-mcp',
69
+ product: 'Todoist',
70
+ surfaces: ['mcp'],
71
+ }),
72
+ createRepositoryTarget({
73
+ repo: 'todoist-cli',
74
+ product: 'Todoist',
75
+ surfaces: ['cli'],
76
+ }),
67
77
  ];
68
78
  const TWIST_REPOSITORIES = [
69
79
  createRepositoryTarget({
@@ -126,6 +136,16 @@ const TWIST_REPOSITORIES = [
126
136
  product: 'Twist',
127
137
  surfaces: ['desktop'],
128
138
  }),
139
+ createRepositoryTarget({
140
+ repo: 'twist-ai',
141
+ product: 'Twist',
142
+ surfaces: ['mcp'],
143
+ }),
144
+ createRepositoryTarget({
145
+ repo: 'twist-cli',
146
+ product: 'Twist',
147
+ surfaces: ['cli'],
148
+ }),
129
149
  ];
130
150
  function dedupeRepositories(repositories) {
131
151
  const unique = new Map();
@@ -145,8 +165,10 @@ export const APP_TO_REPO_SELECTORS = {
145
165
  all: (repositories) => dedupeRepositories(repositories),
146
166
  android: (repositories) => selectBySurface(repositories, ['android']),
147
167
  backend: (repositories) => selectBySurface(repositories, ['backend']),
168
+ cli: (repositories) => selectBySurface(repositories, ['cli']),
148
169
  desktop: (repositories) => selectBySurface(repositories, ['desktop']),
149
170
  ios: (repositories) => selectBySurface(repositories, ['ios']),
171
+ mcp: (repositories) => selectBySurface(repositories, ['mcp']),
150
172
  web: (repositories) => selectBySurface(repositories, ['web']),
151
173
  };
152
174
  export const TEAM_TO_REPO_SELECTORS = {
@@ -179,8 +201,10 @@ export const APP_LABEL_ALIASES = {
179
201
  all: 'all',
180
202
  android: 'android',
181
203
  backend: 'backend',
204
+ cli: 'cli',
182
205
  desktop: 'desktop',
183
206
  ios: 'ios',
207
+ mcp: 'mcp',
184
208
  web: 'web',
185
209
  };
186
210
  export const TEAM_LABEL_ALIASES = {
@@ -69,3 +69,12 @@ export function recordDecision(name, meta) {
69
69
  export function recordEvent(name, meta) {
70
70
  emitPointInTime('event', name, meta);
71
71
  }
72
+ /**
73
+ * Maps a doistbot provider key to the OTel `gen_ai.provider.name` value
74
+ * (docs/sandbox-instrumentation.md). Only `gemini` has a standard name
75
+ * (`gcp.gemini`); the OpenRouter-routed keys (`zai`, `deepseek`) have none, so
76
+ * they pass through. Single source so every span site stays consistent.
77
+ */
78
+ export function genAiProviderName(provider) {
79
+ return provider === 'gemini' ? 'gcp.gemini' : provider;
80
+ }
@@ -6,6 +6,57 @@ import { logger } from '../core/logging.js';
6
6
  import { isRawReviewResult } from './schemas.js';
7
7
  const RAW_MODEL_LOG_CHUNK_CHARS = 8000;
8
8
  const REVIEW_LOG_ENGINE = 'multi_focus';
9
+ const PROVIDER_ERROR_DETAILS_MAX_DEPTH = 8;
10
+ function isRecord(value) {
11
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
12
+ }
13
+ function parseJsonValue(value) {
14
+ try {
15
+ return JSON.parse(value.trim());
16
+ }
17
+ catch {
18
+ return undefined;
19
+ }
20
+ }
21
+ export function readProviderErrorDetails(error) {
22
+ const rawError = error instanceof Error ? error.message : String(error);
23
+ const details = { rawError };
24
+ function visit(value, depth) {
25
+ if (depth > PROVIDER_ERROR_DETAILS_MAX_DEPTH) {
26
+ return;
27
+ }
28
+ if (typeof value === 'string') {
29
+ const trimmed = value.trim();
30
+ const parsed = parseJsonValue(trimmed);
31
+ if (isRecord(parsed)) {
32
+ visit(parsed, depth + 1);
33
+ return;
34
+ }
35
+ if (!details.reason && trimmed) {
36
+ details.reason = trimmed;
37
+ }
38
+ return;
39
+ }
40
+ if (!isRecord(value)) {
41
+ return;
42
+ }
43
+ if (value.error !== undefined) {
44
+ visit(value.error, depth + 1);
45
+ }
46
+ if (typeof value.message === 'string') {
47
+ visit(value.message, depth + 1);
48
+ }
49
+ if (details.errorCode === undefined &&
50
+ (typeof value.code === 'number' || typeof value.code === 'string')) {
51
+ details.errorCode = value.code;
52
+ }
53
+ if (details.status === undefined && typeof value.status === 'string') {
54
+ details.status = value.status;
55
+ }
56
+ }
57
+ visit(rawError, 0);
58
+ return details;
59
+ }
9
60
  function redactSensitiveStrings(content) {
10
61
  return content
11
62
  .replace(/(sk-[A-Za-z0-9_-]{10,})/g, '[REDACTED_OPENAI_KEY]')
@@ -53,7 +53,13 @@ function isOpenRouterBackedReviewRequest(provider, config) {
53
53
  PI_PROVIDER_BY_DOISTBOT_PROVIDER[provider];
54
54
  return resolvedPiProviderId === 'openrouter';
55
55
  }
56
- export async function runPiReviewProvider({ provider, modelName, prompt, config, prompts = [prompt], parseOutput, }) {
56
+ export async function runPiReviewProvider(params) {
57
+ // Stamp the resolved model spec on every result (success or failure) so
58
+ // telemetry has the real model without re-deriving each provider's default.
59
+ const result = await runPiReviewProviderCore(params);
60
+ return { ...result, requestModel: params.modelName };
61
+ }
62
+ async function runPiReviewProviderCore({ provider, modelName, prompt, config, prompts = [prompt], parseOutput, }) {
57
63
  const startTime = Date.now();
58
64
  const timeoutMs = config.timeout ?? 0;
59
65
  let stdout = '';
@@ -4,7 +4,7 @@ import { isKnownDoistbotLogin } from '../../core/bot-logins.js';
4
4
  import { recordTaskMetrics } from '../../core/datadog-metrics.js';
5
5
  import { logger } from '../../core/logging.js';
6
6
  import { invokePiPrompt } from '../../core/pi.js';
7
- import { loadPromptTemplateFile } from '../../core/prompt-template.js';
7
+ import { loadPromptTemplateFile, resolvePromptFile } from '../../core/prompt-template.js';
8
8
  import { removeIssueCommentReactionIfPresent } from '../../core/reactions.js';
9
9
  import { cloneRepository, createGitHubClients, parseBaseContext, requiredEnv, WORKSPACE_DIR, } from '../../core/shared.js';
10
10
  import { withPhase } from '../../core/tracing.js';
@@ -148,6 +148,17 @@ export async function invokeChatModel(prompt) {
148
148
  // Keep the executed model in sync with the recorded span attr.
149
149
  modelName: CHAT_MODEL,
150
150
  });
151
+ // Record why the model stopped, before the ok/failure branch so
152
+ // terminal "error"/"aborted"/"length" endings are reported too.
153
+ // stopReason values:
154
+ // stop Natural end — model finished or hit a stop sequence.
155
+ // length Hit the token cap (max_tokens or context limit).
156
+ // toolUse Paused to call a tool. Expected mid-agent-loop.
157
+ // error Provider errored mid-generation.
158
+ // aborted Generation was cancelled (e.g. signal/timeout).
159
+ if (result.stopReason) {
160
+ span.setAttribute('gen_ai.response.finish_reasons', [result.stopReason]);
161
+ }
151
162
  if (!result.ok) {
152
163
  // Failure returns a result rather than throwing — mark the span
153
164
  // ERROR explicitly so it survives to phase.end (withPhase only
@@ -280,7 +291,7 @@ async function main() {
280
291
  }
281
292
  // Build prompt and invoke the chat model
282
293
  const promptTemplate = loadPromptTemplateFile({
283
- fileName: CHAT_PROMPT_FILE,
294
+ fileName: resolvePromptFile(import.meta.url, CHAT_PROMPT_FILE),
284
295
  promptLabel: 'chat',
285
296
  });
286
297
  const prompt = buildPrompt({