@ddtcorex/dsh-maestro-review 0.3.2 → 0.4.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.
@@ -7,7 +7,39 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
7
7
 
8
8
  export const name='maestro-govard-audit-lint-tool'
9
9
  export const inject=['tools']
10
- export const Config:z<{rootPath?:string, timeoutMs?:number}> = z.object({rootPath:z.string(), timeoutMs:z.number()})
10
+ export const Config:z<{rootPath?:string, timeoutMs?:number, defaultBase?:string, allowXdebug?:boolean}> = z.object({rootPath:z.string(), timeoutMs:z.number(), defaultBase:z.string(), allowXdebug:z.boolean()})
11
+
12
+ export interface AuditLintOptions {
13
+ checks?: string[]
14
+ mode?: string
15
+ scope?: string
16
+ base?: string
17
+ phpVersions?: string[]
18
+ noLintResultCache?: boolean
19
+ timeout?: string
20
+ lintProvider?: string
21
+ allowXdebug?: boolean
22
+ }
23
+
24
+ /** Explicit call base wins, then the review-wired default (MR base_sha). */
25
+ export function resolveLintBase(argsBase: string | undefined, defaultBase: string | undefined): string | undefined {
26
+ return argsBase ?? defaultBase
27
+ }
28
+
29
+ export function buildAuditCliArgs(a: AuditLintOptions): string[] {
30
+ const checks = a.checks && a.checks.length ? a.checks.join(',') : 'lint'
31
+ const mode = a.mode ?? 'auto'
32
+ const timeout = a.timeout ?? 'auto'
33
+ const lintProvider = a.lintProvider ?? 'govard'
34
+ const cliArgs=['audit','run','--checks',checks,'--format','json','--mode',mode,'--timeout',timeout,'--lint-provider',lintProvider]
35
+ if(a.scope) cliArgs.push('--scope', a.scope)
36
+ const base = resolveLintBase(a.base, undefined)
37
+ if(base) cliArgs.push('--base', base)
38
+ if(a.phpVersions && a.phpVersions.length) cliArgs.push('--php', a.phpVersions.join(','))
39
+ if(a.noLintResultCache) cliArgs.push('--no-lint-result-cache')
40
+ if(a.allowXdebug) cliArgs.push('--allow-xdebug')
41
+ return cliArgs
42
+ }
11
43
 
12
44
  interface SC{agent?:{session?:{header?:{cwd?:string}}}}
13
45
  function workspaceRootFor(c:string|undefined, e:unknown):string{
@@ -38,7 +70,98 @@ async function isInsideRoot(r:string,t:string):Promise<boolean>{
38
70
  }
39
71
  }
