@ddtcorex/dsh-maestro-review 0.3.2 → 0.5.0

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 (83) hide show
  1. package/README.md +12 -0
  2. package/cordis.patch.yml +18 -0
  3. package/lib/ci-clone.d.ts +21 -0
  4. package/lib/ci-clone.d.ts.map +1 -0
  5. package/lib/ci-clone.js +35 -0
  6. package/lib/ci-clone.js.map +1 -0
  7. package/lib/ci-coexist.d.ts +9 -0
  8. package/lib/ci-coexist.d.ts.map +1 -0
  9. package/lib/ci-coexist.js +36 -0
  10. package/lib/ci-coexist.js.map +1 -0
  11. package/lib/config-store.d.ts +13 -6
  12. package/lib/config-store.d.ts.map +1 -1
  13. package/lib/config-store.js.map +1 -1
  14. package/lib/events.d.ts +23 -0
  15. package/lib/events.d.ts.map +1 -1
  16. package/lib/gitlab-auth.d.ts +11 -0
  17. package/lib/gitlab-auth.d.ts.map +1 -0
  18. package/lib/gitlab-auth.js +15 -0
  19. package/lib/gitlab-auth.js.map +1 -0
  20. package/lib/gitlab-client.d.ts +57 -0
  21. package/lib/gitlab-client.d.ts.map +1 -1
  22. package/lib/gitlab-client.js +120 -17
  23. package/lib/gitlab-client.js.map +1 -1
  24. package/lib/govard-audit-lint-tool.d.ts +81 -0
  25. package/lib/govard-audit-lint-tool.d.ts.map +1 -1
  26. package/lib/govard-audit-lint-tool.js +121 -36
  27. package/lib/govard-audit-lint-tool.js.map +1 -1
  28. package/lib/govard-tool.js +1 -1
  29. package/lib/govard-tool.js.map +1 -1
  30. package/lib/incremental.d.ts.map +1 -1
  31. package/lib/incremental.js +3 -2
  32. package/lib/incremental.js.map +1 -1
  33. package/lib/orchestrator.d.ts +137 -5
  34. package/lib/orchestrator.d.ts.map +1 -1
  35. package/lib/orchestrator.js +439 -70
  36. package/lib/orchestrator.js.map +1 -1
  37. package/lib/providers/ci-trigger.d.ts +43 -0
  38. package/lib/providers/ci-trigger.d.ts.map +1 -0
  39. package/lib/providers/ci-trigger.js +146 -0
  40. package/lib/providers/ci-trigger.js.map +1 -0
  41. package/lib/providers/gitlab.d.ts.map +1 -1
  42. package/lib/providers/gitlab.js +2 -1
  43. package/lib/providers/gitlab.js.map +1 -1
  44. package/lib/review-findings-tool.d.ts +5 -0
  45. package/lib/review-findings-tool.d.ts.map +1 -1
  46. package/lib/review-findings-tool.js +3 -1
  47. package/lib/review-findings-tool.js.map +1 -1
  48. package/lib/review-intake.d.ts.map +1 -1
  49. package/lib/review-intake.js +5 -1
  50. package/lib/review-intake.js.map +1 -1
  51. package/lib/review-marker.d.ts +17 -0
  52. package/lib/review-marker.d.ts.map +1 -0
  53. package/lib/review-marker.js +23 -0
  54. package/lib/review-marker.js.map +1 -0
  55. package/lib/review-signals.d.ts.map +1 -1
  56. package/lib/review-signals.js +4 -8
  57. package/lib/review-signals.js.map +1 -1
  58. package/lib/settings-rpc.js +1 -1
  59. package/lib/settings-rpc.js.map +1 -1
  60. package/package.json +14 -9
  61. package/profiles/reviewer-ci/cordis.patch.yml +61 -0
  62. package/profiles/reviewer-ci/package.json +19 -0
  63. package/profiles/reviewer-ci/pnpm-lock.yaml +62 -0
  64. package/profiles/reviewer-ci/pnpm-workspace.yaml +12 -0
  65. package/src/host/ci-clone.ts +54 -0
  66. package/src/host/ci-coexist.ts +43 -0
  67. package/src/host/config-store.ts +14 -1
  68. package/src/host/events.ts +25 -0
  69. package/src/host/gitlab-auth.ts +13 -0
  70. package/src/host/gitlab-client.ts +142 -17
  71. package/src/host/govard-audit-lint-tool.ts +144 -31
  72. package/src/host/govard-tool.ts +1 -1
  73. package/src/host/incremental.ts +3 -2
  74. package/src/host/orchestrator.ts +462 -50
  75. package/src/host/providers/ci-trigger.ts +164 -0
  76. package/src/host/providers/gitlab.ts +2 -1
  77. package/src/host/review-findings-tool.ts +9 -1
  78. package/src/host/review-intake.ts +5 -1
  79. package/src/host/review-marker.ts +25 -0
  80. package/src/host/review-signals.ts +4 -3
  81. package/src/host/settings-rpc.ts +1 -1
  82. package/templates/reviewer-project.gitlab-ci.yml +90 -0
  83. package/templates/source-project.gitlab-ci.yml +42 -0
@@ -1,4 +1,4 @@
1
- import { mkdir, writeFile } from 'node:fs/promises'
1
+ import { mkdir, writeFile, symlink, stat, lstat } from 'node:fs/promises'
2
2
  import { execFile } from 'node:child_process'
3
3
  import { createHash } from 'node:crypto'
4
4
  import { promisify } from 'node:util'
@@ -29,9 +29,9 @@ import * as ScopeSplitTool from './scope-split-tool.js'
29
29
  import * as GovardAuditLintTool from './govard-audit-lint-tool.js'
30
30
  import * as PerfLogStatsTool from './perf-log-stats-tool.js'
31
31
  import * as ReviewToolPolicy from './tool-policy.js'
32
- import type { ReviewFinding } from './review-findings-tool.js'
33
- import type { ReviewRequest } from './events.js'
34
- import { loadUserConfig, type MaestroUserConfig, type ReviewModelSelection } from './config-store.js'
32
+ import type { ReviewFinding, FindingSeverity } from './review-findings-tool.js'
33
+ import type { ReviewRequest, ReviewResult } from './events.js'
34
+ import { loadUserConfig, type MaestroUserConfig, type ProjectMapping, type ReviewModelSelection } from './config-store.js'
35
35
  import { hasCompletedReview, lastCompletedReview, pruneHistory, recordReviewFinish, recordReviewStart } from './review-history.js'
36
36
  import { buildIncrementalBlock, fetchCompare, fetchMrDetailHeadSha } from './incremental.js'
37
37
  import { createReviewSignals } from './review-signals.js'
@@ -40,6 +40,9 @@ import { loadedReviewProfile, type ReviewSkillProfile } from './skills-tool.js'
40
40
  import type { ReviewProvider } from './providers/interface.js'
41
41
  import { gitlabProvider } from './providers/gitlab.js'
42
42
  import './events.js'
43
+ import { gitlabAuthHeaders } from './gitlab-auth.js'
44
+ import { reviewMarker, resolveFlow, type ReviewFlow } from './review-marker.js'
45
+ import { cloneSourceRepo, defaultRun } from './ci-clone.js'
43
46
 
44
47
  // Provider-aware wrapper — orchestrator can run reviews via any ReviewProvider.
45
48
  // This keeps the GitLab-specific flow intact while allowing Phase C to add GitHub/Jira without modifying core logic.
@@ -58,6 +61,62 @@ export { gitlabProvider }
58
61
  const execFileAsync = promisify(execFile)
59
62
  const GIT_TIMEOUT_MS = 60_000
60
63
 
