@doist/doistbot-cli 1.0.5 → 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 (61) hide show
  1. package/dist/actions/auth.js +78 -24
  2. package/dist/actions/doctor.js +14 -7
  3. package/dist/actions/review.js +67 -10
  4. package/dist/auth.js +51 -6
  5. package/dist/config.js +77 -5
  6. package/dist/env.js +1 -0
  7. package/dist/runtime.js +1 -1
  8. package/dist/terminal.js +2 -0
  9. package/package.json +4 -4
  10. package/sandbox/_node_modules/doistbot-repo-config/dist/index.d.ts +4 -8
  11. package/sandbox/_node_modules/doistbot-repo-config/dist/index.js +8 -20
  12. package/sandbox/dist/core/check-run.js +16 -0
  13. package/sandbox/dist/core/datadog-metrics.js +6 -52
  14. package/sandbox/dist/core/logger-span-processor.js +78 -0
  15. package/sandbox/dist/core/pi.js +285 -174
  16. package/sandbox/dist/core/prompt-template.js +45 -3
  17. package/sandbox/dist/core/repository-map.js +24 -0
  18. package/sandbox/dist/core/tracing-setup.js +15 -0
  19. package/sandbox/dist/core/tracing.js +80 -0
  20. package/sandbox/dist/main.js +4 -1
  21. package/sandbox/dist/providers/credentials.js +12 -2
  22. package/sandbox/dist/providers/gemini.js +5 -1
  23. package/sandbox/dist/providers/helpers.js +63 -17
  24. package/sandbox/dist/providers/index.js +4 -0
  25. package/sandbox/dist/providers/openrouter-pi.js +25 -0
  26. package/sandbox/dist/providers/pi-review.js +19 -2
  27. package/sandbox/dist/tasks/chat/chat.js +173 -102
  28. package/sandbox/dist/tasks/issue-fix-retry/fix-retry.js +210 -185
  29. package/sandbox/dist/tasks/issue-summarize/model.js +120 -49
  30. package/sandbox/dist/tasks/issue-summarize/prompt.js +2 -2
  31. package/sandbox/dist/tasks/issue-summarize/summarize.js +104 -88
  32. package/sandbox/dist/tasks/issue-triage/clone.js +40 -0
  33. package/sandbox/dist/tasks/issue-triage/context.js +25 -1
  34. package/sandbox/dist/tasks/issue-triage/fix-attempt.js +33 -10
  35. package/sandbox/dist/tasks/issue-triage/fix-dispatch.js +154 -101
  36. package/sandbox/dist/tasks/issue-triage/fix-loop/runners.js +15 -6
  37. package/sandbox/dist/tasks/issue-triage/hero-group-map.js +2 -0
  38. package/sandbox/dist/tasks/issue-triage/model.js +120 -36
  39. package/sandbox/dist/tasks/issue-triage/output.js +27 -0
  40. package/sandbox/dist/tasks/issue-triage/pr-creator.js +5 -4
  41. package/sandbox/dist/tasks/issue-triage/prompt.js +2 -2
  42. package/sandbox/dist/tasks/issue-triage/resolution.js +26 -0
  43. package/sandbox/dist/tasks/issue-triage/routing.js +9 -9
  44. package/sandbox/dist/tasks/issue-triage/side-effects.js +9 -0
  45. package/sandbox/dist/tasks/issue-triage/triage.js +290 -420
  46. package/sandbox/dist/tasks/persist-logs/llm-query-planner.js +3 -3
  47. package/sandbox/dist/tasks/review/check-run.js +2 -1
  48. package/sandbox/dist/tasks/review/conversation-context.js +2 -1
  49. package/sandbox/dist/tasks/review/engines/dedupe.js +110 -44
  50. package/sandbox/dist/tasks/review/engines/multi-focus.js +254 -113
  51. package/sandbox/dist/tasks/review/engines/shared.js +43 -6
  52. package/sandbox/dist/tasks/review/github-comment-format.js +72 -10
  53. package/sandbox/dist/tasks/review/local.js +7 -4
  54. package/sandbox/dist/tasks/review/review-summary.js +48 -7
  55. package/sandbox/dist/tasks/review/review.js +267 -186
  56. package/sandbox/dist/tasks/review/runner.js +33 -7
  57. package/sandbox/dist/tasks/review/summary-model.js +189 -20
  58. package/sandbox/dist/tasks/review/thinking-level.js +4 -0
  59. package/sandbox/node_modules/doistbot-repo-config/dist/index.d.ts +4 -8
  60. package/sandbox/node_modules/doistbot-repo-config/dist/index.js +8 -20
  61. package/sandbox/src/tasks/review/prompts/review-multi-focus-base-prompt.md +1 -0