40
72
  export function cleanJson(raw:string):string{
41
- return raw.replace(/\n\s*ERROR audit run.*$/s, '').replace(/\s{2,}ERROR audit run.*$/s, '').trimEnd()
73
+ // pterm pads the level name: " ERROR audit run ..." (multi-space).
74
+ return raw.replace(/\n\s*ERROR\s+audit run.*$/s, '').replace(/\s{2,}ERROR\s+audit run.*$/s, '').trimEnd()
75
+ }
76
+
77
+ interface LintViolationLike { path?: string; line?: number; rule?: string; message?: string }
78
+
79
+ interface LintResultLike {
80
+ ok: boolean
81
+ exitCode?: number
82
+ lint?: {
83
+ phpcs?: { violations?: LintViolationLike[] }
84
+ phpstan?: { errors?: LintViolationLike[] }
85
+ pubMediaGuard?: { violations?: LintViolationLike[] }
86
+ }
87
+ summary?: { findingCount?: number }
88
+ diagnostics?: string
89
+ }
90
+
91
+ /**
92
+ * One-line-plus render for the agent: a bare "audit lint failed" hides the
93
+ * violations the reviewer needs, so carry counts plus the top findings.
94
+ */
95
+ export interface CollectedLintFindings {
96
+ phpcsViolations: Array<{ path?: string; line?: number; column?: number; rule?: string; message?: string; severity?: string }>
97
+ phpstanErrors: Array<{ path?: string; line?: number; message?: string }>
98
+ pubMediaViolations: unknown[]
99
+ compat: Array<{ tool?: string; path?: string; line?: number; rule?: string; message?: string }>
100
+ total: number
101
+ }
102
+
103
+ /**
104
+ * Collect findings across govard audit JSON shapes. The live envelope nests
105
+ * them at results[].evidence.php_results[].findings (older callers used a
106
+ * top-level findings array or evidence.php_results) — and non-phpcs/phpstan
107
+ * tools (e.g. M2-LINT-COMPAT internal errors) go to the compat bucket
108
+ * instead of being silently dropped from counts.
109
+ */
110
+ export function collectLintFindings(parsed: unknown): CollectedLintFindings {
111
+ const p = (parsed ?? {}) as Record<string, any>
112
+ const lists: unknown[][] = []
113
+ if (Array.isArray(p.findings)) lists.push(p.findings)
114
+ const ev = p.evidence as Record<string, any> | undefined
115
+ if (Array.isArray(ev?.php_results)) lists.push(...ev.php_results.map((r: any) => r?.findings).filter(Array.isArray))
116
+ if (Array.isArray(p.php_results)) lists.push(...p.php_results.map((r: any) => r?.findings).filter(Array.isArray))
117
+ // Live shape (govard audit run): jobs[].evidence.php_results[].findings.
118
+ // Older shapes (results[], bare evidence) kept for backward compatibility.
119
+ for (const key of ['jobs', 'results'] as const) {
120
+ if (!Array.isArray(p[key])) continue
121
+ for (const r of p[key] as Array<Record<string, any>>) {
122
+ const e = r?.evidence as Record<string, any> | undefined
123
+ const pr = e?.php_results ?? r?.php_results
124
+ if (Array.isArray(pr)) lists.push(...pr.map((x: any) => x?.findings).filter(Array.isArray))
125
+ if (Array.isArray(r?.findings)) lists.push(r.findings)
126
+ }
127
+ }
128
+ const out: CollectedLintFindings = { phpcsViolations: [], phpstanErrors: [], pubMediaViolations: [], compat: [], total: 0 }
129
+ // DSH tool output must be lossless JSON: drop undefined fields, the
130
+ // runtime rejects values that do not survive a JSON round-trip.
131
+ const compact = (entry: Record<string, unknown>): Record<string, unknown> => {
132
+ const kept: Record<string, unknown> = {}
133
+ for (const [k, v] of Object.entries(entry)) if (v !== undefined) kept[k] = v
134
+ return kept
135
+ }
136
+ for (const list of lists) {
137
+ for (const f of list as Array<Record<string, any>>) {
138
+ if (f?.tool === 'phpstan') out.phpstanErrors.push(compact({ path: f.path, line: f.line, message: f.message }) as CollectedLintFindings['phpstanErrors'][number])
139
+ else if (f?.tool === 'phpcs') {
140
+ if (f.path?.includes('pub/media') || f.rule?.includes('PubMedia')) out.pubMediaViolations.push(f)
141
+ else out.phpcsViolations.push(compact({ path: f.path, line: f.line, column: f.column, rule: f.rule, message: f.message, severity: f.severity }) as CollectedLintFindings['phpcsViolations'][number])
142
+ } else if (f && typeof f === 'object') {
143
+ out.compat.push(compact({ tool: f.tool, path: f.path, line: f.line, rule: f.rule, message: f.message }) as CollectedLintFindings['compat'][number])
144
+ }
145
+ }
146
+ }
147
+ out.total = out.phpcsViolations.length + out.phpstanErrors.length + out.pubMediaViolations.length + out.compat.length
148
+ return out
149
+ }
150
+
151
+ export function lintResultText(v: LintResultLike): string {
152
+ if (v.ok) return 'audit lint passed'
153
+ const phpcs = v.lint?.phpcs?.violations ?? []
154
+ const phpstan = v.lint?.phpstan?.errors ?? []
155
+ const pubMedia = v.lint?.pubMediaGuard?.violations ?? []
156
+ const compat = (v.lint as Record<string, any> | undefined)?.compat?.findings ?? []
157
+ const total = v.summary?.findingCount ?? phpcs.length + phpstan.length + pubMedia.length + compat.length
158
+ const bits: string[] = [`audit lint failed — ${total} finding(s) (phpcs ${phpcs.length}, phpstan ${phpstan.length}, pubMedia ${pubMedia.length}, compat ${compat.length})`]
159
+ const top = [...phpcs.map(x => ({ ...x, tool: 'phpcs' })), ...phpstan.map(x => ({ ...x, tool: 'phpstan' })), ...pubMedia.map(x => ({ ...x, tool: 'pubMedia' })), ...compat.map((x: Record<string, any>) => ({ ...x, tool: x.tool ?? 'compat' }))].slice(0, 5)
160
+ for (const f of top) bits.push(`- [${f.tool}] ${f.path ?? (f.message ?? '?').toString().slice(0, 120)}${f.path !== undefined ? `:${f.line ?? '?'}` : ''}${f.rule !== undefined ? ` ${f.rule}` : ''}`)
161
+ if (v.exitCode !== undefined) bits.push(`exit ${v.exitCode}`)
162
+ const diag = (v.diagnostics ?? '').split('\n')[0]?.trim()
163
+ if (total === 0 && diag !== '' && diag !== undefined) bits.push(diag.slice(0, 200))
164
+ return bits.join('\n')
42
165
  }
43
166
  // Govard 1.67 auto timeout: 90s-30m framework-aware (15m floor for wordpress/magento2 → 22.5m auto)
44
167
  // Keep kill timeout above the largest auto value so the outer watchdog doesn't cancel a valid auto run.
@@ -57,12 +180,16 @@ function run(cmd:string, args:string[], cwd:string, timeoutMs:number):Promise<{c
57
180
  })
58
181
  }
59
182
 
