@doist/doistbot-cli 1.0.6 → 1.0.8

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 (36) hide show
  1. package/dist/actions/auth.js +43 -11
  2. package/dist/actions/doctor.js +4 -2
  3. package/dist/actions/review.js +11 -14
  4. package/dist/auth.js +23 -6
  5. package/dist/config.js +58 -12
  6. package/package.json +3 -3
  7. package/sandbox/_node_modules/doistbot-repo-config/dist/index.d.ts +1 -1
  8. package/sandbox/_node_modules/doistbot-repo-config/dist/index.js +1 -1
  9. package/sandbox/dist/core/pi.js +31 -1
  10. package/sandbox/dist/core/prompt-template.js +45 -3
  11. package/sandbox/dist/core/repository-map.js +24 -0
  12. package/sandbox/dist/core/tracing.js +9 -0
  13. package/sandbox/dist/providers/helpers.js +51 -0
  14. package/sandbox/dist/providers/pi-review.js +7 -1
  15. package/sandbox/dist/tasks/chat/chat.js +20 -8
  16. package/sandbox/dist/tasks/issue-summarize/model.js +119 -48
  17. package/sandbox/dist/tasks/issue-summarize/prompt.js +2 -2
  18. package/sandbox/dist/tasks/issue-summarize/summarize.js +104 -88
  19. package/sandbox/dist/tasks/issue-triage/fix-attempt.js +3 -2
  20. package/sandbox/dist/tasks/issue-triage/hero-group-map.js +2 -0
  21. package/sandbox/dist/tasks/issue-triage/pr-creator.js +2 -2
  22. package/sandbox/dist/tasks/issue-triage/prompt.js +2 -2
  23. package/sandbox/dist/tasks/issue-triage/triage.js +2 -2
  24. package/sandbox/dist/tasks/persist-logs/llm-query-planner.js +3 -3
  25. package/sandbox/dist/tasks/review/engines/dedupe.js +107 -42
  26. package/sandbox/dist/tasks/review/engines/multi-focus.js +229 -119
  27. package/sandbox/dist/tasks/review/engines/shared.js +36 -1
  28. package/sandbox/dist/tasks/review/github-comment-format.js +12 -4
  29. package/sandbox/dist/tasks/review/local.js +6 -1
  30. package/sandbox/dist/tasks/review/review-summary.js +3 -0
  31. package/sandbox/dist/tasks/review/review.js +253 -186
  32. package/sandbox/dist/tasks/review/runner.js +10 -5
  33. package/sandbox/dist/tasks/review/summary-model.js +40 -18
  34. package/sandbox/node_modules/doistbot-repo-config/dist/index.d.ts +1 -1
  35. package/sandbox/node_modules/doistbot-repo-config/dist/index.js +1 -1
  36. 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,