64
+ /**
65
+ * Finding severity, assigned by the reviewer. Display order is fixed
66
+ * (blocking first) wherever counts are rendered.
67
+ */
68
+ export const SEVERITY_ORDER: readonly FindingSeverity[] = ['blocking', 'major', 'minor', 'nit'] as const
69
+
70
+ const SEVERITY_LABEL: Record<FindingSeverity, string> = {
71
+ blocking: '🔴 Blocking',
72
+ major: '🟡 Major',
73
+ minor: '🔵 Minor',
74
+ nit: '⚪ Nit',
75
+ }
76
+
77
+ /** Missing or unknown severities degrade to `minor` — never drop a finding. */
78
+ export function normalizeFindingSeverity(severity: unknown): FindingSeverity {
79
+ return severity === 'blocking' || severity === 'major' || severity === 'minor' || severity === 'nit'
80
+ ? severity
81
+ : 'minor'
82
+ }
83
+
84
+ export function severityPrefix(severity: unknown): string {
85
+ return SEVERITY_LABEL[normalizeFindingSeverity(severity)]
86
+ }
87
+
88
+ /** Count findings per severity; zero counts are omitted. */
89
+ export function countFindingSeverities(findings: ReviewFinding[]): Partial<Record<FindingSeverity, number>> {
90
+ const counts: Partial<Record<FindingSeverity, number>> = {}
91
+ for (const finding of findings) {
92
+ const level = normalizeFindingSeverity(finding.severity)
93
+ counts[level] = (counts[level] ?? 0) + 1
94
+ }
95
+ return counts
96
+ }
97
+
98
+ export interface ReviewerScopePromptOpts {
99
+ scopeKind: 'discussion' | 'full'
100
+ discussionId?: string
101
+ path?: string
102
+ line?: number
103
+ mode?: string
104
+ profileInstruction: string
105
+ }
106
+
107
+ /**
108
+ * Reviewer scope prompt. Static analysis is mandatory: reviewers skipped
109
+ * govard_audit_lint for whole rounds (no lint signal at all), so the prompt
110
+ * requires at least one call before report_review_findings. Pure for testing.
111
+ */
112
+ export function buildReviewerScopePrompt(opts: ReviewerScopePromptOpts): string {
113
+ const lintRule = 'LINT RULE: you MUST call govard_audit_lint at least once (scope "diff"; the MR base default is already wired, no base arg needed) before report_review_findings. A review with no lint call is incomplete.'
114
+ if (opts.scopeKind === 'discussion') {
115
+ return `${opts.profileInstruction}Review only the requested inline discussion ${opts.discussionId} at ${opts.path}:${opts.line}. Do not review unrelated files or start a broad audit. Call gitlab_get_mr_diff, then gitlab_get_file_diff for the file under review, then call report_review_findings exactly once when done. ${lintRule}`
116
+ }
117
+ return `${opts.profileInstruction}Review this merge request (${opts.mode} mode). Call gitlab_list_own_review_threads and gitlab_get_mr_diff first, then gitlab_get_file_diff per file you inspect (inline results never spill), then call report_review_findings exactly once when done. ${lintRule} DEDUP RULE: when a finding matches the substance of an existing own thread (same file and same underlying issue, even if worded differently — including resolved threads, whose reply reopens them), report it as {status: "reply", discussionId} instead of posting a new thread. Use status "new" only for issues with no matching thread.`
118
+ }
119
+
61
120
  /**
62
121
  * Build a user-friendly GitLab Markdown comment for a completed review.
63
122
  * Shared design language with `reviewDigestText` (Telegram HTML) — same
@@ -72,10 +131,11 @@ export function buildReviewComment(opts: {
72
131
  profile?: string
73
132
  summary: string
74
133
  failures: string[]
75
- findings?: { newCount: number; replyCount: number }
134
+ findings?: { newCount: number; replyCount: number; severityCounts?: Partial<Record<FindingSeverity, number>> }
76
135
  durationMs?: number
77
136
  isDiffOnly?: boolean
78
137
  isDiscussion?: boolean
138
+ marker?: { sha: string; flow: ReviewFlow }
79
139
  }): string {
80
140
  const mrUrl = `${opts.gitlabBaseUrl.replace(/\/$/, '')}/${opts.projectPath}/-/merge_requests/${opts.mrIid}`
81
141
  const title = opts.isDiffOnly === true ? '## 🤖 Maestro Review — Diff-only' : '## 🤖 Maestro Review'
@@ -97,6 +157,12 @@ export function buildReviewComment(opts: {
97
157
  const parts: string[] = []
98
158
  if (opts.findings.newCount > 0) parts.push(`💬 ${opts.findings.newCount} new`)
99
159
  if (opts.findings.replyCount > 0) parts.push(`🔁 ${opts.findings.replyCount} updated`)
160
+ if (opts.findings.severityCounts !== undefined) {
161
+ for (const level of SEVERITY_ORDER) {
162
+ const count = opts.findings.severityCounts[level] ?? 0
163
+ if (count > 0) parts.push(`${SEVERITY_LABEL[level].split(' ')[0]} ${count} ${level}`)
164
+ }
165
+ }
100
166
  if (parts.length === 0) parts.push('no inline findings')
101
167
  return `\n\n**Findings:** ${parts.join(' · ')}`
102
168
  })()
@@ -105,10 +171,11 @@ export function buildReviewComment(opts: {
105
171
  ? `\n\n<details>\n<summary>⚠️ Failed to post (${opts.failures.length})</summary>\n\n${opts.failures.map((f) => `- \`${f}\``).join('\n')}\n\n</details>`
106
172
  : ''
107
173
  const footer = `\n\n---\n\n<sub>Generated by Maestro · [View MR →](${mrUrl})</sub>`
108
- return `${title}\n\n${metaLine}\n\n${scopeNote}${opts.summary}${findingsLine}${failuresBlock}${footer}`
174
+ const markerLine = opts.marker === undefined ? '' : `\n\n${reviewMarker(opts.marker.sha, opts.marker.flow)}`
175
+ return `${title}\n\n${metaLine}\n\n${scopeNote}${opts.summary}${findingsLine}${failuresBlock}${footer}${markerLine}`
109
176
  }
110
177
 
111
- export function buildNotStartedComment(opts: { gitlabBaseUrl: string; projectPath: string; mrIid: number }): string {
178
+ export function buildNotStartedComment(opts: { gitlabBaseUrl: string; projectPath: string; mrIid: number; marker?: { sha: string; flow: ReviewFlow } }): string {
112
179
  const mrUrl = `${opts.gitlabBaseUrl.replace(/\/$/, '')}/${opts.projectPath}/-/merge_requests/${opts.mrIid}`
113
180
  return [
114
181
  '## 🤖 Maestro Review — Not started',
@@ -121,6 +188,7 @@ export function buildNotStartedComment(opts: { gitlabBaseUrl: string; projectPat
121
188
  `---`,
122
189
  '',
123
190
  `<sub>Generated by Maestro · [View MR →](${mrUrl})</sub>`,
191
+ ...(opts.marker === undefined ? [] : [``, `${reviewMarker(opts.marker.sha, opts.marker.flow)}`]),
124
192
  ].join('\n')
125
193
  }
126
194
 
@@ -168,14 +236,17 @@ export function getTurnErrorMessage(handle: unknown): string | undefined {
168
236
 
169
237
  /**
170
238
  * Resolve the model to use for an automated review. Priority: per-project
171
- * override > global reviewModel > DSH default (`fallback`).
239
+ * override > global reviewModel (Maestro Settings) > row-config reviewModel
240
+ * (lets a headless profile such as reviewer-ci pin a model from env vars,
241
+ * where there is no Settings UI) > DSH default (`fallback`).
172
242
  */