@@ -1,5 +1,6 @@
1
+ import { SpanStatusCode } from '@opentelemetry/api';
1
2
  import { logger } from '../../core/logging.js';
2
- import { createOpenRouterCredential } from '../../providers/credentials.js';
3
+ import { withPhase } from '../../core/tracing.js';
3
4
  import { DefaultLoopEffects } from './fix-loop/effects.js';
4
5
  import { runFixLoop } from './fix-loop/runners.js';
5
6
  import { attemptFix, buildFixAssessmentSkipReason, shouldAttemptFix } from './fix-attempt.js';
@@ -80,111 +81,163 @@ function prUrlFor(targetRepo, prNumber) {
80
81
  return `https://github.com/${targetRepo.repository.fullName}/pull/${prNumber}`;
81
82
  }
82
83
  export async function runConfiguredFix(input) {
83
- const target = resolveFixTarget(input);
84
- if (!target.ok) {
85
- return { attempted: false, repository: target.repository, reason: target.reason };
86
- }
87
- const targetRepo = target.targetRepo;
88
- let repoConfig;
89
- try {
90
- repoConfig = await input.loadRepoConfig(targetRepo.repository);
91
- }
92
- catch (error) {
93
- logger.warn('Fix loop config load failed', {
94
- task: 'fix-loop',
95
- repository: targetRepo.repository.fullName,
96
- error: errorReason(error),
97
- });
98
- return {
99
- attempted: true,
100
- repository: targetRepo.repository.fullName,
101
- prCreated: false,
102
- reason: `Fix loop config load failed: ${errorReason(error)}`,
103
- };
104
- }
105
- const autoFixMode = getAutoFixMode(repoConfig, input.ctx.enableAutoFixOverride);
106
- if (autoFixMode === 'disabled') {
107
- return {
108
- attempted: false,
109
- repository: targetRepo.repository.fullName,
110
- reason: 'Auto fix disabled by repository config.',
111
- };
112
- }
113
- if (autoFixMode === 'one-pass') {
114
- return attemptFix(toAttemptFixInput(input));
115
- }
116
- if (!shouldAttemptFix(input.fixAssessment)) {
117
- return {
118
- attempted: false,
119
- repository: targetRepo.repository.fullName,
120
- reason: buildFixAssessmentSkipReason(input.fixAssessment),
121
- };
122
- }
123
- const session = {
124
- kind: 'fix-loop',
125
- ctx: input.ctx,
126
- octokit: input.octokit,
127
- triageOutput: input.triageOutput,
128
- fixAssessment: input.fixAssessment,
129
- targetRepo,
130
- issueTitle: input.issueTitle,
131
- issueUrl: input.issueUrl,
132
- ghToken: input.ghToken,
133
- pushToken: input.ctx.githubToken,
134
- apiKeys: {
135
- openai: input.ctx.openaiApiKey,
136
- ...(input.ctx.openRouterApiKey !== undefined && {
137
- gemini: createOpenRouterCredential(input.ctx.openRouterApiKey),
138
- }),
139
- },
140
- };
141
- let finalState;
142
- try {
143
- finalState = await runFixLoop({
144
- effects: new DefaultLoopEffects(session, { dryRun: input.ctx.dryRun }),
145
- ...(input.ctx.dryRun ? { onTransition: logDryRunTransition } : {}),
146
- });
147
- }
148
- catch (error) {
149
- return {
150
- attempted: true,
151
- repository: targetRepo.repository.fullName,
152
- prCreated: false,
153
- reason: `${FIX_LOOP_FAILED_REASON_PREFIX} ${errorReason(error)}`,
84
+ return withPhase('triage.fix-attempt', { 'gen_ai.operation.name': 'invoke_agent', 'gen_ai.agent.name': 'codex.fix' }, async (span) => {
85
+ function finish(result) {
86
+ const prCreated = 'prCreated' in result ? result.prCreated : false;
87
+ // Only a real PR number counts as PR-evidence — dry-run loop
88
+ // states can carry a placeholder prNumber of 0.
89
+ const prNumber = 'prNumber' in result && result.prNumber !== undefined && result.prNumber > 0
90
+ ? result.prNumber
91
+ : undefined;
92
+ // Dry-run skips are expected outcomes, not failures — signaled
93
+ // by the stable dryRun flag on the result, never by parsing
94
+ // the human-readable reason copy.
95
+ const isDryRunSkip = 'dryRun' in result && result.dryRun === true;
96
+ let outcome;
97
+ if (prCreated) {
98
+ outcome = 'pr-created';
99
+ }
100
+ else if (isDryRunSkip) {
101
+ outcome = 'dry-run';
102
+ }
103
+ else if (prNumber !== undefined) {
104
+ // The loop opened a PR earlier (REVIEWING/ITERATING) but
105
+ // ended needing a human: a PR exists, the autonomous
106
+ // attempt did not finish.
107
+ outcome = 'pr-opened-needs-human';
108
+ }
109
+ else if (result.attempted) {
110
+ outcome = 'attempted-no-pr';
111
+ }
112
+ else {
113
+ outcome = 'not-attempted';
114
+ }
115
+ span.setAttributes({
116
+ 'fix.attempted': result.attempted,
117
+ 'fix.repository': result.repository,
118
+ 'fix.pr_created': prCreated,
119
+ ...(prNumber !== undefined ? { 'fix.pr_number': prNumber } : {}),
120
+ 'fix.outcome': outcome,
121
+ });
122
+ // A real attempt that did not deliver a ready PR is a failure
123
+ // (including pr-opened-needs-human — someone must take over);
124
+ // a legitimate skip or a dry-run is not. withPhase does not
125
+ // force OK on a normal return, so this ERROR survives to
126
+ // phase.end.
127
+ if (result.attempted && !prCreated && !isDryRunSkip && 'reason' in result) {
128
+ span.setStatus({ code: SpanStatusCode.ERROR, message: result.reason });
129
+ }
130
+ return result;
131
+ }
132
+ const target = resolveFixTarget(input);
133
+ if (!target.ok) {
134
+ return finish({
135
+ attempted: false,
136
+ repository: target.repository,
137
+ reason: target.reason,
138
+ });
139
+ }
140
+ const targetRepo = target.targetRepo;
141
+ let repoConfig;
142
+ try {
143
+ repoConfig = await input.loadRepoConfig(targetRepo.repository);
144
+ }
145
+ catch (error) {
146
+ logger.warn('Fix loop config load failed', {
147
+ task: 'fix-loop',
148
+ repository: targetRepo.repository.fullName,
149
+ error: errorReason(error),
150
+ });
151
+ return finish({
152
+ attempted: true,
153
+ repository: targetRepo.repository.fullName,
154
+ prCreated: false,
155
+ reason: `Fix loop config load failed: ${errorReason(error)}`,
156
+ });
157
+ }
158
+ const autoFixMode = getAutoFixMode(repoConfig, input.ctx.enableAutoFixOverride);
159
+ if (autoFixMode === 'disabled') {
160
+ return finish({
161
+ attempted: false,
162
+ repository: targetRepo.repository.fullName,
163
+ reason: 'Auto fix disabled by repository config.',
164
+ });
165
+ }
166
+ if (autoFixMode === 'one-pass') {
167
+ return finish(await attemptFix(toAttemptFixInput(input)));
168
+ }
169
+ if (!shouldAttemptFix(input.fixAssessment)) {
170
+ return finish({
171
+ attempted: false,
172
+ repository: targetRepo.repository.fullName,
173
+ reason: buildFixAssessmentSkipReason(input.fixAssessment),
174
+ });
175
+ }
176
+ const session = {
177
+ kind: 'fix-loop',
178
+ ctx: input.ctx,
179
+ octokit: input.octokit,
180
+ triageOutput: input.triageOutput,
181
+ fixAssessment: input.fixAssessment,
182
+ targetRepo,
183
+ issueTitle: input.issueTitle,
184
+ issueUrl: input.issueUrl,
185
+ ghToken: input.ghToken,
186
+ pushToken: input.ctx.githubToken,
187
+ apiKeys: {
188
+ openai: input.ctx.openaiApiKey,
189
+ ...(input.ctx.geminiApiKey !== undefined && { gemini: input.ctx.geminiApiKey }),
190
+ },
154
191
  };
155
- }
156
- if (finalState.kind === 'PUSHED_AWAITING_CI') {
157
- if (input.ctx.dryRun) {
158
- return {
192
+ let finalState;
193
+ try {
194
+ finalState = await runFixLoop({
195
+ effects: new DefaultLoopEffects(session, { dryRun: input.ctx.dryRun }),
196
+ ...(input.ctx.dryRun ? { onTransition: logDryRunTransition } : {}),
197
+ });
198
+ }
199
+ catch (error) {
200
+ return finish({
159
201
  attempted: true,
160
202
  repository: targetRepo.repository.fullName,
161
203
  prCreated: false,
162
- reason: 'Dry run: PR not created.',
163
- };
204
+ reason: `${FIX_LOOP_FAILED_REASON_PREFIX} ${errorReason(error)}`,
205
+ });
164
206
  }
165
- const prNumber = finalState.prNumber;
166
- const prUrl = prUrlFor(targetRepo, prNumber);
167
- return {
207
+ if (finalState.kind === 'PUSHED_AWAITING_CI') {
208
+ if (input.ctx.dryRun) {
209
+ return finish({
210
+ attempted: true,
211
+ repository: targetRepo.repository.fullName,
212
+ prCreated: false,
213
+ reason: 'Dry run: PR not created.',
214
+ dryRun: true,
215
+ });
216
+ }
217
+ const prNumber = finalState.prNumber;
218
+ const prUrl = prUrlFor(targetRepo, prNumber);
219
+ return finish({
220
+ attempted: true,
221
+ repository: targetRepo.repository.fullName,
222
+ prCreated: true,
223
+ prUrl,
224
+ prNumber,
225
+ });
226
+ }
227
+ const reason = finalState.kind === 'DONE_NEEDS_HUMAN'
228
+ ? finalState.reason
229
+ : `Fix loop ended in ${finalState.kind}`;
230
+ return finish({
168
231
  attempted: true,
169
232
  repository: targetRepo.repository.fullName,
170
- prCreated: true,
171
- prUrl,
172
- prNumber,
173
- };
174
- }
175
- const reason = finalState.kind === 'DONE_NEEDS_HUMAN'
176
- ? finalState.reason
177
- : `Fix loop ended in ${finalState.kind}`;
178
- return {
179
- attempted: true,
180
- repository: targetRepo.repository.fullName,
181
- prCreated: false,
182
- reason,
183
- // If a PR was opened earlier in the loop (REVIEWING/ITERATING reached
184
- // before DONE_NEEDS_HUMAN), surface it so callers can distinguish a
185
- // silent failure from an in-progress PR.
186
- ...('prNumber' in finalState && finalState.prNumber !== undefined
187
- ? { prNumber: finalState.prNumber }
188
- : {}),
189
- };
233
+ prCreated: false,
234
+ reason,
235
+ // If a PR was opened earlier in the loop (REVIEWING/ITERATING reached
236
+ // before DONE_NEEDS_HUMAN), surface it so callers can distinguish a
237
+ // silent failure from an in-progress PR.
238
+ ...('prNumber' in finalState && finalState.prNumber !== undefined
239
+ ? { prNumber: finalState.prNumber }
240
+ : {}),
241
+ });
242
+ });
190
243
  }
@@ -1,3 +1,4 @@
1
+ import { iterationLabel, PROMOTION_COMMENT_MARKER } from 'doistbot-shared-contracts';
1
2
  import { logger } from '../../../core/logging.js';
2
3
  import { isFixLoopTerminal, isTerminal, next, } from './state-machine.js';
3
4
  const CI_FAILED_LABEL = 'doistbot:ci-failed';
@@ -6,10 +7,13 @@ const AWAITING_CI_LABEL = 'doistbot:awaiting-ci';
6
7
  // Stamped on terminal comments so postIssueComment can find-or-update an
7
8
  // existing comment instead of double-posting when an earlier dispatch
8
9
  // succeeded on GitHub but the response timed out.
9
- const PROMOTION_COMMENT_MARKER = '<!--doistbot-promotion-comment-->';
10
- function iterationLabel(iteration) {
11
- return `doistbot:iter-${iteration}`;
12
- }
10
+ //
11
+ // The success and human-escalation paths use DISTINCT markers: merge tracking
12
+ // reads 👍/👎 reactions on the promotion (success) comment to derive the thumbs
13
+ // metric, so it must be uniquely identifiable. A shared marker would let a
14
+ // CI-failed-then-human-merged bot PR expose its failure comment as the reaction
15
+ // target instead.
16
+ const PROMOTION_NEEDS_HUMAN_COMMENT_MARKER = '<!--doistbot-promotion-needs-human-comment-->';
13
17
  function formatFailedChecks(checks) {
14
18
  return checks.map((c) => (c.url ? `- [${c.name}](${c.url})` : `- ${c.name}`)).join('\n');
15
19
  }
@@ -176,7 +180,12 @@ export async function runPromotionFlow(effects, input) {
176
180
  await effects.postIssueComment({
177
181
  prNumber: input.prNumber,
178
182
  marker: PROMOTION_COMMENT_MARKER,
179
- body: 'CI passed — promoting this PR out of draft and requesting hero review.',
183
+ // This comment doubles as the thumbs-feedback prompt: merge
184
+ // tracking reads 👍/👎 reactions on this exact comment (located
185
+ // via the marker above) when the PR merges. Keep the reaction
186
+ // ask on the comment people are invited to react to.
187
+ body: 'CI passed — promoting this PR out of draft and requesting hero review.\n\n' +
188
+ '<sub>🤖🫵 Please, react 👍 or 👎 on this comment to rate this bot-authored PR.</sub>',
180
189
  });
181
190
  await effects.promoteToReady(input.prNumber);
182
191
  return state;
@@ -193,7 +202,7 @@ export async function runPromotionFlow(effects, input) {
193
202
  : 'This PR needs human review.';
194
203
  await effects.postIssueComment({
195
204
  prNumber: input.prNumber,
196
- marker: PROMOTION_COMMENT_MARKER,
205
+ marker: PROMOTION_NEEDS_HUMAN_COMMENT_MARKER,
197
206
  body,
198
207
  });
199
208
  return state;
@@ -42,8 +42,10 @@ const TEAM_LABEL_TO_HERO = {
42
42
  const SURFACE_TO_HERO = {
43
43
  android: 'android-hero',
44
44
  backend: 'backend-hero',
45
+ cli: 'frontend-hero',
45
46
  desktop: 'desktop-hero',
46
47
  ios: 'apple-hero',
48
+ mcp: 'frontend-hero',
47
49
  web: 'frontend-hero',
48
50
  };
49
51
  export const CX_HERO_MANUAL_HANDLE = HERO_GROUPS['cx-hero'].mention;
@@ -1,39 +1,123 @@
1
- import { invokePiPrompt } from '../../core/pi.js';
1
+ import { SpanStatusCode } from '@opentelemetry/api';
2
+ import { DEFAULT_PI_MODELS, invokePiPrompt } from '../../core/pi.js';
3
+ import { recordEvent, startToolSpan, withPhase } from '../../core/tracing.js';
2
4
  const ISSUE_TRIAGE_TIMEOUT_MS = 12 * 60 * 1000;
3
- export async function invokeTriageModel({ prompt, apiKey, cwd, timeoutMs = ISSUE_TRIAGE_TIMEOUT_MS, extraEnv, thinkingLevel, onProgress, }) {
4
- const startedAt = Date.now();
5
- const result = await invokePiPrompt({
6
- provider: 'openai',
7
- apiKey,
8
- prompt,
9
- cwd,
10
- timeoutMs,
11
- extraEnv,
12
- capabilityPreset: 'shell-focused',
13
- ...(thinkingLevel ? { thinkingLevel } : {}),
14
- ...(onProgress ? { onProgress } : {}),
5
+ const TRIAGE_PROVIDER = 'openai';
6
+ export async function invokeTriageModel({ prompt, apiKey, cwd, timeoutMs = ISSUE_TRIAGE_TIMEOUT_MS, extraEnv, thinkingLevel, modelName = DEFAULT_PI_MODELS[TRIAGE_PROVIDER],
7
+ // Callers on other paths (e.g. fix generation) pass their own phase name
8
+ // so model telemetry stays separable per flow.
9
+ phaseName = 'triage.model', }) {
10
+ return withPhase(phaseName, {
11
+ 'gen_ai.operation.name': 'chat',
12
+ 'gen_ai.provider.name': TRIAGE_PROVIDER,
13
+ 'gen_ai.request.model': modelName,
14
+ }, async (span) => {
15
+ // pi emits tool start/end across separate onProgress callbacks, so
16
+ // tool spans are managed manually (the documented exception to
17
+ // "withPhase everywhere"). startToolSpan captures context.active()
18
+ // at call time, parenting each tool span under THIS model span.
19
+ const inFlightTools = new Map();
20
+ function toolKey(event) {
21
+ return event.toolCallId ?? event.toolName ?? '';
22
+ }
23
+ function onProgress(event) {
24
+ switch (event.type) {
25
+ case 'tool_start': {
26
+ const toolCallId = event.toolCallId;
27
+ const key = toolKey(event);
28
+ // Overlapping same-name tools without a toolCallId
29
+ // collide on the key; close the displaced span so it
30
+ // is never leaked un-ended.
31
+ const displaced = inFlightTools.get(key);
32
+ if (displaced) {
33
+ displaced.setStatus({
34
+ code: SpanStatusCode.ERROR,
35
+ message: 'replaced by overlapping same-name tool without toolCallId',
36
+ });
37
+ displaced.end();
38
+ }
39
+ const toolSpan = startToolSpan(event.toolName, {
40
+ 'gen_ai.operation.name': 'execute_tool',
41
+ 'gen_ai.tool.name': event.toolName,
42
+ ...(toolCallId ? { 'gen_ai.tool.call.id': toolCallId } : {}),
43
+ });
44
+ inFlightTools.set(key, toolSpan);
45
+ return;
46
+ }
47
+ case 'tool_end': {
48
+ const key = toolKey(event);
49
+ const toolSpan = inFlightTools.get(key);
50
+ if (toolSpan) {
51
+ if (event.isError) {
52
+ toolSpan.setStatus({
53
+ code: SpanStatusCode.ERROR,
54
+ message: 'tool reported error',
55
+ });
56
+ }
57
+ toolSpan.end();
58
+ inFlightTools.delete(key);
59
+ }
60
+ return;
61
+ }
62
+ case 'thinking':
63
+ case 'responding': {
64
+ // Liveness signal for long model phases: pi already
65
+ // throttles these to one per 30s. Emitted as point-in-time
66
+ // events (correlated to this model span), NOT spans —
67
+ // they are heartbeats, not boundaries.
68
+ recordEvent('model.heartbeat', { state: event.type });
69
+ }
70
+ // agent_start/agent_end duplicate this phase span's own
71
+ // boundaries and tool_update is covered by the open tool
72
+ // span — intentionally no emission for those.
73
+ }
74
+ }
75
+ const startedAt = Date.now();
76
+ const result = await invokePiPrompt({
77
+ provider: TRIAGE_PROVIDER,
78
+ apiKey,
79
+ prompt,
80
+ cwd,
81
+ timeoutMs,
82
+ extraEnv,
83
+ capabilityPreset: 'shell-focused',
84
+ ...(thinkingLevel ? { thinkingLevel } : {}),
85
+ ...(modelName ? { modelName } : {}),
86
+ onProgress,
87
+ });
88
+ // Force-end any tool spans pi never closed (defensive — pi's stream
89
+ // is ordered, but if a tool_end was lost we don't want hanging spans).
90
+ for (const [, toolSpan] of inFlightTools) {
91
+ toolSpan.setStatus({
92
+ code: SpanStatusCode.ERROR,
93
+ message: 'tool span did not receive matching end',
94
+ });
95
+ toolSpan.end();
96
+ }
97
+ inFlightTools.clear();
98
+ const durationMs = Date.now() - startedAt;
99
+ if (!result.ok) {
100
+ // Mark the model phase as failed. withPhase does NOT force OK on
101
+ // success, so this ERROR status survives to the phase.end line.
102
+ span.setStatus({ code: SpanStatusCode.ERROR, message: result.error });
103
+ return {
104
+ ok: false,
105
+ error: result.error,
106
+ durationMs,
107
+ timedOut: result.timedOut,
108
+ };
109
+ }
110
+ const output = result.output.trim();
111
+ if (!output) {
112
+ const message = 'Pi triage model did not produce an assistant response';
113
+ span.setStatus({ code: SpanStatusCode.ERROR, message });
114
+ return {
115
+ ok: false,
116
+ error: message,
117
+ durationMs,
118
+ timedOut: false,
119
+ };
120
+ }
121
+ return { ok: true, output, durationMs };
15
122
  });
16
- const durationMs = Date.now() - startedAt;
17
- if (!result.ok) {
18
- return {
19
- ok: false,
20
- error: result.error,
21
- durationMs,
22
- timedOut: result.timedOut,
23
- };
24
- }
25
- const output = result.output.trim();
26
- if (!output) {
27
- return {
28
- ok: false,
29
- error: 'Pi triage model did not produce an assistant response',
30
- durationMs,
31
- timedOut: false,
32
- };
33
- }
34
- return {
35
- ok: true,
36
- output,
37
- durationMs,
38
- };
39
123
  }
@@ -1,4 +1,6 @@
1
+ import { SpanStatusCode } from '@opentelemetry/api';
1
2
  import { fillPromptTemplate } from '../../core/prompt-template.js';
3
+ import { withPhase } from '../../core/tracing.js';
2
4
  const REQUIRED_SECTIONS = [
3
5
  'Summary',
4
6
  'Findings',
@@ -121,3 +123,28 @@ export function parseFixAssessment(triageOutput) {
121
123
  };
122
124
  }
123
125
  export { REQUIRED_HEADINGS };
126
+ export function validateTriageOutput(params) {
127
+ return withPhase('triage.output', { 'output.length': params.rawOutput.length }, async (span) => {
128
+ const normalized = ensureStructuredTriageOutput(params.rawOutput);
129
+ span.setAttributes({
130
+ 'output.missing_sections.count': normalized.missingSections.length,
131
+ 'output.quality_check.passed': normalized.missingSections.length === 0,
132
+ });
133
+ if (normalized.missingSections.length > 0) {
134
+ // Mirrors the failure path in current triage.ts (missingSections > 0).
135
+ const error = `Triage model returned incomplete output. Missing sections: ${normalized.missingSections.join(', ')}`;
136
+ // ensureStructuredTriageOutput does not throw; mark the span ERROR
137
+ // explicitly. withPhase does not force OK on a normal return, so this
138
+ // survives to phase.end.
139
+ span.setStatus({ code: SpanStatusCode.ERROR, message: error });
140
+ return { ok: false, error };
141
+ }
142
+ const assessment = parseFixAssessment(normalized.output);
143
+ return {
144
+ ok: true,
145
+ commentOutput: normalized.commentOutput,
146
+ output: normalized.output,
147
+ assessment,
148
+ };
149
+ });
150
+ }
@@ -1,8 +1,9 @@
1
1
  import { execFileSync } from 'child_process';
2
2
  import fs from 'fs';
3
3
  import path from 'path';
4
+ import { DOISTBOT_AUTO_FIX_LABEL, DOISTBOT_SPECULATIVE_FIX_LABEL } from 'doistbot-shared-contracts';
4
5
  import { logger } from '../../core/logging.js';
5
- import { fillPromptTemplate, loadPromptTemplateFile } from '../../core/prompt-template.js';
6
+ import { fillPromptTemplate, loadPromptTemplateFile, resolvePromptFile, } from '../../core/prompt-template.js';
6
7
  const PR_TEMPLATE_PATHS = [
7
8
  '.github/pull_request_template.md',
8
9
  '.github/PULL_REQUEST_TEMPLATE.md',
@@ -195,7 +196,7 @@ function getConfidenceQualifier(classification, confidence) {
195
196
  const PR_BODY_TEMPLATE_FILE = 'auto-fix-pr-body-template.md';
196
197
  function buildPRBody({ issueNumber, issueUrl, triageAnalysis, fixDescription, fixAssessment, }) {
197
198
  const template = loadPromptTemplateFile({
198
- fileName: PR_BODY_TEMPLATE_FILE,
199
+ fileName: resolvePromptFile(import.meta.url, PR_BODY_TEMPLATE_FILE),
199
200
  promptLabel: 'auto-fix PR body',
200
201
  });
201
202
  let confidenceLine = '';
@@ -299,8 +300,8 @@ export async function openDraftFixPR(input) {
299
300
  });
300
301
  try {
301
302
  const prLabel = input.fixAssessment?.classification === 'speculative'
302
- ? 'doistbot-speculative-fix'
303
- : 'doistbot-auto-fix';
303
+ ? DOISTBOT_SPECULATIVE_FIX_LABEL
304
+ : DOISTBOT_AUTO_FIX_LABEL;
304
305
  await input.octokit.rest.issues.addLabels({
305
306
  owner: input.repoOwner,
306
307
  repo: input.repoName,
@@ -1,8 +1,8 @@
1
- import { fillPromptTemplate, loadPromptTemplateFile } from '../../core/prompt-template.js';
1
+ import { fillPromptTemplate, loadPromptTemplateFile, resolvePromptFile, } from '../../core/prompt-template.js';
2
2
  const PROMPT_FILE = 'issue-triage-prompt.md';
3
3
  export function loadPromptTemplate() {
4
4
  return loadPromptTemplateFile({
5
- fileName: PROMPT_FILE,
5
+ fileName: resolvePromptFile(import.meta.url, PROMPT_FILE),
6
6
  promptLabel: 'issue triage',
7
7
  });
8
8
  }
@@ -0,0 +1,26 @@
1
+ import { resolveIssueRepositories, } from '../../core/repository-resolver.js';
2
+ import { recordDecision, withPhase } from '../../core/tracing.js';
3
+ export async function resolveTriageScope(params) {
4
+ return withPhase('triage.resolution', { 'labels.count': params.labelNames.length }, async (span) => {
5
+ const result = resolveIssueRepositories(params.labelNames);
6
+ recordDecision('triage.resolution.outcome', {
7
+ trigger: params.triggerType,
8
+ status: result.status,
9
+ confidence: result.confidence,
10
+ reason: result.reason,
11
+ decision_path: result.decisionPath,
12
+ parsed_products: result.parsedLabels.productLabels,
13
+ parsed_apps: result.parsedLabels.appLabels,
14
+ parsed_teams: result.parsedLabels.teamLabels,
15
+ unknown_product_labels: result.parsedLabels.unknownProductLabels,
16
+ unknown_app_labels: result.parsedLabels.unknownAppLabels,
17
+ unknown_team_labels: result.parsedLabels.unknownTeamLabels,
18
+ });
19
+ span.setAttributes({
20
+ 'resolution.status': result.status,
21
+ 'resolution.confidence': result.confidence,
22
+ 'resolution.repos.count': result.status === 'resolved' ? result.repositories.length : 0,
23
+ });
24
+ return result;
25
+ });
26
+ }
@@ -1,4 +1,4 @@
1
- import { logger } from '../../core/logging.js';
1
+ import { withPhase } from '../../core/tracing.js';
2
2
  import { shouldAttemptFix } from './fix-attempt.js';
3
3
  import { CX_HERO_MANUAL_HANDLE, resolveHeroGroup } from './hero-group-map.js';
4
4
  import { postIssueComment } from './side-effects.js';
@@ -61,16 +61,16 @@ export function shouldPostHeroRoutingComment(fixAssessment) {
61
61
  return !shouldAttemptFix(fixAssessment);
62
62
  }
63
63
  export async function postHeroRoutingComment({ octokit, ctx, body, }) {
64
- logger.info('Issue triage routing comment post starting', {
65
- task: 'issue-triage',
66
- issueNumber: ctx.issueNumber,
67
- bodyChars: body.length,
68
- });
69
64
  await postIssueComment(ctx, octokit, body, {
70
65
  marker: HERO_ROUTING_MARKER,
71
66
  });
72
- logger.info('Issue triage routing comment post succeeded', {
73
- task: 'issue-triage',
74
- issueNumber: ctx.issueNumber,
67
+ }
68
+ export async function postTriageRouting(params) {
69
+ await withPhase('triage.routing', { 'comment.body.length': params.body.length }, async () => {
70
+ await postHeroRoutingComment({
71
+ octokit: params.octokit,
72
+ ctx: params.ctx,
73
+ body: params.body,
74
+ });
75
75
  });
76
76
  }
@@ -1,4 +1,5 @@
1
1
  import { logger } from '../../core/logging.js';
2
+ import { withPhase } from '../../core/tracing.js';
2
3
  import { TRIAGE_MARKER } from './shared.js';
3
4
  export async function postIssueComment(ctx, octokit, body, options = {}) {
4
5
  const marker = options.marker;
@@ -44,6 +45,14 @@ export async function postIssueComment(ctx, octokit, body, options = {}) {
44
45
  });
45
46
  }
46
47
  }
48
+ export async function postTriageOutput(params) {
49
+ await withPhase('triage.side-effects', { 'comment.body.length': params.body.length }, async () => {
50
+ await postIssueComment(params.ctx, params.octokit, params.body, {
51
+ marker: TRIAGE_MARKER,
52
+ placeholderCommentId: params.ctx.placeholderCommentId,
53
+ });
54
+ });
55
+ }
47
56
  export async function deleteIssueComment(ctx, octokit, commentId, options = {}) {
48
57
  if (ctx.dryRun) {
49
58
  logger.info('Issue triage dry run side effect skipped', {