@@ -57,19 +70,22 @@ export async function runAuthSetup(dependencies = {}) {
57
70
  const status = await deps.getAuthStatus();
58
71
  deps.writeLine('1. OpenAI Codex');
59
72
  deps.writeLine();
60
- if (status.openai.configured) {
73
+ if (status.openai.configured && !status.openai.refreshFailed) {
61
74
  deps.writeLine('✓ OpenAI already configured (pi-auth).');
62
75
  }
63
76
  else {
64
77
  if (!deps.isInteractive) {
65
78
  throw new Error('Cannot configure OpenAI Codex auth in non-interactive mode.');
66
79
  }
80
+ if (status.openai.refreshFailed) {
81
+ deps.writeLine('OpenAI session could not be refreshed (expired, revoked, or offline). Reconnecting.');
82
+ }
67
83
  await deps.loginOpenAICodex();
68
84
  deps.writeLine();
69
85
  deps.writeLine('✓ OpenAI configured.');
70
86
  }
71
87
  deps.writeLine();
72
- deps.writeLine('2. Gemini API key');
88
+ deps.writeLine(`2. Gemini API key ${formatOptionalLabel()}`);
73
89
  deps.writeLine();
74
90
  if (status.gemini.source === 'env') {
75
91
  deps.writeLine('✓ Gemini already configured from GEMINI_API_KEY.');
@@ -85,8 +101,8 @@ export async function runAuthSetup(dependencies = {}) {
85
101
  deps.writeLine('Gemini not configured. Skipping optional Gemini provider.');
86
102
  }
87
103
  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();
104
+ deps.writeLine(`Doistbot can use Gemini via Google Vertex. Paste a Gemini API key, or ${formatSkipInstruction()}.`);
105
+ const geminiApiKey = (await deps.promptHiddenInput(API_KEY_PROMPT)).trim();
90
106
  if (!geminiApiKey) {
91
107
  deps.writeLine();
92
108
  deps.writeLine('Gemini skipped. You can add it later with `doistbot auth`.');
@@ -102,7 +118,7 @@ export async function runAuthSetup(dependencies = {}) {
102
118
  }
103
119
  }
104
120
  deps.writeLine();
105
- deps.writeLine('3. OpenRouter API key');
121
+ deps.writeLine(`3. OpenRouter API key ${formatOptionalLabel()}`);
106
122
  deps.writeLine();
107
123
  if (status.openrouter.source === 'env') {
108
124
  deps.writeLine('✓ OpenRouter already configured from OPENROUTER_API_KEY.');
@@ -117,10 +133,17 @@ export async function runAuthSetup(dependencies = {}) {
117
133
  return;
118
134
  }
119
135
  if (!deps.isInteractive) {
120
- throw new Error('Cannot prompt for OpenRouter API key in non-interactive mode. Set OPENROUTER_API_KEY instead.');
136
+ deps.writeLine('OpenRouter not configured. Skipping optional OpenRouter provider.');
137
+ return;
121
138
  }
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: '));
139
+ deps.writeLine(`Doistbot can use OpenRouter for DeepSeek, Z.ai, and other open models. Paste an OpenRouter API key, or ${formatSkipInstruction()}.`);
140
+ const openRouterApiKey = (await deps.promptHiddenInput(API_KEY_PROMPT)).trim();
141
+ if (!openRouterApiKey) {
142
+ deps.writeLine();
143
+ deps.writeLine('OpenRouter skipped. You can add it later with `doistbot auth`.');
144
+ return;
145
+ }
146
+ const backend = await deps.saveOpenRouterApiKey(openRouterApiKey);
124
147
  deps.writeLine();
125
148
  if (backend === 'file') {
126
149
  deps.writeLine(`Note: ${getSecureStoreLabel()} unavailable; key stored in ${FILE_STORE_PATH} with 0600 permissions.`);
@@ -129,9 +152,18 @@ export async function runAuthSetup(dependencies = {}) {
129
152
  }
130
153
  export async function runAuthStatus() {
131
154
  const status = await getAuthStatus();
132
- 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'}`);
155
+ writeLine(`OpenAI: ${formatOpenAIStatus(status.openai)}`);
156
+ writeLine(`Gemini ${formatOptionalLabel()}: ${status.gemini.configured ? `configured (${status.gemini.source ?? 'unknown'})` : 'not configured'}`);
157
+ writeLine(`OpenRouter ${formatOptionalLabel()}: ${status.openrouter.configured ? `configured (${status.openrouter.source ?? 'unknown'})` : 'not configured'}`);
158
+ }
159
+ function formatOpenAIStatus(status) {
160
+ if (!status.configured) {
161
+ return 'not configured';
162
+ }
163
+ const base = `configured (${status.source ?? 'unknown'})`;
164
+ return status.refreshFailed
165
+ ? `${base} — session could not be refreshed; check your connection or run \`doistbot auth login\``
166
+ : base;
135
167
  }
136
168
  export async function runAuthLogout(target, dependencies = {}) {
137
169
  const deps = {
@@ -4,12 +4,14 @@ import { checkGit, checkPiDependency, checkRepository, checkSandboxBuild } from
4
4
  import { renderDiagnosticCheck } from '../terminal.js';
5
5
  export function getConfiguredProviders({ status, }) {
6
6
  return [
7
- status.openai.configured ? `OpenAI (${status.openai.source ?? 'configured'})` : undefined,
7
+ status.openai.configured && !status.openai.refreshFailed
8
+ ? `OpenAI (${status.openai.source ?? 'configured'})`
9
+ : undefined,
8
10
  status.gemini.configured
9
11
  ? `Gemini optional (${status.gemini.source ?? 'configured'})`
10
12
  : undefined,
11
13
  status.openrouter.configured
12
- ? `OpenRouter (${status.openrouter.source ?? 'configured'})`
14
+ ? `OpenRouter optional (${status.openrouter.source ?? 'configured'})`
13
15
  : undefined,
14
16
  ].filter((provider) => provider !== undefined);
15
17
  }
@@ -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';
@@ -21,10 +21,14 @@ function getReadableProviderFailure(failure) {
21
21
  if (failure.model === 'openai' &&
22
22
  (normalized.includes('no api key found for openai') ||
23
23
  normalized.includes('no api key found for openai-codex') ||
24
+ normalized.includes('no api key for provider') ||
24
25
  normalized.includes('use /login') ||
25
26
  normalized.includes('invalid api key') ||
26
- normalized.includes('incorrect api key'))) {
27
- return 'OpenAI Codex is not authenticated. Run `doistbot auth` to reconnect OpenAI.';
27
+ normalized.includes('incorrect api key') ||
28
+ normalized.includes('refresh_token_invalidated') ||
29
+ normalized.includes('unauthorized') ||
30
+ /\b401\b/.test(normalized))) {
31
+ return 'OpenAI Codex is not authenticated. Run `doistbot auth login` to reconnect OpenAI.';
28
32
  }
29
33
  if (failure.model === 'gemini' &&
30
34
  (normalized.includes('429') ||
@@ -40,7 +44,8 @@ function getReadableProviderFailure(failure) {
40
44
  normalized.includes('invalid_argument') ||
41
45
  normalized.includes('invalid api key') ||
42
46
  normalized.includes('no api key found for google') ||
43
- normalized.includes('no api key found for gemini'))) {
47
+ normalized.includes('no api key found for gemini') ||
48
+ normalized.includes('no api key for provider'))) {
44
49
  return 'Gemini API key is invalid or missing. Run `doistbot auth` and paste a valid Gemini key.';
45
50
  }
46
51
  const firstLine = error
@@ -205,16 +210,7 @@ function getMissingAuthProviderFailures(missingProviders) {
205
210
  if (missingProviders.includes('OpenAI')) {
206
211
  failures.push({
207
212
  model: 'openai',
208
- message: 'OpenAI not configured; run `doistbot auth`',
209
- });
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`',
213
+ message: 'OpenAI authentication unavailable; run `doistbot auth login`',
218
214
  });
219
215
  }
220
216
  return failures;
@@ -245,6 +241,7 @@ async function runLocalReviewForCli({ source, apiKeys, onProgress, }) {
245
241
  workspaceDir: getRepositoryRoot(),
246
242
  source,
247
243
  apiKeys,
244
+ ...getCliReviewRouting(apiKeys),
248
245
  suppressLogs: true,
249
246
  onProgress,
250
247
  });
package/dist/auth.js CHANGED
@@ -28,9 +28,22 @@ export function getBrowserOpenCommand(platform, url) {
28
28
  }
29
29
  return { command: 'xdg-open', args: [url] };
30
30
  }
31
- export async function hasOpenAICodexAuth(storage) {
31
+ /**
32
+ * 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.
37
+ */
38
+ export async function getOpenAICodexAuthState(storage) {
32
39
  const resolvedStorage = storage ?? (await createPiAuthStorage());
33
- return resolvedStorage.get(OPENAI_CODEX_PROVIDER_ID)?.type === 'oauth';
40
+ if (resolvedStorage.get(OPENAI_CODEX_PROVIDER_ID)?.type !== 'oauth') {
41
+ return 'missing';
42
+ }
43
+ const apiKey = await resolvedStorage.getApiKey(OPENAI_CODEX_PROVIDER_ID, {
44
+ includeFallback: false,
45
+ });
46
+ return apiKey ? 'ready' : 'refresh-failed';
34
47
  }
35
48
  export async function loginOpenAICodex(storage) {
36
49
  const resolvedStorage = storage ?? (await createPiAuthStorage());
@@ -105,11 +118,15 @@ export async function getAuthStatus({ env = process.env, storage, geminiStore =
105
118
  const openRouterStored = envApiKeys.openrouter
106
119
  ? { value: undefined }
107
120
  : await readStoredOpenRouterApiKey(openRouterStore).catch(() => ({ value: undefined }));
108
- const openAICodexAuthConfigured = await hasOpenAICodexAuth(storage);
121
+ const openAICodexAuthState = await getOpenAICodexAuthState(storage);
109
122
  return {
110
- openai: openAICodexAuthConfigured
111
- ? { configured: true, source: 'pi-auth' }
112
- : { configured: false, source: undefined },
123
+ openai: openAICodexAuthState === 'missing'
124
+ ? { configured: false, source: undefined }
125
+ : {
126
+ configured: true,
127
+ source: 'pi-auth',
128
+ refreshFailed: openAICodexAuthState === 'refresh-failed',
129
+ },
113
130
  gemini: envApiKeys.gemini
114
131
  ? { configured: true, source: 'env' }
115
132
  : {
package/dist/config.js CHANGED
@@ -1,13 +1,35 @@
1
- import { getStoredGeminiApiKey, getStoredOpenRouterApiKey, hasOpenAICodexAuth, PI_AUTH_API_KEY, } from './auth.js';
1
+ import { getOpenAICodexAuthState, getStoredGeminiApiKey, getStoredOpenRouterApiKey, 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,
10
- hasOpenAICodexAuth,
32
+ getOpenAICodexAuthState,
11
33
  }) {
12
34
  const envApiKeys = getEnvApiKeys(env);
13
35
  const storedGeminiApiKey = envApiKeys.gemini
@@ -16,36 +38,60 @@ export async function getApiKeys(env = process.env, auth = {
16
38
  const storedOpenRouterApiKey = envApiKeys.openrouter
17
39
  ? undefined
18
40
  : await auth.getStoredOpenRouterApiKey().catch(() => undefined);
19
- const openAICodexAuthConfigured = await auth.hasOpenAICodexAuth();
41
+ const openAICodexAuthState = await auth.getOpenAICodexAuthState();
20
42
  const openRouterApiKey = envApiKeys.openrouter || storedOpenRouterApiKey;
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' };
28
50
  apiKeys.zai = { apiKey: openRouterApiKey, piProviderId: 'openrouter' };
29
51
  }
30
- if (openAICodexAuthConfigured) {
52
+ if (openAICodexAuthState === 'ready') {
31
53
  apiKeys.openai = PI_AUTH_API_KEY;
32
54
  }
33
55
  return apiKeys;
34
56
  }
35
57
  export function getMissingRequiredCliAuthProviders(status) {
36
- return REQUIRED_CLI_AUTH_PROVIDERS.filter(({ provider }) => !status[provider].configured).map(({ label }) => label);
58
+ return REQUIRED_CLI_AUTH_PROVIDERS.filter(({ provider }) => {
59
+ const providerStatus = status[provider];
60
+ return !providerStatus.configured || providerStatus.refreshFailed === true;
61
+ }).map(({ label }) => label);
37
62
  }
38
63
  export function getMissingRequiredCliReviewAuthProviders(apiKeys) {
39
64
  return REQUIRED_CLI_AUTH_PROVIDERS.filter(({ provider }) => {
40
65
  switch (provider) {
41
66
  case 'openai':
42
67
  return !apiKeys.openai;
43
- case 'openrouter':
44
- return !(apiKeys.deepseek && apiKeys.zai);
45
68
  }
46
69
  return false;
47
70
  }).map(({ label }) => label);
48
71
  }
49
72
  export function formatMissingRequiredCliAuthProviders(missingProviders) {
50
- return `${missingProviders.join(' and ')} not configured; run \`doistbot auth\``;
73
+ return `${missingProviders.join(' and ')} authentication unavailable (not configured, or the session could not be refreshed); run \`doistbot auth login\``;
74
+ }
75
+ export function getCliReviewRouting(apiKeys) {
76
+ const hasGemini = Boolean(apiKeys.gemini);
77
+ const hasOpenRouter = Boolean(apiKeys.deepseek && apiKeys.zai);
78
+ if (hasGemini && hasOpenRouter) {
79
+ return {
80
+ reviewModelsEnabled: ['openai', ...OPENROUTER_REVIEW_MODELS],
81
+ geminiFallbackFocuses: ALL_REVIEW_FOCUSES,
82
+ };
83
+ }
84
+ const focusModels = [
85
+ 'openai',
86
+ ...(hasGemini ? ['gemini'] : []),
87
+ ...(hasOpenRouter ? OPENROUTER_REVIEW_MODELS : []),
88
+ ];
89
+ return {
90
+ reviewModelsEnabled: focusModels,
91
+ reviewModelsByFocus: Object.fromEntries(ALL_REVIEW_FOCUSES.map((focus) => [focus, focusModels])),
92
+ geminiFallbackFocuses: [],
93
+ ...(hasGemini && !hasOpenRouter
94
+ ? { reviewSummaryModel: createCliGeminiSummaryModel(apiKeys.gemini) }
95
+ : {}),
96
+ };
51
97
  }
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.8",
4
4
  "description": "Local self-review CLI for Doistbot",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -42,8 +42,8 @@
42
42
  "dependencies": {
43
43
  "@inquirer/checkbox": "5.2.1",
44
44
  "@inquirer/password": "5.1.1",
45
- "@earendil-works/pi-ai": "0.79.6",
46
- "@earendil-works/pi-coding-agent": "0.79.6",
45
+ "@earendil-works/pi-ai": "0.80.6",
46
+ "@earendil-works/pi-coding-agent": "0.80.6",
47
47
  "@napi-rs/keyring": "1.3.0",
48
48
  "ajv": "8.20.0",
49
49
  "chalk": "4.1.2",
@@ -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.6-terra',
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
+ }