60
- export function apply(ctx:Context, config:{rootPath?:string, timeoutMs?:number}={}):void{
183
+ export function apply(ctx:Context, config:{rootPath?:string, timeoutMs?:number, defaultBase?:string, allowXdebug?:boolean}={}):void{
61
184
  const configuredRoot=config.rootPath
62
185
  const defaultTimeout=config.timeoutMs ?? DEFAULT_TIMEOUT
186
+ const defaultBase=config.defaultBase
187
+ // Review worktrees disable xdebug via .govard.local.yml, but govard's lint
188
+ // guard probes the base .govard.yml only — so the mount opts out explicitly.
189
+ const allowXdebug=config.allowXdebug ?? false
63
190
  ctx.effect(()=>ctx.tools.register(defineTool({
64
191
  name:'govard_audit_lint',
65
- description:'Run govard audit --checks lint --format json and return structured phpcs/phpstan results. Use before hand-parsing text. Govard 1.67+ uses --timeout auto (framework-aware 90s-30m, 22.5m for wordpress/magento2) by default.',
192
+ description:'Run govard audit --checks lint --format json and return structured phpcs/phpstan results. Use before hand-parsing text. Govard 1.67+ uses --timeout auto (framework-aware 90s-30m, 22.5m for wordpress/magento2) by default. scope "diff" requires a base ref: pass base, or rely on the wired defaultBase (MR base_sha) when present.',
66
193
  parameters:{
67
194
  worktreePath:{type:'string'},
68
195
  checks:{type:'array', items:{type:'string'}},
@@ -93,7 +220,7 @@ export function apply(ctx:Context, config:{rootPath?:string, timeoutMs?:number}=
93
220
  runId:{type:'string'},
94
221
  }
95
222
  },
96
- render:(_a,v:{ok:boolean})=>[{type:'text', text: v.ok? 'audit lint passed':'audit lint failed'}],
223
+ render:(_a,v:LintResultLike)=>[{type:'text', text: lintResultText(v)}],
97
224
  },
98
225
  async execute(args, exec){
99
226
  const rawPath=(args as {worktreePath?:string}).worktreePath
@@ -104,20 +231,13 @@ export function apply(ctx:Context, config:{rootPath?:string, timeoutMs?:number}=
104
231
  const timeoutMs=(args as {timeoutMs?:number}).timeoutMs ?? defaultTimeout
105
232
  if(timeoutMs<5000 || timeoutMs>1_800_000) return {text:'timeoutMs out of range 5000-1800000 (5s-30m). Use --timeout auto for framework-aware estimation.', truncated:false} as never
106
233
 
107
- const a = args as {checks?:string[]; mode?:string; scope?:string; base?:string; phpVersions?:string[]; noLintResultCache?:boolean; timeout?:string; lintProvider?:string}
108
- const checks = a.checks && a.checks.length ? a.checks.join(',') : 'lint'
109
- const mode = a.mode ?? 'auto'
234
+ const a = args as {checks?:string[]; mode?:string; scope?:string; base?:string; phpVersions?:string[]; noLintResultCache?:boolean; timeout?:string; lintProvider?:string; allowXdebug?:boolean}
110
235
  const scope = a.scope
111
- const base = a.base
112
- const phpVersionsArg = a.phpVersions
113
- const noLintResultCache = a.noLintResultCache
114
- const timeout = a.timeout ?? 'auto'
115
- const lintProvider = a.lintProvider ?? 'govard'
116
- const cliArgs=['audit','run','--checks',checks,'--format','json','--mode',mode,'--timeout',timeout,'--lint-provider',lintProvider]
117
- if(scope) cliArgs.push('--scope', scope)
118
- if(base) cliArgs.push('--base', base)
119
- if(phpVersionsArg && phpVersionsArg.length) cliArgs.push('--php', phpVersionsArg.join(','))
120
- if(noLintResultCache) cliArgs.push('--no-lint-result-cache')
236
+ const base = resolveLintBase(a.base, defaultBase)
237
+ if (scope === 'diff' && base === undefined) {
238
+ return {text:'scope "diff" requires a base ref (govard --base): pass base explicitly (e.g. origin/master or the MR base_sha) or mount this tool with defaultBase.', truncated:false} as never
239
+ }
240
+ const cliArgs = buildAuditCliArgs({ ...a, base, allowXdebug: a.allowXdebug ?? allowXdebug })
121
241
  const result=await run('govard', cliArgs, worktreePath, timeoutMs)
122
242
  if(result.timedOut){
123
243
  return {ok:false, exitCode: result.code ?? 124, timedOut:true, worktreePath, lint:{phpcs:{violations:[]}, phpstan:{errors:[]}, pubMediaGuard:{violations:[]}}, summary:{status:null, phpVersions:[], matrixComplete:false, findingCount:0, truncated:false}, rawJson:{}, errors:[{code:'timeout', message:`timed out after ${timeoutMs}ms` }], diagnostics:result.stderr.slice(0,4000)} as never
@@ -139,20 +259,13 @@ export function apply(ctx:Context, config:{rootPath?:string, timeoutMs?:number}=
139
259
  if(!parsed){
140
260
  return {ok:false, exitCode: result.code ?? 1, timedOut:false, worktreePath, lint:{phpcs:{violations:[]}, phpstan:{errors:[]}, pubMediaGuard:{violations:[]}}, summary:{status:null, phpVersions:[], matrixComplete:false, findingCount:0, truncated:false}, rawJson:{}, errors:[{code:'parse_error', message:'stdout not JSON'}], diagnostics:(cleaned+result.stderr).slice(0,4000)} as never
141
261
  }
142
- const findings:Array<any>=parsed.findings ?? parsed.evidence?.php_results?.flatMap((r:any)=>r.findings) ?? []
143
- const phpcsViolations:any[]=[]
144
- const phpstanErrors:any[]=[]
145
- const pubMediaViolations:any[]=[]
146
- for(const f of findings){
147
- if(f.tool==='phpstan') phpstanErrors.push({path:f.path, line:f.line, message:f.message})
148
- else if(f.tool==='phpcs'){
149
- if(f.path?.includes('pub/media') || f.rule?.includes('PubMedia')) pubMediaViolations.push(f)
150
- else phpcsViolations.push({path:f.path, line:f.line, column:f.column, rule:f.rule, message:f.message, severity:f.severity})
151
- }
152
- }
262
+ const collected = collectLintFindings(parsed)
263
+ const phpcsViolations = collected.phpcsViolations
264
+ const phpstanErrors = collected.phpstanErrors
265
+ const pubMediaViolations = collected.pubMediaViolations
153
266
  const status=parsed.status ?? (result.code===0?'passed':'failed')
154
267
  const phpVersions=parsed.php_versions ?? parsed.phpVersions ?? []
155
- const findingCount=findings.length
268
+ const findingCount=collected.total
156
269
  const sessionId = parsed.session_id ?? parsed.sessionId ?? undefined
157
270
  const runId = parsed.run_id ?? parsed.runId ?? undefined
158
271
  return {
@@ -164,7 +277,7 @@ export function apply(ctx:Context, config:{rootPath?:string, timeoutMs?:number}=
164
277
  ...(runId ? { runId: String(runId) } : {}),
165
278
  rawJson: parsed as Record<string, unknown>,
166
279
  summary:{status, phpVersions, matrixComplete:true, findingCount, truncated: findingCount>100},
167
- lint:{phpcs:{violations:phpcsViolations}, phpstan:{errors:phpstanErrors}, pubMediaGuard:{violations:pubMediaViolations}},
280
+ lint:{phpcs:{violations:phpcsViolations}, phpstan:{errors:phpstanErrors}, pubMediaGuard:{violations:pubMediaViolations}, compat:{findings:collected.compat}},
168
281
  errors:[],
169
282
  diagnostics: result.stderr.slice(0,4000),
170
283
  } as never
@@ -82,7 +82,7 @@ export function apply(ctx: Context, config: Config): void {
82
82
  name: 'govard_shell',
83
83
  description: 'Run one non-interactive command inside the Govard-managed container (e.g. a test suite) and return its output.',
84
84
  parameters: {
85
- command: { type: 'string', required: true, description: 'Shell command to run inside the container, e.g. "vendor/bin/phpunit".' },
85
+ command: { type: 'string', required: true, description: 'Shell command to run inside the container. PHPUnit in a Magento root: always scope it — "vendor/bin/phpunit -c dev/tests/unit/phpunit.xml.dist --filter <Module>" or "vendor/bin/phpunit --no-coverage --bootstrap dev/tests/unit/framework/bootstrap.php app/code/<Vendor>/<Module>/Test/Unit". The root ships no phpunit.xml so the bare binary prints usage and exits 1; never run the full suite bare (core fixture conflicts), and capture status via ${PIPESTATUS[0]} when piping through tail.' },
86
86
  },
87
87
  output: {
88
88
  schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true } } },
@@ -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,7 +29,7 @@ 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'
32
+ import type { ReviewFinding, FindingSeverity } from './review-findings-tool.js'
33
33
  import type { ReviewRequest } from './events.js'
34
34
  import { loadUserConfig, type MaestroUserConfig, type ReviewModelSelection } from './config-store.js'
35
35
  import { hasCompletedReview, lastCompletedReview, pruneHistory, recordReviewFinish, recordReviewStart } from './review-history.js'
@@ -58,6 +58,62 @@ export { gitlabProvider }
58
58
  const execFileAsync = promisify(execFile)
59
59
  const GIT_TIMEOUT_MS = 60_000
60
60
 
61
+ /**
62
+ * Finding severity, assigned by the reviewer. Display order is fixed
63
+ * (blocking first) wherever counts are rendered.
64
+ */
65
+ export const SEVERITY_ORDER: readonly FindingSeverity[] = ['blocking', 'major', 'minor', 'nit'] as const
66
+
67
+ const SEVERITY_LABEL: Record<FindingSeverity, string> = {
68
+ blocking: '🔴 Blocking',
69
+ major: '🟡 Major',
70
+ minor: '🔵 Minor',
71
+ nit: '⚪ Nit',
72
+ }
73
+
74
+ /** Missing or unknown severities degrade to `minor` — never drop a finding. */
75
+ export function normalizeFindingSeverity(severity: unknown): FindingSeverity {
76
+ return severity === 'blocking' || severity === 'major' || severity === 'minor' || severity === 'nit'
77
+ ? severity
78
+ : 'minor'
79
+ }
80
+
81
+ export function severityPrefix(severity: unknown): string {
82
+ return SEVERITY_LABEL[normalizeFindingSeverity(severity)]
83
+ }
84
+
85
+ /** Count findings per severity; zero counts are omitted. */
86
+ export function countFindingSeverities(findings: ReviewFinding[]): Partial<Record<FindingSeverity, number>> {
87
+ const counts: Partial<Record<FindingSeverity, number>> = {}
88
+ for (const finding of findings) {
89
+ const level = normalizeFindingSeverity(finding.severity)
90
+ counts[level] = (counts[level] ?? 0) + 1
91
+ }
92
+ return counts
93
+ }
94
+
95
+ export interface ReviewerScopePromptOpts {
96
+ scopeKind: 'discussion' | 'full'
97
+ discussionId?: string
98
+ path?: string
99
+ line?: number
100
+ mode?: string
101
+ profileInstruction: string
102
+ }
103
+
104
+ /**
105
+ * Reviewer scope prompt. Static analysis is mandatory: reviewers skipped
106
+ * govard_audit_lint for whole rounds (no lint signal at all), so the prompt
107
+ * requires at least one call before report_review_findings. Pure for testing.
108
+ */
109
+ export function buildReviewerScopePrompt(opts: ReviewerScopePromptOpts): string {
110
+ 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.'
111
+ if (opts.scopeKind === 'discussion') {
112
+ 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}`
113
+ }
114
+ 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.`
115
+ }
116
+
61
117
  /**
62
118
  * Build a user-friendly GitLab Markdown comment for a completed review.
63
119
  * Shared design language with `reviewDigestText` (Telegram HTML) — same
@@ -72,7 +128,7 @@ export function buildReviewComment(opts: {
72
128
  profile?: string
73
129
  summary: string
74
130
  failures: string[]
75
- findings?: { newCount: number; replyCount: number }
131
+ findings?: { newCount: number; replyCount: number; severityCounts?: Partial<Record<FindingSeverity, number>> }
76
132
  durationMs?: number
77
133
  isDiffOnly?: boolean
78
134
  isDiscussion?: boolean
@@ -97,6 +153,12 @@ export function buildReviewComment(opts: {
97
153
  const parts: string[] = []
98
154
  if (opts.findings.newCount > 0) parts.push(`💬 ${opts.findings.newCount} new`)
99
155
  if (opts.findings.replyCount > 0) parts.push(`🔁 ${opts.findings.replyCount} updated`)
156
+ if (opts.findings.severityCounts !== undefined) {
157
+ for (const level of SEVERITY_ORDER) {
158
+ const count = opts.findings.severityCounts[level] ?? 0
159
+ if (count > 0) parts.push(`${SEVERITY_LABEL[level].split(' ')[0]} ${count} ${level}`)
160
+ }
161
+ }
100
162
  if (parts.length === 0) parts.push('no inline findings')
101
163
  return `\n\n**Findings:** ${parts.join(' · ')}`
102
164
  })()
@@ -223,6 +285,7 @@ export const Config: z<Config> = z.object({
223
285
  export interface ReviewOutcome {
224
286
  summary: string
225
287
  failures: string[]
288
+ severityCounts?: Partial<Record<FindingSeverity, number>>
226
289
  }
227
290
 
228
291
  /**
@@ -374,10 +437,12 @@ export async function postReviewFindings(findings: ReviewFinding[], config: Gitl
374
437
  const headers = { 'PRIVATE-TOKEN': config.token, 'Content-Type': 'application/json' }
375
438
  for (const finding of findings) {
376
439
  let response: Response
440
+ const label = severityPrefix(finding.severity)
441
+ const body = finding.body.startsWith(label) ? finding.body : `${label}\n\n${finding.body}`
377
442
  if (finding.status === 'reply') {
378
443
  if (finding.discussionId === undefined) throw new Error('reply finding missing discussionId')
379
444
  response = await fetcher(`${apiBase}/discussions/${encodeURIComponent(finding.discussionId)}/notes`, {
380
- method: 'POST', headers, body: JSON.stringify({ body: finding.body }),
445
+ method: 'POST', headers, body: JSON.stringify({ body }),
381
446
  })
382
447
  } else {
383
448
  if (finding.path === undefined || finding.line === undefined) throw new Error('new finding missing path or line')
@@ -395,12 +460,12 @@ export async function postReviewFindings(findings: ReviewFinding[], config: Gitl
395
460
  // Line not in diff — fallback to MR note so finding is not silently lost.
396
461
  response = await fetcher(`${apiBase}/notes`, {
397
462
  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}` }),
463
+ body: JSON.stringify({ body: `**Inline fallback — \`${finding.path}:${finding.line}\` line is not in current MR diff**\n\n${body}` }),
399
464
  })
400
465
  } else {
401
466
  response = await fetcher(`${apiBase}/discussions`, {
402
467
  method: 'POST', headers,
403
- body: JSON.stringify({ body: finding.body, position: {
468
+ body: JSON.stringify({ body, position: {
404
469
  position_type: 'text', ...snapshot.diffRefs, old_path: change.old_path, new_path: change.new_path,
405
470
  ...linePosition.oldLine === undefined ? {} : { old_line: linePosition.oldLine },
406
471
  ...linePosition.newLine === undefined ? {} : { new_line: linePosition.newLine },
@@ -453,6 +518,33 @@ function assertSafeId(value: number, label: string): void {
453
518
  }
454
519
  }
455
520
 
521
+ /**
522
+ * Read an agent session's transcript for the auditor's final output across
523
+ * host/session API skew: hosts built from the harness checkout expose
524
+ * `ownEvents()`/`snapshotEvents()` with no `.events` getter, while older
525
+ * packaged `@deepseek-ai/dsh-session` builds expose only the `.events`
526
+ * getter. `ownEvents()` (child-owned suffix, no fork prefix) matches
527
+ * `finalAssistantOutput`'s documented input best, so it wins when present.
528
+ * Returns `[]` — never throws — when no event source exists.
529
+ */
530
+ export function auditorOutputFromSession(session: unknown) {
531
+ const candidate = session as {
532
+ ownEvents?: unknown
533
+ snapshotEvents?: unknown
534
+ events?: unknown
535
+ } | null | undefined
536
+ let events: unknown
537
+ if (typeof candidate?.ownEvents === 'function') {
538
+ events = (candidate.ownEvents as () => unknown)()
539
+ } else if (typeof candidate?.snapshotEvents === 'function') {
540
+ events = (candidate.snapshotEvents as () => unknown)()
541
+ } else {
542
+ events = candidate?.events
543
+ }
544
+ if (!Array.isArray(events)) return []
545
+ return finalAssistantOutput(events as Parameters<typeof finalAssistantOutput>[0]) ?? []
546
+ }
547
+
456
548
  /** Full review + performance audit; resolves to the comment body that was posted. */
457
549
  export async function runReviewAndAudit(payload: ReviewRequest, deps: ReviewAndAuditDeps): Promise<string> {
458
550
  assertSafeId(payload.projectId, 'projectId')
@@ -471,7 +563,7 @@ export async function runReviewAndAudit(payload: ReviewRequest, deps: ReviewAndA
471
563
  ])
472
564
  const labels = ['Reviewer', 'Auditor']
473
565
  if (settled[0].status === 'fulfilled') {
474
- const { summary, failures } = settled[0].value
566
+ const { summary, failures, severityCounts } = settled[0].value
475
567
  // Try to parse findings counts from summary like "2 new inline comment(s), 1 thread(s) updated."
476
568
  const summaryText = summary ?? ''
477
569
  const newMatch = /(\d+)\s+new inline/.exec(summaryText)
@@ -488,7 +580,7 @@ export async function runReviewAndAudit(payload: ReviewRequest, deps: ReviewAndA
488
580
  profile: (deps as unknown as { reviewProfile?: string }).reviewProfile,
489
581
  summary: summaryText,
490
582
  failures,
491
- findings: { newCount, replyCount },
583
+ findings: { newCount, replyCount, severityCounts },
492
584
  })
493
585
  : `## 🤖 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
586
  sections.push(richOpts)
@@ -619,7 +711,34 @@ export function isBranchNotFoundError(err: unknown): boolean {
619
711
  */
620
712
  export function govardWorktreeOverride(projectId: number, mrIid: number, keySuffix?: string): string {
621
713
  const name = `maestro-mr-${projectId}-${mrIid}${keySuffix === undefined ? '' : `-${keySuffix}`}`
622
- return `project_name: ${name}\ndomain: ${name}.test\n`
714
+ // Xdebug stays off in review envs: the auditor runs no coverage, and an
715
+ // enabled Xdebug trips govard's lint perf-tax guard (exit 1, 0 findings).
716
+ return `project_name: ${name}\ndomain: ${name}.test\nstack:\n features:\n xdebug: false\n`
717
+ }
718
+
719
+ /**
720
+ * Fetch the MR's diff base SHA for govard diff-scope runs. Best-effort:
721
+ * returns undefined (the lint tool then fail-fasts with guidance) rather
722
+ * than failing the review when GitLab is unreachable.
723
+ */
724
+ export async function fetchMrBaseSha(
725
+ baseUrl: string,
726
+ token: string,
727
+ projectId: number,
728
+ mrIid: number,
729
+ fetcher: typeof fetch = fetch,
730
+ ): Promise<string | undefined> {
731
+ try {
732
+ const response = await fetcher(
733
+ `${baseUrl}/api/v4/projects/${projectId}/merge_requests/${mrIid}`,
734
+ { headers: { 'PRIVATE-TOKEN': token } },
735
+ )
736
+ if (!response.ok) return undefined
737
+ const mr = await response.json() as { diff_refs?: { base_sha?: string } }
738
+ return mr.diff_refs?.base_sha
739
+ } catch {
740
+ return undefined
741
+ }
623
742
  }
624
743
 
625
744
  export async function ensureWorktree(localRepoPath: string, sourceBranch: string, projectId: number, mrIid: number, keySuffix?: string): Promise<string> {
@@ -643,9 +762,134 @@ export async function ensureWorktree(localRepoPath: string, sourceBranch: string
643
762
  .catch(() => {})
644
763
  await execFileAsync('git', ['worktree', 'add', '--', worktreePath, `origin/${sourceBranch}`], { cwd: localRepoPath, timeout: GIT_TIMEOUT_MS })
645
764
  await writeFile(join(worktreePath, '.govard.local.yml'), govardWorktreeOverride(projectId, mrIid, keySuffix), 'utf-8')
765
+ await linkVendorIntoWorktree(localRepoPath, worktreePath)
766
+ await writeContainerVendorOverride(localRepoPath, worktreePath)
646
767
  return worktreePath
647
768
  }
648
769
 
770
+ /**
771
+ * A vendor dir only counts as installed dependencies when the composer
772
+ * autoloader is a real file. Magento tracks `vendor/.htaccess`, so every
773
+ * worktree has a vendor/ stub — that stub must not pass for real deps, and
774
+ * must not block the container bind that provides them.
775
+ */
776
+ export async function vendorHasAutoload(dir: string): Promise<boolean> {
777
+ try {
778
+ return (await stat(join(dir, 'vendor', 'autoload.php'))).isFile()
779
+ } catch {
780
+ return false
781
+ }
782
+ }
783
+
784
+ /**
785
+ * Share the primary checkout's `vendor/` (and `app/etc/env.php` when the
786
+ * auditor needs a database) into the review worktree via symlinks, so
787
+ * phpunit/static analysis run against real dependencies instead of falling
788
+ * back to static-only. Links point INTO the worktree only — the primary
789
+ * checkout is never written to — and `git worktree remove --force` deletes
790
+ * the links along with the worktree. Missing sources, or targets that
791
+ * already exist (e.g. Magento's tracked `vendor/.htaccess` stub), are
792
+ * skipped quietly: the review still runs, the auditor discloses it.
793
+ */
794
+ export async function linkVendorIntoWorktree(localRepoPath: string, worktreePath: string): Promise<string[]> {
795
+ const linked: string[] = []
796
+ const candidates: Array<{ source: string; target: string; dir: boolean; needsAutoload: boolean }> = [
797
+ { source: join(localRepoPath, 'vendor'), target: join(worktreePath, 'vendor'), dir: true, needsAutoload: true },
798
+ { source: join(localRepoPath, 'app', 'etc', 'env.php'), target: join(worktreePath, 'app', 'etc', 'env.php'), dir: false, needsAutoload: false },
799
+ ]
800
+ let advertised = false
801
+ for (const { source, target, dir, needsAutoload } of candidates) {
802
+ let isDir = false
803
+ try {
804
+ isDir = (await stat(source)).isDirectory()
805
+ if (dir !== isDir) continue
806
+ } catch {
807
+ continue
808
+ }
809
+ if (needsAutoload && !(await vendorHasAutoload(localRepoPath))) continue
810
+ if ((await lstat(target).catch(() => undefined)) !== undefined) {
811
+ console.error(`maestro-orchestrator: link target exists, skipping ${target}`)
812
+ continue
813
+ }
814
+ try {
815
+ if (!advertised) {
816
+ const sha = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: localRepoPath, timeout: GIT_TIMEOUT_MS })
817
+ .then(({ stdout }) => stdout.trim()).catch(() => 'unknown')
818
+ console.error(`maestro-orchestrator: linking vendor from ${localRepoPath} @ ${sha} into ${worktreePath}`)
819
+ advertised = true
820
+ }
821
+ if (!dir) await mkdir(join(worktreePath, 'app', 'etc'), { recursive: true })
822
+ await symlink(source, target, dir ? 'dir' : 'file')
823
+ linked.push(target)
824
+ } catch (err) {
825
+ console.error(`maestro-orchestrator: failed to link ${source} into worktree:`, err)
826
+ }
827
+ }
828
+ if (!advertised) {
829
+ console.error(`maestro-orchestrator: no vendor/ in ${localRepoPath} — worktree ${worktreePath} runs without shared dependencies`)
830
+ }
831
+ return linked
832
+ }
833
+
834
+ /** Container path of the project root inside govard services. */
835
+ const GOVARD_CONTAINER_WORKDIR = '/var/www/html'
836
+
837
+ /**
838
+ * Render a compose override that bind-mounts the primary checkout's vendor/
839
+ * read-only into the php service. A host-side symlink alone dangles inside
840
+ * containers (absolute host path), so container tools (phpunit) need this
841
+ * bind to see real dependencies.
842
+ *
843
+ * The project mount (`.`) MUST be repeated first: govard merges overrides
844
+ * with MergeMap, which REPLACES lists instead of appending — an override
845
+ * carrying only the vendor bind would wipe `.:<workdir>` and leave the
846
+ * container docroot holding nothing but vendor/. Relative `.` resolves
847
+ * against the project root because govard passes --project-directory.
848
+ *
849
+ * Only the always-present `php` service is targeted: `php-debug` drops out
850
+ * of the rendered base when xdebug is off, and a volumes-only override
851
+ * entry would recreate it as a hollow service that fails compose validation.
852
+ */
853
+ export function buildVendorOverrideYaml(vendorHostPath: string, envHostPath?: string, containerWorkDir: string = GOVARD_CONTAINER_WORKDIR): string {
854
+ const volumes = [
855
+ `.:${containerWorkDir}`,
856
+ `${vendorHostPath}:${containerWorkDir}/vendor:ro`,
857
+ ...(envHostPath !== undefined ? [`${envHostPath}:${containerWorkDir}/app/etc/env.php:ro`] : []),
858
+ ]
859
+ const service = ` volumes:\n${volumes.map((v) => ` - ${v}\n`).join('')}`
860
+ return `services:\n php:\n${service}`
861
+ }
862
+
863
+ /**
864
+ * Drop the vendor bind override into the worktree (merged by govard via
865
+ * `.govard/docker-compose.override.yml`). Binds only when the primary
866
+ * checkout carries installed deps and the worktree does not — never shadow
867
+ * real deps with a possibly stale bind. Returns the written path, or
868
+ * undefined when skipped.
869
+ */
870
+ export async function writeContainerVendorOverride(localRepoPath: string, worktreePath: string): Promise<string | undefined> {
871
+ const vendorHostPath = join(localRepoPath, 'vendor')
872
+ if (!(await vendorHasAutoload(localRepoPath))) return undefined
873
+ // Our own host-side symlink still needs the bind (it dangles in-container);
874
+ // only a real installed worktree vendor makes the bind redundant.
875
+ const wtLink = await lstat(join(worktreePath, 'vendor')).catch(() => undefined)
876
+ if (wtLink?.isSymbolicLink() !== true && await vendorHasAutoload(worktreePath)) return undefined
877
+ const overridePath = join(worktreePath, '.govard', 'docker-compose.override.yml')
878
+ await mkdir(join(worktreePath, '.govard'), { recursive: true })
879
+ // A linked env.php dangles in-container like the vendor symlink did — bind
880
+ // the real file over it when the primary checkout carries one.
881
+ const envHostPath = join(localRepoPath, 'app', 'etc', 'env.php')
882
+ let envBind: string | undefined
883
+ try {
884
+ if ((await stat(envHostPath)).isFile()) envBind = envHostPath
885
+ } catch {
886
+ envBind = undefined
887
+ }
888
+ await writeFile(overridePath, buildVendorOverrideYaml(vendorHostPath, envBind), 'utf-8')
889
+ console.error(`maestro-orchestrator: container vendor bind ${vendorHostPath} -> ${GOVARD_CONTAINER_WORKDIR}/vendor (ro) for ${worktreePath}`)
890
+ return overridePath
891
+ }
892
+
649
893
  async function removeWorktree(worktreePath: string): Promise<void> {
650
894
  // Best-effort cleanup by design (a failure here must not block or fail the review that
651
895
  // already ran) — but a swallowed failure with zero visibility leaves an orphaned worktree
@@ -667,6 +911,9 @@ export function apply(ctx: Context, config: Config): void {
667
911
  // effect without a plugin restart.
668
912
  let effectiveAgentTimeoutMs = config.agentTimeoutMs
669
913
  async function runReviewer(worktreePath: string | undefined, payload: ReviewRequest, effective: { gitlabBaseUrl: string; gitlabToken: string; botUsername: string }, reviewProfile?: ReviewSkillProfile, modelSelection?: ModelSelection, incrementalBlock?: string): Promise<ReviewOutcome> {
914
+ // MR base SHA feeds govard diff-scope runs; undefined degrades to the
915
+ // tool's fail-fast guidance instead of a wasted govard invocation.
916
+ const lintDefaultBase = await fetchMrBaseSha(effective.gitlabBaseUrl, effective.gitlabToken, payload.projectId, payload.mrIid)
670
917
  const primaryOptions = agentOptionsForModel(modelSelection ?? ctx.agentDefaultModel.currentSelection())
671
918
  const fallbackOptions: ModelSelection = { provider: primaryOptions.provider, model: primaryOptions.model }
672
919
  let lastHandle: AgentHandle | undefined
@@ -701,7 +948,7 @@ export function apply(ctx: Context, config: Config): void {
701
948
  await agentCtx.plugin(ModuleCheckTool, { rootPath: worktreePath })
702
949
  await agentCtx.plugin(PhtmlEscapeScanTool, { rootPath: worktreePath })
703
950
  await agentCtx.plugin(ScopeSplitTool, { rootPath: worktreePath })
704
- await agentCtx.plugin(GovardAuditLintTool, { rootPath: worktreePath })
951
+ await agentCtx.plugin(GovardAuditLintTool, { rootPath: worktreePath, defaultBase: lintDefaultBase, allowXdebug: true })
705
952
  await agentCtx.plugin(PerfLogStatsTool, { rootPath: worktreePath })
706
953
  }
707
954
  },
@@ -717,8 +964,8 @@ export function apply(ctx: Context, config: Config): void {
717
964
  ? '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
965
  : `Call maestro_load_review_profile with {"profile":"${reviewProfile}"} before examining code. `
719
966
  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.`
967
+ ? buildReviewerScopePrompt({ scopeKind: 'discussion', discussionId: payload.scope.discussionId, path: payload.scope.path, line: payload.scope.line, profileInstruction })
968
+ : buildReviewerScopePrompt({ scopeKind: 'full', mode: payload.mode, profileInstruction })
722
969
  if (incrementalBlock !== undefined) scopePrompt = `${incrementalBlock}\n\n${scopePrompt}`
723
970
  handle.agent.followup(createUserMessage({
724
971
  content: [{ type: 'text', text: scopePrompt }],
@@ -759,7 +1006,7 @@ export function apply(ctx: Context, config: Config): void {
759
1006
  failures.push(`${locator}: ${err instanceof Error ? err.message : String(err)}`)
760
1007
  }
761
1008
  }
762
- return { summary: `${postedNew} new inline comment(s), ${postedReplies} thread(s) updated.`, failures }
1009
+ return { summary: `${postedNew} new inline comment(s), ${postedReplies} thread(s) updated.`, failures, severityCounts: countFindingSeverities(capturedFindings) }
763
1010
  } finally {
764
1011
  await handle.dispose()
765
1012
  }
@@ -822,7 +1069,7 @@ export function apply(ctx: Context, config: Config): void {
822
1069
  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.'
823
1070
  handle.agent.followup(createUserMessage({ content: [{ type: 'text', text: prompt }], source: { kind: 'user' } }))
824
1071
  await whenIdleWithTimeout(handle, effectiveAgentTimeoutMs)
825
- const output = finalAssistantOutput(handle.agent.session.events) ?? []
1072
+ const output = auditorOutputFromSession(handle.agent.session)
826
1073
  const text = output.map(block => ('text' in block ? block.text : '')).join('')
827
1074
  return `## Maestro Performance Audit\n\n${text}`
828
1075
  } finally {