173
243
  export function resolveReviewModel(
174
244
  userConfig: MaestroUserConfig,
175
- mapping: { reviewModel?: ReviewModelSelection | null } & Record<string, unknown> | undefined,
245
+ mapping: { reviewModel?: ReviewModelSelection | null; projectPath?: string } | undefined,
176
246
  fallback: ModelSelection,
247
+ rowSelection?: ReviewModelSelection | null,
177
248
  ): ModelSelection {
178
- const raw = (mapping?.reviewModel as ReviewModelSelection | null | undefined) ?? userConfig.reviewModel
249
+ const raw = (mapping?.reviewModel as ReviewModelSelection | null | undefined) ?? userConfig.reviewModel ?? rowSelection
179
250
  if (raw === undefined || raw === null) return fallback
180
251
  return {
181
252
  provider: raw.provider,
@@ -184,6 +255,17 @@ export function resolveReviewModel(
184
255
  }
185
256
  }
186
257
 
258
+ /** Effective auto-trigger flags: per-project row overrides global, unset inherits (design §4). */
259
+ export function resolveReviewTriggers(
260
+ userConfig: MaestroUserConfig,
261
+ mapping?: ProjectMapping,
262
+ ): { onPush: boolean; onAssign: boolean } {
263
+ return {
264
+ onPush: mapping?.rereviewOnPush ?? userConfig.autoRereviewOnPush ?? false,
265
+ onAssign: mapping?.reviewOnAssign ?? userConfig.autoReviewOnAssign ?? true,
266
+ }
267
+ }
268
+
187
269
  /** Compose a newly-created agent from the preset service owned by the root context. */
188
270
  export async function mountAgentPreset(
189
271
  agentPresets: { mount(agentCtx: Context, id: string): Promise<unknown> | unknown },
@@ -204,6 +286,19 @@ export interface Config {
204
286
  botUsername: string
205
287
  /** Hard ceiling on one automated agent's turn. */
206
288
  agentTimeoutMs: number
289
+ /**
290
+ * Deployment-level model pin, below Maestro Settings in precedence. Lets a
291
+ * headless profile (reviewer-ci) select the review model from env vars —
292
+ * e.g. REVIEW_MODEL_PROVIDER/REVIEW_MODEL — where no Settings UI exists.
293
+ *
294
+ * Deliberately absent from the zod schema below: schemastery object schemas
295
+ * always descend into inner fields (probed — even `.required(false)` still
296
+ * throws on undefined), so declaring it would fail every boot without the
297
+ * vars. Unknown keys pass validation through (same precedent as mapping-level
298
+ * reviewModel, which the projectMappings schema doesn't declare either),
299
+ * and resolveReviewModel reads it at review time.
300
+ */
301
+ reviewModel?: ReviewModelSelection | null
207
302
  }
208
303
 
209
304
  export const DEFAULT_AGENT_TIMEOUT_MS = 20 * 60_000
@@ -223,6 +318,7 @@ export const Config: z<Config> = z.object({
223
318
  export interface ReviewOutcome {
224
319
  summary: string
225
320
  failures: string[]
321
+ severityCounts?: Partial<Record<FindingSeverity, number>>
226
322
  }
227
323
 
228
324
  /**
@@ -371,13 +467,15 @@ function normalizedDiffPath(path: string): string {
371
467
  export async function postReviewFindings(findings: ReviewFinding[], config: GitlabFindingPoster): Promise<void> {
372
468
  const fetcher = config.fetcher ?? fetch
373
469
  const apiBase = `${config.baseUrl}/api/v4/projects/${config.projectId}/merge_requests/${config.mrIid}`
374
- const headers = { 'PRIVATE-TOKEN': config.token, 'Content-Type': 'application/json' }
470
+ const headers = { ...gitlabAuthHeaders(config.token), 'Content-Type': 'application/json' }
375
471
  for (const finding of findings) {
376
472
  let response: Response
473
+ const label = severityPrefix(finding.severity)
474
+ const body = finding.body.startsWith(label) ? finding.body : `${label}\n\n${finding.body}`
377
475
  if (finding.status === 'reply') {
378
476
  if (finding.discussionId === undefined) throw new Error('reply finding missing discussionId')
379
477
  response = await fetcher(`${apiBase}/discussions/${encodeURIComponent(finding.discussionId)}/notes`, {
380
- method: 'POST', headers, body: JSON.stringify({ body: finding.body }),
478
+ method: 'POST', headers, body: JSON.stringify({ body }),
381
479
  })
382
480
  } else {
383
481
  if (finding.path === undefined || finding.line === undefined) throw new Error('new finding missing path or line')
@@ -395,12 +493,12 @@ export async function postReviewFindings(findings: ReviewFinding[], config: Gitl
395
493
  // Line not in diff — fallback to MR note so finding is not silently lost.
396
494
  response = await fetcher(`${apiBase}/notes`, {
397
495
  method: 'POST', headers,
398
- body: JSON.stringify({ body: `**Inline fallback — \`${finding.path}:${finding.line}\` line is not in current MR diff**\n\n${finding.body}` }),
496
+ body: JSON.stringify({ body: `**Inline fallback — \`${finding.path}:${finding.line}\` line is not in current MR diff**\n\n${body}` }),
399
497
  })
400
498
  } else {
401
499
  response = await fetcher(`${apiBase}/discussions`, {
402
500
  method: 'POST', headers,
403
- body: JSON.stringify({ body: finding.body, position: {
501
+ body: JSON.stringify({ body, position: {
404
502
  position_type: 'text', ...snapshot.diffRefs, old_path: change.old_path, new_path: change.new_path,
405
503
  ...linePosition.oldLine === undefined ? {} : { old_line: linePosition.oldLine },
406
504
  ...linePosition.newLine === undefined ? {} : { new_line: linePosition.newLine },
@@ -453,7 +551,91 @@ function assertSafeId(value: number, label: string): void {
453
551
  }
454
552
  }
455
553
 
554
+ /**
555
+ * Read an agent session's transcript for the auditor's final output across
556
+ * host/session API skew: hosts built from the harness checkout expose
557
+ * `ownEvents()`/`snapshotEvents()` with no `.events` getter, while older
558
+ * packaged `@deepseek-ai/dsh-session` builds expose only the `.events`
559
+ * getter. `ownEvents()` (child-owned suffix, no fork prefix) matches
560
+ * `finalAssistantOutput`'s documented input best, so it wins when present.
561
+ * Returns `[]` — never throws — when no event source exists.
562
+ */
563
+ export function auditorOutputFromSession(session: unknown) {
564
+ const candidate = session as {
565
+ ownEvents?: unknown
566
+ snapshotEvents?: unknown
567
+ events?: unknown
568
+ } | null | undefined
569
+ let events: unknown
570
+ if (typeof candidate?.ownEvents === 'function') {
571
+ events = (candidate.ownEvents as () => unknown)()
572
+ } else if (typeof candidate?.snapshotEvents === 'function') {
573
+ events = (candidate.snapshotEvents as () => unknown)()
574
+ } else {
575
+ events = candidate?.events
576
+ }
577
+ if (!Array.isArray(events)) return []
578
+ return finalAssistantOutput(events as Parameters<typeof finalAssistantOutput>[0]) ?? []
579
+ }
580
+
456
581
  /** Full review + performance audit; resolves to the comment body that was posted. */
582
+ /** Marker for CI-posted comments; webhook payloads carry no headSha and stay marker-free. */
583
+ function commentMarker(payload: ReviewRequest): { sha: string; flow: ReviewFlow } | undefined {
584
+ if (payload.headSha === undefined) return undefined
585
+ return { sha: payload.headSha, flow: resolveFlow(payload.mode) }
586
+ }
587
+
588
+ /** CI-deep decision: deep mode + CI env + head SHA (spec §3). Mapping is checked by the caller. */
589
+ export function shouldCiDeepReview(payload: ReviewRequest): boolean {
590
+ return payload.mode === 'deep'
591
+ && payload.headSha !== undefined
592
+ && process.env.SOURCE_PROJECT_ID !== undefined
593
+ && process.env.MR_IID !== undefined
594
+ }
595
+
596
+ /** CI quick-with-profile decision: quick mode + non-generic profile + CI env
597
+ * + head SHA. Quick + generic/unset stays diff-only (no clone cost); a real
598
+ * profile opts into the clone branch reviewer-only (no auditor — quick never
599
+ * audits, see shouldAudit in runReviewAndAudit). Mapping is checked by the caller. */
600
+ export function shouldCiQuickProfileReview(payload: ReviewRequest): boolean {
601
+ return payload.mode === 'quick'
602
+ && payload.reviewProfile !== undefined
603
+ && payload.reviewProfile !== 'generic'
604
+ && payload.headSha !== undefined
605
+ && process.env.SOURCE_PROJECT_ID !== undefined
606
+ && process.env.MR_IID !== undefined
607
+ }
608
+
609
+ /** Auditor degrade for CI (no runtime env): a govard throw becomes reviewer-only, never a failed review. */
610
+ export function withAuditorDegrade(
611
+ runAuditor: (worktreePath: string, payload: ReviewRequest) => Promise<string>,
612
+ ): (worktreePath: string, payload: ReviewRequest) => Promise<string> {
613
+ return async (worktreePath, payload) => {
614
+ try {
615
+ return await runAuditor(worktreePath, payload)
616
+ } catch {
617
+ return 'Auditor skipped — no runtime environment in CI (reviewer-only review).'
618
+ }
619
+ }
620
+ }
621
+
622
+ /**
623
+ * Auditor instruction. The mapped flow keeps the full environment + test-suite
624
+ * workflow; the CI flow has no runtime, so the prompt countermands the
625
+ * auditor preset's environment steps and drops the Environment & Test Suite
626
+ * section entirely instead of reporting it "blocked".
627
+ */
628
+ export function buildAuditorPrompt(opts: { staticOnly: boolean }): string {
629
+ if (!opts.staticOnly) {
630
+ return 'Audit this merge request\'s performance: bring up the environment, run the test suite, look for regressions, then write a Markdown report and tear the environment down.'
631
+ }
632
+ return 'Audit this merge request\'s performance from the static diff and checked-out code only. '
633
+ + 'No runtime environment exists in this container: ignore the auditor preset\'s environment steps '
634
+ + '(do not bring anything up, do not run the test suite, do not tear anything down). '
635
+ + 'Look for regressions by static analysis (diff, dependencies, query/shape risks), then write a Markdown report '
636
+ + 'and OMIT the Environment & Test Suite section entirely — never report it as blocked.'
637
+ }
638
+
457
639
  export async function runReviewAndAudit(payload: ReviewRequest, deps: ReviewAndAuditDeps): Promise<string> {
458
640
  assertSafeId(payload.projectId, 'projectId')
459
641
  assertSafeId(payload.mrIid, 'mrIid')
@@ -471,7 +653,7 @@ export async function runReviewAndAudit(payload: ReviewRequest, deps: ReviewAndA
471
653
  ])
472
654
  const labels = ['Reviewer', 'Auditor']
473
655
  if (settled[0].status === 'fulfilled') {
474
- const { summary, failures } = settled[0].value
656
+ const { summary, failures, severityCounts } = settled[0].value
475
657
  // Try to parse findings counts from summary like "2 new inline comment(s), 1 thread(s) updated."
476
658
  const summaryText = summary ?? ''
477
659
  const newMatch = /(\d+)\s+new inline/.exec(summaryText)
@@ -488,7 +670,8 @@ export async function runReviewAndAudit(payload: ReviewRequest, deps: ReviewAndA
488
670
  profile: (deps as unknown as { reviewProfile?: string }).reviewProfile,
489
671
  summary: summaryText,
490
672
  failures,
491
- findings: { newCount, replyCount },
673
+ findings: { newCount, replyCount, severityCounts },
674
+ marker: commentMarker(payload),
492
675
  })
493
676
  : `## 🤖 Maestro Review\n\n**\`${payload.projectPath}\` !${payload.mrIid}** · ✅ Completed · \`${payload.mode}\`${(deps as unknown as { reviewProfile?: string }).reviewProfile !== undefined ? ` · \`${(deps as unknown as { reviewProfile: string }).reviewProfile}\`` : ''}\n\n${summaryText}${failures.length > 0 ? `\n\n<details>\n<summary>⚠️ Failed to post (${failures.length})</summary>\n\n${failures.map((f) => `- \`${f}\``).join('\n')}\n\n</details>` : ''}`
494
677
  sections.push(richOpts)
@@ -505,7 +688,8 @@ export async function runReviewAndAudit(payload: ReviewRequest, deps: ReviewAndA
505
688
  try {
506
689
  if (payload.scope.kind === 'discussion') await deps.replyToDiscussion(payload.scope.discussionId, body)
507
690
  else await deps.postComment(body)
508
- } catch {
691
+ } catch (err) {
692
+ console.error(`maestro-orchestrator: posting review comment failed: ${err instanceof Error ? err.message : String(err)}`)
509
693
  await deps.writeFailedReport(payload.mrIid, body)
510
694
  }
511
695
  return body
@@ -540,13 +724,15 @@ export async function runDiffOnlyReview(payload: ReviewRequest, deps: DiffOnlyRe
540
724
  summary: summary ?? '',
541
725
  failures,
542
726
  isDiffOnly: true,
727
+ marker: commentMarker(payload),
543
728
  })
544
729
  : `## 🤖 Maestro Review — Diff-only\n\n> **Scope:** Diff-only — reviewed the GitLab diff without a local checkout, Magento environment, static analysis, or tests. For a full review, add this project in Settings → Maestro and mention again.\n\n${summary}${failures.length > 0 ? `\n\n<details>\n<summary>⚠️ Failed to post (${failures.length})</summary>\n\n${failures.map((f) => `- \`${f}\``).join('\n')}\n\n</details>` : ''}`
545
730
  const body = diffBody
546
731
  try {
547
732
  if (payload.scope.kind === 'discussion') await deps.replyToDiscussion(payload.scope.discussionId, body)
548
733
  else await deps.postComment(body)
549
- } catch {
734
+ } catch (err) {
735
+ console.error(`maestro-orchestrator: posting review comment failed: ${err instanceof Error ? err.message : String(err)}`)
550
736
  await deps.writeFailedReport(payload.mrIid, body)
551
737
  }
552
738
  return body
@@ -565,13 +751,14 @@ export async function declineUnmappedDeepReview(payload: ReviewRequest, deps: Re
565
751
  const depsWithUrl = deps as unknown as { gitlabBaseUrl?: string; projectPath?: string }
566
752
  const baseUrl = depsWithUrl.gitlabBaseUrl
567
753
  const body = baseUrl !== undefined
568
- ? buildNotStartedComment({ gitlabBaseUrl: baseUrl, projectPath: payload.projectPath, mrIid: payload.mrIid })
754
+ ? buildNotStartedComment({ gitlabBaseUrl: baseUrl, projectPath: payload.projectPath, mrIid: payload.mrIid, marker: commentMarker(payload) })
569
755
  : '## 🤖 Maestro Review — Not started\n\n**`' + payload.projectPath + '` !' + payload.mrIid + '** · ⏸️ Not started\n\n> Deep review requires a project mapping with a local checkout and Magento environment.\n> Add this project in **Settings → Maestro**, then mention the reviewer again.'
570
756
  try {
571
757
  try {
572
758
  if (payload.scope.kind === 'discussion') await deps.replyToDiscussion(payload.scope.discussionId, body)
573
759
  else await deps.postComment(body)
574
- } catch {
760
+ } catch (err) {
761
+ console.error(`maestro-orchestrator: posting review comment failed: ${err instanceof Error ? err.message : String(err)}`)
575
762
  await deps.writeFailedReport(payload.mrIid, body)
576
763
  }
577
764
  } finally {
@@ -619,7 +806,34 @@ export function isBranchNotFoundError(err: unknown): boolean {
619
806
  */
620
807
  export function govardWorktreeOverride(projectId: number, mrIid: number, keySuffix?: string): string {
621
808
  const name = `maestro-mr-${projectId}-${mrIid}${keySuffix === undefined ? '' : `-${keySuffix}`}`
622
- return `project_name: ${name}\ndomain: ${name}.test\n`
809
+ // Xdebug stays off in review envs: the auditor runs no coverage, and an
810
+ // enabled Xdebug trips govard's lint perf-tax guard (exit 1, 0 findings).
811
+ return `project_name: ${name}\ndomain: ${name}.test\nstack:\n features:\n xdebug: false\n`
812
+ }
813
+
814
+ /**
815
+ * Fetch the MR's diff base SHA for govard diff-scope runs. Best-effort:
816
+ * returns undefined (the lint tool then fail-fasts with guidance) rather
817
+ * than failing the review when GitLab is unreachable.
818
+ */
819
+ export async function fetchMrBaseSha(
820
+ baseUrl: string,
821
+ token: string,
822
+ projectId: number,
823
+ mrIid: number,
824
+ fetcher: typeof fetch = fetch,
825
+ ): Promise<string | undefined> {
826
+ try {
827
+ const response = await fetcher(
828
+ `${baseUrl}/api/v4/projects/${projectId}/merge_requests/${mrIid}`,
829
+ { headers: gitlabAuthHeaders(token) },
830
+ )
831
+ if (!response.ok) return undefined
832
+ const mr = await response.json() as { diff_refs?: { base_sha?: string } }
833
+ return mr.diff_refs?.base_sha
834
+ } catch {
835
+ return undefined
836
+ }
623
837
  }
624
838
 
625
839
  export async function ensureWorktree(localRepoPath: string, sourceBranch: string, projectId: number, mrIid: number, keySuffix?: string): Promise<string> {
@@ -643,9 +857,134 @@ export async function ensureWorktree(localRepoPath: string, sourceBranch: string
643
857
  .catch(() => {})
644
858
  await execFileAsync('git', ['worktree', 'add', '--', worktreePath, `origin/${sourceBranch}`], { cwd: localRepoPath, timeout: GIT_TIMEOUT_MS })
645
859
  await writeFile(join(worktreePath, '.govard.local.yml'), govardWorktreeOverride(projectId, mrIid, keySuffix), 'utf-8')
860
+ await linkVendorIntoWorktree(localRepoPath, worktreePath)
861
+ await writeContainerVendorOverride(localRepoPath, worktreePath)
646
862
  return worktreePath
647
863
  }
648
864
 
865
+ /**
866
+ * A vendor dir only counts as installed dependencies when the composer
867
+ * autoloader is a real file. Magento tracks `vendor/.htaccess`, so every
868
+ * worktree has a vendor/ stub — that stub must not pass for real deps, and
869
+ * must not block the container bind that provides them.
870
+ */
871
+ export async function vendorHasAutoload(dir: string): Promise<boolean> {
872
+ try {
873
+ return (await stat(join(dir, 'vendor', 'autoload.php'))).isFile()
874
+ } catch {
875
+ return false
876
+ }
877
+ }
878
+
879
+ /**
880
+ * Share the primary checkout's `vendor/` (and `app/etc/env.php` when the
881
+ * auditor needs a database) into the review worktree via symlinks, so
882
+ * phpunit/static analysis run against real dependencies instead of falling
883
+ * back to static-only. Links point INTO the worktree only — the primary
884
+ * checkout is never written to — and `git worktree remove --force` deletes
885
+ * the links along with the worktree. Missing sources, or targets that
886
+ * already exist (e.g. Magento's tracked `vendor/.htaccess` stub), are
887
+ * skipped quietly: the review still runs, the auditor discloses it.
888
+ */
889
+ export async function linkVendorIntoWorktree(localRepoPath: string, worktreePath: string): Promise<string[]> {
890
+ const linked: string[] = []
891
+ const candidates: Array<{ source: string; target: string; dir: boolean; needsAutoload: boolean }> = [
892
+ { source: join(localRepoPath, 'vendor'), target: join(worktreePath, 'vendor'), dir: true, needsAutoload: true },
893
+ { source: join(localRepoPath, 'app', 'etc', 'env.php'), target: join(worktreePath, 'app', 'etc', 'env.php'), dir: false, needsAutoload: false },
894
+ ]
895
+ let advertised = false
896
+ for (const { source, target, dir, needsAutoload } of candidates) {
897
+ let isDir = false
898
+ try {
899
+ isDir = (await stat(source)).isDirectory()
900
+ if (dir !== isDir) continue
901
+ } catch {
902
+ continue
903
+ }
904
+ if (needsAutoload && !(await vendorHasAutoload(localRepoPath))) continue
905
+ if ((await lstat(target).catch(() => undefined)) !== undefined) {
906
+ console.error(`maestro-orchestrator: link target exists, skipping ${target}`)
907
+ continue
908
+ }
909
+ try {
910
+ if (!advertised) {
911
+ const sha = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: localRepoPath, timeout: GIT_TIMEOUT_MS })
912
+ .then(({ stdout }) => stdout.trim()).catch(() => 'unknown')
913
+ console.error(`maestro-orchestrator: linking vendor from ${localRepoPath} @ ${sha} into ${worktreePath}`)
914
+ advertised = true
915
+ }
916
+ if (!dir) await mkdir(join(worktreePath, 'app', 'etc'), { recursive: true })
917
+ await symlink(source, target, dir ? 'dir' : 'file')
918
+ linked.push(target)
919
+ } catch (err) {
920
+ console.error(`maestro-orchestrator: failed to link ${source} into worktree:`, err)
921
+ }
922
+ }
923
+ if (!advertised) {
924
+ console.error(`maestro-orchestrator: no vendor/ in ${localRepoPath} — worktree ${worktreePath} runs without shared dependencies`)
925
+ }
926
+ return linked
927
+ }
928
+
929
+ /** Container path of the project root inside govard services. */
930
+ const GOVARD_CONTAINER_WORKDIR = '/var/www/html'
931
+
932
+ /**
933
+ * Render a compose override that bind-mounts the primary checkout's vendor/
934
+ * read-only into the php service. A host-side symlink alone dangles inside
935
+ * containers (absolute host path), so container tools (phpunit) need this
936
+ * bind to see real dependencies.
937
+ *
938
+ * The project mount (`.`) MUST be repeated first: govard merges overrides
939
+ * with MergeMap, which REPLACES lists instead of appending — an override
940
+ * carrying only the vendor bind would wipe `.:<workdir>` and leave the
941
+ * container docroot holding nothing but vendor/. Relative `.` resolves
942
+ * against the project root because govard passes --project-directory.
943
+ *
944
+ * Only the always-present `php` service is targeted: `php-debug` drops out
945
+ * of the rendered base when xdebug is off, and a volumes-only override
946
+ * entry would recreate it as a hollow service that fails compose validation.
947
+ */
948
+ export function buildVendorOverrideYaml(vendorHostPath: string, envHostPath?: string, containerWorkDir: string = GOVARD_CONTAINER_WORKDIR): string {
949
+ const volumes = [
950
+ `.:${containerWorkDir}`,
951
+ `${vendorHostPath}:${containerWorkDir}/vendor:ro`,
952
+ ...(envHostPath !== undefined ? [`${envHostPath}:${containerWorkDir}/app/etc/env.php:ro`] : []),
953
+ ]
954
+ const service = ` volumes:\n${volumes.map((v) => ` - ${v}\n`).join('')}`
955
+ return `services:\n php:\n${service}`
956
+ }
957
+
958
+ /**
959
+ * Drop the vendor bind override into the worktree (merged by govard via
960
+ * `.govard/docker-compose.override.yml`). Binds only when the primary
961
+ * checkout carries installed deps and the worktree does not — never shadow
962
+ * real deps with a possibly stale bind. Returns the written path, or
963
+ * undefined when skipped.
964
+ */
965
+ export async function writeContainerVendorOverride(localRepoPath: string, worktreePath: string): Promise<string | undefined> {
966
+ const vendorHostPath = join(localRepoPath, 'vendor')
967
+ if (!(await vendorHasAutoload(localRepoPath))) return undefined
968
+ // Our own host-side symlink still needs the bind (it dangles in-container);
969
+ // only a real installed worktree vendor makes the bind redundant.
970
+ const wtLink = await lstat(join(worktreePath, 'vendor')).catch(() => undefined)
971
+ if (wtLink?.isSymbolicLink() !== true && await vendorHasAutoload(worktreePath)) return undefined
972
+ const overridePath = join(worktreePath, '.govard', 'docker-compose.override.yml')
973
+ await mkdir(join(worktreePath, '.govard'), { recursive: true })
974
+ // A linked env.php dangles in-container like the vendor symlink did — bind
975
+ // the real file over it when the primary checkout carries one.
976
+ const envHostPath = join(localRepoPath, 'app', 'etc', 'env.php')
977
+ let envBind: string | undefined
978
+ try {
979
+ if ((await stat(envHostPath)).isFile()) envBind = envHostPath
980
+ } catch {
981
+ envBind = undefined
982
+ }
983
+ await writeFile(overridePath, buildVendorOverrideYaml(vendorHostPath, envBind), 'utf-8')
984
+ console.error(`maestro-orchestrator: container vendor bind ${vendorHostPath} -> ${GOVARD_CONTAINER_WORKDIR}/vendor (ro) for ${worktreePath}`)
985
+ return overridePath
986
+ }
987
+
649
988
  async function removeWorktree(worktreePath: string): Promise<void> {
650
989
  // Best-effort cleanup by design (a failure here must not block or fail the review that
651
990
  // already ran) — but a swallowed failure with zero visibility leaves an orphaned worktree
@@ -667,6 +1006,9 @@ export function apply(ctx: Context, config: Config): void {
667
1006
  // effect without a plugin restart.
668
1007
  let effectiveAgentTimeoutMs = config.agentTimeoutMs
669
1008
  async function runReviewer(worktreePath: string | undefined, payload: ReviewRequest, effective: { gitlabBaseUrl: string; gitlabToken: string; botUsername: string }, reviewProfile?: ReviewSkillProfile, modelSelection?: ModelSelection, incrementalBlock?: string): Promise<ReviewOutcome> {
1009
+ // MR base SHA feeds govard diff-scope runs; undefined degrades to the
1010
+ // tool's fail-fast guidance instead of a wasted govard invocation.
1011
+ const lintDefaultBase = await fetchMrBaseSha(effective.gitlabBaseUrl, effective.gitlabToken, payload.projectId, payload.mrIid)
670
1012
  const primaryOptions = agentOptionsForModel(modelSelection ?? ctx.agentDefaultModel.currentSelection())
671
1013
  const fallbackOptions: ModelSelection = { provider: primaryOptions.provider, model: primaryOptions.model }
672
1014
  let lastHandle: AgentHandle | undefined
@@ -701,7 +1043,7 @@ export function apply(ctx: Context, config: Config): void {
701
1043
  await agentCtx.plugin(ModuleCheckTool, { rootPath: worktreePath })
702
1044
  await agentCtx.plugin(PhtmlEscapeScanTool, { rootPath: worktreePath })
703
1045
  await agentCtx.plugin(ScopeSplitTool, { rootPath: worktreePath })
704
- await agentCtx.plugin(GovardAuditLintTool, { rootPath: worktreePath })
1046
+ await agentCtx.plugin(GovardAuditLintTool, { rootPath: worktreePath, defaultBase: lintDefaultBase, allowXdebug: true })
705
1047
  await agentCtx.plugin(PerfLogStatsTool, { rootPath: worktreePath })
706
1048
  }
707
1049
  },
@@ -717,8 +1059,8 @@ export function apply(ctx: Context, config: Config): void {
717
1059
  ? 'This is a diff-only review with no local checkout or Magento environment. Do not claim that tests, static analysis, or Magento runtime validation ran. '
718
1060
  : `Call maestro_load_review_profile with {"profile":"${reviewProfile}"} before examining code. `
719
1061
  let scopePrompt = payload.scope.kind === 'discussion'
720
- ? `${profileInstruction}Review only the requested inline discussion ${payload.scope.discussionId} at ${payload.scope.path}:${payload.scope.line}. Do not review unrelated files or start a broad audit. Call gitlab_get_mr_diff, then call report_review_findings exactly once when done.`
721
- : `${profileInstruction}Review this merge request (${payload.mode} mode). Call gitlab_list_own_review_threads and gitlab_get_mr_diff first, then call report_review_findings exactly once when done.`
1062
+ ? buildReviewerScopePrompt({ scopeKind: 'discussion', discussionId: payload.scope.discussionId, path: payload.scope.path, line: payload.scope.line, profileInstruction })
1063
+ : buildReviewerScopePrompt({ scopeKind: 'full', mode: payload.mode, profileInstruction })
722
1064
  if (incrementalBlock !== undefined) scopePrompt = `${incrementalBlock}\n\n${scopePrompt}`
723
1065
  handle.agent.followup(createUserMessage({
724
1066
  content: [{ type: 'text', text: scopePrompt }],
@@ -759,7 +1101,7 @@ export function apply(ctx: Context, config: Config): void {
759
1101
  failures.push(`${locator}: ${err instanceof Error ? err.message : String(err)}`)
760
1102
  }
761
1103
  }
762
- return { summary: `${postedNew} new inline comment(s), ${postedReplies} thread(s) updated.`, failures }
1104
+ return { summary: `${postedNew} new inline comment(s), ${postedReplies} thread(s) updated.`, failures, severityCounts: countFindingSeverities(capturedFindings) }
763
1105
  } finally {
764
1106
  await handle.dispose()
765
1107
  }
@@ -792,7 +1134,7 @@ export function apply(ctx: Context, config: Config): void {
792
1134
  }
793
1135
  }
794
1136
 
795
- async function runAuditor(worktreePath: string, payload: ReviewRequest, effective: { gitlabBaseUrl: string; gitlabToken: string; botUsername: string }, modelSelection?: ModelSelection): Promise<string> {
1137
+ async function runAuditor(worktreePath: string, payload: ReviewRequest, effective: { gitlabBaseUrl: string; gitlabToken: string; botUsername: string }, modelSelection?: ModelSelection, opts?: { staticOnly?: boolean }): Promise<string> {
796
1138
  let handle: AgentHandle
797
1139
  const agentOptions = agentOptionsForModel(modelSelection ?? ctx.agentDefaultModel.currentSelection())
798
1140
  try {
@@ -819,10 +1161,10 @@ export function apply(ctx: Context, config: Config): void {
819
1161
  }
820
1162
  ctx.sessionTitle.rename(handle.agent.session, `Maestro Auditor — MR !${payload.mrIid} (${payload.projectPath})`)
821
1163
  try {
822
- const prompt = 'Audit this merge request\'s performance: bring up the environment, run the test suite, look for regressions, then write a Markdown report and tear the environment down.'
1164
+ const prompt = buildAuditorPrompt({ staticOnly: opts?.staticOnly === true })
823
1165
  handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: prompt }], source: { kind: 'user' } }))
824
1166
  await whenIdleWithTimeout(handle, effectiveAgentTimeoutMs)
825
- const output = finalAssistantOutput(handle.agent.session.events) ?? []
1167
+ const output = auditorOutputFromSession(handle.agent.session)
826
1168
  const text = output.map(block => ('text' in block ? block.text : '')).join('')
827
1169
  return `## Maestro Performance Audit\n\n${text}`
828
1170
  } finally {
@@ -830,8 +1172,9 @@ export function apply(ctx: Context, config: Config): void {
830
1172
  }
831
1173
  }
832
1174
 
833
- ctx.on('maestro/review-request', (payload) => {
834
- void (async () => {
1175
+ async function runReview(payload: ReviewRequest): Promise<ReviewResult> {
1176
+ const t0 = Date.now()
1177
+ try {
835
1178
  const userConfig = await loadUserConfig()
836
1179
  const effective = {
837
1180
  gitlabBaseUrl: userConfig.gitlabBaseUrl ?? config.gitlabBaseUrl,
@@ -849,16 +1192,37 @@ export function apply(ctx: Context, config: Config): void {
849
1192
  })
850
1193
  }
851
1194
  const mapping = effective.projectMappings.find(m => m.projectPath === payload.projectPath)
1195
+ // CI-deep (spec §3): unmapped deep review with CI env + head SHA skips
1196
+ // both the decline and the diff-only fallback and runs the clone branch
1197
+ // below, which shares the mapped path's completion tail.
1198
+ const ciDeep = mapping === undefined && payload.mode === 'deep' && shouldCiDeepReview(payload)
1199
+ // CI quick-with-profile: unmapped quick + non-generic profile + CI env
1200
+ // skips the diff-only fallback and joins the clone branch below, which
1201
+ // runs reviewer-only (quick never audits).
1202
+ const ciQuickProfile = mapping === undefined && shouldCiQuickProfileReview(payload)
852
1203
  // Unmapped reviewer assignments remain no-ops. Only an explicit mention
853
1204
  // may opt into the intentionally limited, diff-only fallback below.
854
- if (mapping === undefined && payload.trigger !== 'mention') return
1205
+ if (mapping === undefined && payload.trigger !== 'mention') {
1206
+ return { ok: true, failures: [], durationMs: Date.now() - t0 }
1207
+ }
1208
+ const triggers = resolveReviewTriggers(userConfig, mapping)
1209
+ // Gated-off auto-triggers stay fully silent: no history, no signals, no comment.
1210
+ if (payload.trigger === 'reviewer-assignment' && !triggers.onAssign) {
1211
+ return { ok: true, failures: [], durationMs: Date.now() - t0 }
1212
+ }
1213
+ if (payload.trigger === 'push' && !triggers.onPush) {
1214
+ return { ok: true, failures: [], durationMs: Date.now() - t0 }
1215
+ }
855
1216
  // A push only re-reviews an MR that already has a completed review;
856
1217
  // otherwise every newly opened MR would be reviewed twice.
857
- if (payload.trigger === 'push' && !(await hasCompletedReview(payload.projectId, payload.mrIid))) return
1218
+ if (payload.trigger === 'push' && !(await hasCompletedReview(payload.projectId, payload.mrIid))) {
1219
+ return { ok: true, failures: [], durationMs: Date.now() - t0 }
1220
+ }
858
1221
  const { gitlabToken } = effective
859
1222
  if (gitlabToken === undefined) {
860
- console.error(`maestro-orchestrator: MR !${String(payload.mrIid)} for project ${payload.projectPath} has no GitLab token — set one in Maestro Settings or MAESTRO_GITLAB_TOKEN`)
861
- return
1223
+ const message = `MR !${String(payload.mrIid)} for project ${payload.projectPath} has no GitLab token — set one in Maestro Settings or MAESTRO_GITLAB_TOKEN`
1224
+ console.error(`maestro-orchestrator: ${message}`)
1225
+ return { ok: false, failures: [message], durationMs: Date.now() - t0 }
862
1226
  }
863
1227
  const resolved = { ...effective, gitlabToken }
864
1228
  const historyId = `${payload.projectId}-${payload.mrIid}-${Date.now()}`
@@ -889,7 +1253,7 @@ export function apply(ctx: Context, config: Config): void {
889
1253
  const fallbackSelection: ModelSelection = (ctx.get?.('agentDefaultModel') as { currentSelection(): ModelSelection } | undefined)?.currentSelection()
890
1254
  ?? (ctx as unknown as { agentDefaultModel?: { currentSelection(): ModelSelection } }).agentDefaultModel?.currentSelection()
891
1255
  ?? { provider: 'fallback', model: 'fallback' }
892
- const reviewModelSelection = resolveReviewModel(userConfig, mapping, fallbackSelection)
1256
+ const reviewModelSelection = resolveReviewModel(userConfig, mapping, fallbackSelection, config.reviewModel)
893
1257
  // Opt-in Telegram digest; a delivery failure is logged and dropped.
894
1258
  const reviewStartMsOuter = Date.now()
895
1259
  const notifyTelegram = (
@@ -947,7 +1311,7 @@ export function apply(ctx: Context, config: Config): void {
947
1311
  `${resolved.gitlabBaseUrl}/api/v4/projects/${payload.projectId}/merge_requests/${payload.mrIid}/notes`,
948
1312
  {
949
1313
  method: 'POST',
950
- headers: { 'PRIVATE-TOKEN': resolved.gitlabToken, 'Content-Type': 'application/json' },
1314
+ headers: { ...gitlabAuthHeaders(resolved.gitlabToken), 'Content-Type': 'application/json' },
951
1315
  body: JSON.stringify({ body }),
952
1316
  },
953
1317
  )
@@ -958,23 +1322,25 @@ export function apply(ctx: Context, config: Config): void {
958
1322
  `${resolved.gitlabBaseUrl}/api/v4/projects/${payload.projectId}/merge_requests/${payload.mrIid}/discussions/${encodeURIComponent(discussionId)}/notes`,
959
1323
  {
960
1324
  method: 'POST',
961
- headers: { 'PRIVATE-TOKEN': resolved.gitlabToken, 'Content-Type': 'application/json' },
1325
+ headers: { ...gitlabAuthHeaders(resolved.gitlabToken), 'Content-Type': 'application/json' },
962
1326
  body: JSON.stringify({ body }),
963
1327
  },
964
1328
  )
965
1329
  if (!response.ok) throw new Error(`GitLab API error ${response.status}: ${await response.text()}`)
966
1330
  }
967
1331
  try {
968
- if (mapping === undefined) {
969
- if (payload.mode === 'deep') {
1332
+ if (mapping === undefined && !ciDeep && !ciQuickProfile) {
1333
+ // CI-deep (spec §3) skips the decline and falls through to the clone
1334
+ // branch below; every other unmapped deep review still declines here.
1335
+ if (payload.mode === 'deep' && !shouldCiDeepReview(payload)) {
970
1336
  await declineUnmappedDeepReview(payload, { postComment, replyToDiscussion, writeFailedReport, gitlabBaseUrl: resolved.gitlabBaseUrl } as unknown as ReviewCommentDeps)
971
1337
  await recordReviewFinish(historyId, { status: 'completed', summary: 'Deep review declined (unmapped project)' })
972
1338
  notifyTelegram('completed', 'Deep review declined (unmapped project)')
973
1339
  await signals?.finish('completed')
974
- return
1340
+ return { ok: true, summary: 'Deep review declined (unmapped project)', failures: [], durationMs: Date.now() - t0 }
975
1341
  }
976
1342
  const diffBody = await runDiffOnlyReview(payload, {
977
- runReviewer: (p) => runReviewer(undefined, p, resolved, undefined, reviewModelSelection, incrementalBlock),
1343
+ runReviewer: (p) => runReviewer(undefined, p, resolved, payload.reviewProfile, reviewModelSelection, incrementalBlock),
978
1344
  postComment,
979
1345
  replyToDiscussion,
980
1346
  writeFailedReport,
@@ -983,9 +1349,47 @@ export function apply(ctx: Context, config: Config): void {
983
1349
  await recordReviewFinish(historyId, { status: 'completed', summary: summarize(diffBody) })
984
1350
  notifyTelegram('completed', summarize(diffBody))
985
1351
  await signals?.finish('completed')
986
- return
1352
+ return { ok: true, summary: summarize(diffBody), failures: [], durationMs: Date.now() - t0 }
987
1353
  }
988
1354
  let fullBody: string
1355
+ if (ciDeep || ciQuickProfile) {
1356
+ // CI-deep (mapping is always undefined here — the unmapped block above
1357
+ // returned for every other case): clone the source at the head SHA,
1358
+ // map it in memory, and reuse the mapped machinery reviewer-only.
1359
+ // CI quick-with-profile joins the same branch; runReviewAndAudit skips
1360
+ // the auditor for quick (shouldAudit), so it stays reviewer-only.
1361
+ // No govard/vendor linking (ciEnsureWorktree is plain fetch + worktree).
1362
+ const ciHost = process.env.GITLAB_HOST?.trim() || new URL(resolved.gitlabBaseUrl).hostname
1363
+ const cloneDir = join('/tmp', `maestro-ci-src-${payload.projectId}-${payload.mrIid}`)
1364
+ await cloneSourceRepo({ fetcher: fetch, run: defaultRun, host: ciHost, projectId: payload.projectId, sourceBranch: payload.sourceBranch, headSha: payload.headSha as string, token: resolved.gitlabToken, dir: cloneDir })
1365
+ try {
1366
+ const ciEnsureWorktree = async (repoPath: string, branch: string, pid: number, iid: number, suffix?: string): Promise<string> => {
1367
+ assertSafeBranchName(branch)
1368
+ const wt = join('/tmp', `maestro-mr-${pid}-${iid}${suffix === undefined ? '' : `-${suffix}`}`)
1369
+ await defaultRun('git', ['fetch', '--depth', '50', 'origin', branch], repoPath)
1370
+ await defaultRun('git', ['worktree', 'remove', '--force', wt], repoPath).catch(() => {})
1371
+ await defaultRun('git', ['worktree', 'add', '--detach', '--', wt, payload.headSha as string], repoPath)
1372
+ return wt
1373
+ }
1374
+ fullBody = await runReviewAndAudit(payload, {
1375
+ localRepoPath: cloneDir,
1376
+ ensureWorktree: ciEnsureWorktree,
1377
+ removeWorktree,
1378
+ runReviewer: (worktreePath, p) => runReviewer(worktreePath, p, resolved, payload.reviewProfile ?? 'generic', reviewModelSelection, incrementalBlock),
1379
+ runAuditor: withAuditorDegrade((worktreePath, p) => runAuditor(worktreePath, p, resolved, reviewModelSelection, { staticOnly: true })),
1380
+ postComment,
1381
+ replyToDiscussion,
1382
+ writeFailedReport,
1383
+ gitlabBaseUrl: resolved.gitlabBaseUrl,
1384
+ reviewProfile: payload.reviewProfile ?? 'generic',
1385
+ } as unknown as ReviewAndAuditDeps)
1386
+ } finally {
1387
+ await defaultRun('rm', ['-rf', cloneDir]).catch(() => {})
1388
+ }
1389
+ } else {
1390
+ // Unreachable when mapping is undefined: the unmapped block returned
1391
+ // for every non-CI-deep case, and ciDeep took the branch above.
1392
+ if (mapping === undefined) throw new Error('unreachable: unmapped review without CI-deep decision')
989
1393
  try {
990
1394
  fullBody = await runReviewAndAudit(payload, {
991
1395
  localRepoPath: mapping.localRepoPath,
@@ -1003,7 +1407,7 @@ export function apply(ctx: Context, config: Config): void {
1003
1407
  const isBranchNotFound = (err as { code?: string })?.code === 'BRANCH_NOT_FOUND' || isBranchNotFoundError(err)
1004
1408
  if (isBranchNotFound) {
1005
1409
  const diffBody = await runDiffOnlyReview(payload, {
1006
- runReviewer: (p) => runReviewer(undefined, p, resolved, undefined, reviewModelSelection, incrementalBlock),
1410
+ runReviewer: (p) => runReviewer(undefined, p, resolved, payload.reviewProfile, reviewModelSelection, incrementalBlock),
1007
1411
  postComment,
1008
1412
  replyToDiscussion,
1009
1413
  writeFailedReport,
@@ -1014,25 +1418,33 @@ export function apply(ctx: Context, config: Config): void {
1014
1418
  await recordReviewFinish(historyId, { status: 'completed', summary: branchNote })
1015
1419
  notifyTelegram('completed', branchNote)
1016
1420
  await signals?.finish('completed')
1017
- return
1421
+ return { ok: true, summary: branchNote, failures: [], durationMs: Date.now() - t0 }
1018
1422
  }
1019
1423
  throw err
1020
1424
  }
1425
+ }
1021
1426
  await recordReviewFinish(historyId, { status: 'completed', summary: summarize(fullBody) })
1022
1427
  notifyTelegram('completed', summarize(fullBody))
1023
1428
  await signals?.finish('completed')
1429
+ return { ok: true, summary: summarize(fullBody), failures: [], durationMs: Date.now() - t0 }
1024
1430
  } catch (err) {
1025
1431
  const message = err instanceof Error ? err.message : String(err)
1026
1432
  await recordReviewFinish(historyId, { status: 'failed', error: message }).catch(() => {})
1027
1433
  notifyTelegram('failed', message)
1028
1434
  await signals?.finish('failed')
1029
- throw err
1435
+ return { ok: false, summary: undefined, failures: [message], durationMs: Date.now() - t0 }
1030
1436
  }
1031
- })().catch((err: unknown) => {
1437
+ } catch (err) {
1032
1438
  // Worktree creation, agent creation, and fallback delivery all run from
1033
- // an event callback, so surface failures rather than leaking a rejected
1034
- // fire-and-forget Promise.
1439
+ // this function — surface failures as a result instead of an unhandled
1440
+ // rejection, so both the fire-and-forget webhook path and an awaiting
1441
+ // CI caller observe the same outcome.
1442
+ const message = err instanceof Error ? err.message : String(err)
1035
1443
  console.error(`maestro-orchestrator: review run failed for MR !${String(payload.mrIid)}:`, err)
1036
- })
1037
- })
1444
+ return { ok: false, summary: undefined, failures: [message], durationMs: Date.now() - t0 }
1445
+ }
1446
+ }
1447
+
1448
+ ctx.on('maestro/review-request', (payload) => { void runReview(payload) })
1449
+ ctx.provide('reviewRunner', runReview)
1038
1450
  }