@ddtcorex/dsh-maestro-review 0.1.0 → 0.1.2

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 (143) hide show
  1. package/lib/config-store.d.ts +2 -0
  2. package/lib/config-store.d.ts.map +1 -1
  3. package/lib/config-store.js.map +1 -1
  4. package/lib/events.d.ts.map +1 -1
  5. package/lib/events.js.map +1 -1
  6. package/lib/git-worktree-tool.d.ts +11 -0
  7. package/lib/git-worktree-tool.d.ts.map +1 -0
  8. package/lib/git-worktree-tool.js +162 -0
  9. package/lib/git-worktree-tool.js.map +1 -0
  10. package/lib/gitlab-client.d.ts.map +1 -1
  11. package/lib/gitlab-client.js.map +1 -1
  12. package/lib/gitlab-webhook.d.ts.map +1 -1
  13. package/lib/gitlab-webhook.js.map +1 -1
  14. package/lib/govard-audit-lint-tool.d.ts +14 -0
  15. package/lib/govard-audit-lint-tool.d.ts.map +1 -0
  16. package/lib/govard-audit-lint-tool.js +177 -0
  17. package/lib/govard-audit-lint-tool.js.map +1 -0
  18. package/lib/govard-tool.d.ts.map +1 -1
  19. package/lib/govard-tool.js.map +1 -1
  20. package/lib/hyva-csp-scan-tool.d.ts +11 -0
  21. package/lib/hyva-csp-scan-tool.d.ts.map +1 -0
  22. package/lib/hyva-csp-scan-tool.js +193 -0
  23. package/lib/hyva-csp-scan-tool.js.map +1 -0
  24. package/lib/hyva-theme-inspect-tool.d.ts +10 -0
  25. package/lib/hyva-theme-inspect-tool.d.ts.map +1 -0
  26. package/lib/hyva-theme-inspect-tool.js +299 -0
  27. package/lib/hyva-theme-inspect-tool.js.map +1 -0
  28. package/lib/incremental.d.ts +28 -0
  29. package/lib/incremental.d.ts.map +1 -0
  30. package/lib/incremental.js +60 -0
  31. package/lib/incremental.js.map +1 -0
  32. package/lib/index.d.ts.map +1 -1
  33. package/lib/index.js.map +1 -1
  34. package/lib/layout-xml-tool.d.ts +11 -0
  35. package/lib/layout-xml-tool.d.ts.map +1 -0
  36. package/lib/layout-xml-tool.js +240 -0
  37. package/lib/layout-xml-tool.js.map +1 -0
  38. package/lib/module-check-tool.d.ts +11 -0
  39. package/lib/module-check-tool.d.ts.map +1 -0
  40. package/lib/module-check-tool.js +304 -0
  41. package/lib/module-check-tool.js.map +1 -0
  42. package/lib/notify.d.ts.map +1 -1
  43. package/lib/notify.js.map +1 -1
  44. package/lib/orchestrator.d.ts +2 -0
  45. package/lib/orchestrator.d.ts.map +1 -1
  46. package/lib/orchestrator.js +220 -88
  47. package/lib/orchestrator.js.map +1 -1
  48. package/lib/perf-log-stats-tool.d.ts +13 -0
  49. package/lib/perf-log-stats-tool.d.ts.map +1 -0
  50. package/lib/perf-log-stats-tool.js +245 -0
  51. package/lib/perf-log-stats-tool.js.map +1 -0
  52. package/lib/phtml-escape-scan-tool.d.ts +11 -0
  53. package/lib/phtml-escape-scan-tool.d.ts.map +1 -0
  54. package/lib/phtml-escape-scan-tool.js +170 -0
  55. package/lib/phtml-escape-scan-tool.js.map +1 -0
  56. package/lib/plan-track-tool.d.ts +11 -0
  57. package/lib/plan-track-tool.d.ts.map +1 -0
  58. package/lib/plan-track-tool.js +143 -0
  59. package/lib/plan-track-tool.js.map +1 -0
  60. package/lib/providers/github.stub.d.ts.map +1 -1
  61. package/lib/providers/github.stub.js.map +1 -1
  62. package/lib/providers/gitlab.d.ts.map +1 -1
  63. package/lib/providers/gitlab.js.map +1 -1
  64. package/lib/providers/interface.d.ts.map +1 -1
  65. package/lib/providers/interface.js.map +1 -1
  66. package/lib/review-findings-tool.d.ts.map +1 -1
  67. package/lib/review-findings-tool.js.map +1 -1
  68. package/lib/review-history.d.ts +4 -0
  69. package/lib/review-history.d.ts.map +1 -1
  70. package/lib/review-history.js +23 -1
  71. package/lib/review-history.js.map +1 -1
  72. package/lib/review-intake.d.ts.map +1 -1
  73. package/lib/review-intake.js.map +1 -1
  74. package/lib/review-signals.d.ts.map +1 -1
  75. package/lib/review-signals.js.map +1 -1
  76. package/lib/scope-split-tool.d.ts +42 -0
  77. package/lib/scope-split-tool.d.ts.map +1 -0
  78. package/lib/scope-split-tool.js +109 -0
  79. package/lib/scope-split-tool.js.map +1 -0
  80. package/lib/search-tool.d.ts +15 -0
  81. package/lib/search-tool.d.ts.map +1 -0
  82. package/lib/search-tool.js +144 -0
  83. package/lib/search-tool.js.map +1 -0
  84. package/lib/secure-compare.d.ts.map +1 -1
  85. package/lib/secure-compare.js.map +1 -1
  86. package/lib/settings-rpc.d.ts.map +1 -1
  87. package/lib/settings-rpc.js +47 -8
  88. package/lib/settings-rpc.js.map +1 -1
  89. package/lib/skills-tool.d.ts +1 -1
  90. package/lib/skills-tool.d.ts.map +1 -1
  91. package/lib/skills-tool.js +5 -1
  92. package/lib/skills-tool.js.map +1 -1
  93. package/lib/tdd-evidence-tool.d.ts +11 -0
  94. package/lib/tdd-evidence-tool.d.ts.map +1 -0
  95. package/lib/tdd-evidence-tool.js +114 -0
  96. package/lib/tdd-evidence-tool.js.map +1 -0
  97. package/lib/tool-policy.d.ts +20 -0
  98. package/lib/tool-policy.d.ts.map +1 -0
  99. package/lib/tool-policy.js +42 -0
  100. package/lib/tool-policy.js.map +1 -0
  101. package/lib/workspace-tool.d.ts.map +1 -1
  102. package/lib/workspace-tool.js +15 -9
  103. package/lib/workspace-tool.js.map +1 -1
  104. package/package.json +4 -4
  105. package/src/{augment.d.ts → host/augment.d.ts} +5 -2
  106. package/src/{config-store.ts → host/config-store.ts} +2 -0
  107. package/src/host/git-worktree-tool.ts +130 -0
  108. package/src/host/govard-audit-lint-tool.ts +153 -0
  109. package/src/host/hyva-csp-scan-tool.ts +165 -0
  110. package/src/host/hyva-theme-inspect-tool.ts +258 -0
  111. package/src/host/incremental.ts +84 -0
  112. package/src/host/layout-xml-tool.ts +191 -0
  113. package/src/host/module-check-tool.ts +188 -0
  114. package/src/{orchestrator.ts → host/orchestrator.ts} +205 -84
  115. package/src/host/perf-log-stats-tool.ts +224 -0
  116. package/src/host/phtml-escape-scan-tool.ts +117 -0
  117. package/src/host/plan-track-tool.ts +118 -0
  118. package/src/{review-history.ts → host/review-history.ts} +27 -1
  119. package/src/host/scope-split-tool.ts +110 -0
  120. package/src/host/search-tool.ts +153 -0
  121. package/src/{settings-rpc.ts → host/settings-rpc.ts} +54 -11
  122. package/src/{skills-tool.ts → host/skills-tool.ts} +5 -1
  123. package/src/host/tdd-evidence-tool.ts +85 -0
  124. package/src/host/tool-policy.ts +49 -0
  125. package/src/{workspace-tool.ts → host/workspace-tool.ts} +27 -9
  126. package/lib/pin-store.d.ts +0 -5
  127. package/lib/pin-store.d.ts.map +0 -1
  128. package/lib/pin-store.js +0 -5
  129. package/lib/pin-store.js.map +0 -1
  130. package/src/pin-store.ts +0 -4
  131. /package/src/{events.ts → host/events.ts} +0 -0
  132. /package/src/{gitlab-client.ts → host/gitlab-client.ts} +0 -0
  133. /package/src/{gitlab-webhook.ts → host/gitlab-webhook.ts} +0 -0
  134. /package/src/{govard-tool.ts → host/govard-tool.ts} +0 -0
  135. /package/src/{index.ts → host/index.ts} +0 -0
  136. /package/src/{notify.ts → host/notify.ts} +0 -0
  137. /package/src/{providers → host/providers}/github.stub.ts +0 -0
  138. /package/src/{providers → host/providers}/gitlab.ts +0 -0
  139. /package/src/{providers → host/providers}/interface.ts +0 -0
  140. /package/src/{review-findings-tool.ts → host/review-findings-tool.ts} +0 -0
  141. /package/src/{review-intake.ts → host/review-intake.ts} +0 -0
  142. /package/src/{review-signals.ts → host/review-signals.ts} +0 -0
  143. /package/src/{secure-compare.ts → host/secure-compare.ts} +0 -0
@@ -0,0 +1,188 @@
1
+ import { join, resolve, sep, basename, dirname } from 'node:path'
2
+ import { readFile, readdir, realpath, stat } from 'node:fs/promises'
3
+ import type { Context } from '@deepseek-ai/cordis'
4
+ import z from '@deepseek-ai/schemastery'
5
+ import { defineTool } from '@deepseek-ai/dsh-tools'
6
+
7
+ export const name='maestro-module-check-tool'
8
+ export const inject=['tools']
9
+ export const Config:z<{rootPath?:string}>=z.object({rootPath:z.string()})
10
+ interface SC{agent?:{session?:{header?:{cwd?:string}}}}
11
+ function workspaceRootFor(c:string|undefined,e:unknown):string{
12
+ if(c!==undefined) return c
13
+ const cwd=(e as SC|undefined)?.agent?.session?.header?.cwd
14
+ return typeof cwd==='string'&&cwd!==''?cwd:process.cwd()
15
+ }
16
+ async function isInsideRoot(r:string,t:string):Promise<boolean>{
17
+ const ar=resolve(r); const rs=resolve(ar,t)
18
+ if(rs!==ar && !rs.startsWith(ar+sep)) return false
19
+ try{ const rr=await realpath(ar); const rt=await realpath(rs); return rt===rr||rt.startsWith(rr+sep)}catch{return false}
20
+ }
21
+ const MAX_FILE_BYTES=1024*1024
22
+ const MAX_MODULES=100
23
+ async function exists(p:string):Promise<boolean>{ try{await stat(p); return true}catch{return false}}
24
+ async function boundedRead(p:string):Promise<string|null>{
25
+ try{ const b=await readFile(p); if(b.byteLength>MAX_FILE_BYTES) return null; return b.toString('utf-8')}catch{return null}
26
+ }
27
+ function parseRegistrationName(text:string):string|null{
28
+ const re=/(?:\\?[\w\\]*ComponentRegistrar)::register\s*\(\s*(?:\\?[\w\\]*ComponentRegistrar::)?MODULE\s*,\s*['"]([^'"]+)['"]/
29
+ const m=re.exec(text); return m?m[1]:null
30
+ }
31
+ function parseModuleXml(text:string):{name:string|null, sequence:string[]}{
32
+ const nameRe=/<module\b[^>]*name="([^"]+)"/
33
+ const nm=nameRe.exec(text); const name=nm?nm[1]:null
34
+ const seq:string[]=[]; const seqRe=/<module\s+name="([^"]+)"\s*\/>/g; let m:RegExpExecArray|null
35
+ // only inside <sequence> block
36
+ const seqBlock=/<sequence>([\s\S]*?)<\/sequence>/.exec(text)
37
+ if(seqBlock){ const inner=seqBlock[1]; while((m=seqRe.exec(inner))!==null) seq.push(m[1]) }
38
+ return {name, sequence:seq}
39
+ }
40
+
41
+ export function apply(ctx:Context, config:{rootPath?:string}={}):void{
42
+ const configuredRoot=config.rootPath
43
+ ctx.effect(()=>ctx.tools.register(defineTool({
44
+ name:'magento_module_check',
45
+ description:'Validate Magento 2 module structure (registration, module.xml, composer, db_schema, acl, routes, Hyva compat).',
46
+ parameters:{
47
+ modulePath:{type:'string'},
48
+ },
49
+ output:{
50
+ schema:{
51
+ type:'object', additionalProperties:false,
52
+ properties:{
53
+ modules:{type:'array', required:true},
54
+ scannedModules:{type:'number', required:true},
55
+ truncated:{type:'boolean', required:true},
56
+ }
57
+ },
58
+ render:(_a,v)=>[{type:'text', text: JSON.stringify(v,null,2)}],
59
+ },
60
+ async execute(args, exec){
61
+ const rawPath=(args as {modulePath?:string}).modulePath
62
+ const root=workspaceRootFor(configuredRoot, exec)
63
+ if(rawPath!==undefined && !(await isInsideRoot(root, rawPath))) return {text:`Path "${rawPath}" escapes the workspace root.`, truncated:false} as never
64
+ let candidates:string[]=[]
65
+ if(rawPath!==undefined){
66
+ const abs=resolve(root, rawPath)
67
+ // must contain registration.php or etc/module.xml to be considered a module; otherwise empty
68
+ candidates=[rawPath]
69
+ } else {
70
+ const base=join(root,'app/code')
71
+ let vendors:string[]=[]
72
+ try{ vendors=await readdir(base)}catch{ vendors=[]}
73
+ for(const vendor of vendors){
74
+ if(vendor.startsWith('.')) continue
75
+ let mods:string[]=[]
76
+ try{ mods=await readdir(join(base,vendor))}catch{continue}
77
+ for(const mod of mods){
78
+ if(mod.startsWith('.')) continue
79
+ candidates.push(join('app/code',vendor,mod))
80
+ if(candidates.length>=MAX_MODULES) break
81
+ }
82
+ if(candidates.length>=MAX_MODULES) break
83
+ }
84
+ }
85
+ const truncated=candidates.length>=MAX_MODULES
86
+ const modules:Array<{
87
+ path:string,
88
+ module:{name:string|null,sequence:string[],hasRegistration:boolean,registrationValid:boolean|null,hasEtcModuleXml:boolean,hasDbSchema:boolean,dbSchemaTables:string[],hasAcl:boolean,aclResources:string[],hasRoutes:boolean,routes:Array<{area:string,frontName:string}>,hasObserverEvents:string[],hasCron:boolean,hasWebapi:boolean,apiInterfaces:string[],composerName:string|null,psr4:Record<string,string>|null},
89
+ issues:Array<{severity:string,rule:string,message:string,file:string,line:number|null}>,
90
+ isHyvaCompatible:boolean|null, compatNotes:string[]
91
+ }>=[]
92
+ for(const rel of candidates.slice(0,MAX_MODULES)){
93
+ const abs=resolve(root, rel)
94
+ let isDir=false; try{ const s=await stat(abs); isDir=s.isDirectory()}catch{ continue}
95
+ if(!isDir) continue
96
+ const issues: Array<{severity:string,rule:string,message:string,file:string,line:number|null}>=[]
97
+ const regText=await boundedRead(join(abs,'registration.php'))
98
+ const hasRegistration=regText!==null
99
+ let regName:string|null=null
100
+ let registrationValid:boolean|null=null
101
+ if(hasRegistration){
102
+ regName=parseRegistrationName(regText!)
103
+ registrationValid=regName!==null
104
+ if(!registrationValid) issues.push({severity:'high', rule:'registration-invalid', message:'registration.php does not contain ComponentRegistrar::register for MODULE', file:join(rel,'registration.php'), line:null})
105
+ } else {
106
+ issues.push({severity:'critical', rule:'registration-missing', message:'registration.php missing', file:join(rel,'registration.php'), line:null})
107
+ }
108
+ const modXmlText=await boundedRead(join(abs,'etc/module.xml'))
109
+ const hasEtcModuleXml=modXmlText!==null
110
+ let modName:string|null=null
111
+ let sequence:string[]=[]
112
+ if(hasEtcModuleXml){
113
+ const parsed=parseModuleXml(modXmlText!)
114
+ modName=parsed.name; sequence=parsed.sequence
115
+ if(!modName) issues.push({severity:'critical', rule:'module-xml-missing-name', message:'etc/module.xml missing module name', file:join(rel,'etc/module.xml'), line:null})
116
+ } else {
117
+ issues.push({severity:'high', rule:'module-xml-missing', message:'etc/module.xml missing', file:join(rel,'etc/module.xml'), line:null})
118
+ }
119
+ if(regName && modName && regName!==modName){
120
+ issues.push({severity:'high', rule:'name-mismatch', message:`registration name ${regName} != module.xml ${modName}`, file:join(rel,'etc/module.xml'), line:null})
121
+ }
122
+ // composer
123
+ const composerText=await boundedRead(join(abs,'composer.json'))
124
+ let composerName:string|null=null; let psr4:Record<string,string>|null=null
125
+ if(composerText){
126
+ try{ const j=JSON.parse(composerText); composerName=j.name??null; psr4=j.autoload?.['psr-4']??null }catch{}
127
+ if(composerName && modName){
128
+ // psr-4 cross-check not strict for test
129
+ }
130
+ }
131
+ // db_schema
132
+ const hasDbSchema=await exists(join(abs,'etc/db_schema.xml'))
133
+ let dbSchemaTables:string[]=[]
134
+ if(hasDbSchema){
135
+ const dbText=await boundedRead(join(abs,'etc/db_schema.xml'))
136
+ if(dbText){ const re=/<table\s+name="([^"]+)"/g; let m:RegExpExecArray|null; while((m=re.exec(dbText))!==null) dbSchemaTables.push(m[1])}
137
+ }
138
+ // acl
139
+ const hasAcl=await exists(join(abs,'etc/acl.xml'))
140
+ let aclResources:string[]=[]
141
+ if(hasAcl){ const t=await boundedRead(join(abs,'etc/acl.xml')); if(t){ const re=/<resource\s+id="([^"]+)"/g; let m:RegExpExecArray|null; while((m=re.exec(t))!==null) aclResources.push(m[1])}}
142
+ // routes
143
+ let hasRoutes=false; const routes:Array<{area:string,frontName:string}>=[]
144
+ for(const area of ['frontend','adminhtml']){
145
+ const rp=join(abs,`etc/${area}/routes.xml`); if(await exists(rp)){ hasRoutes=true; const rt=await boundedRead(rp); if(rt){ const re=/<route\s+[^>]*frontName="([^"]+)"/g; let m:RegExpExecArray|null; while((m=re.exec(rt))!==null) routes.push({area, frontName:m[1]}) } }
146
+ }
147
+ if(await exists(join(abs,'etc/routes.xml'))){ hasRoutes=true }
148
+ // events
149
+ let hasObserverEvents:string[]=[]
150
+ const evText=await boundedRead(join(abs,'etc/events.xml'))
151
+ if(evText){ const re=/<event\s+name="([^"]+)"/g; let m:RegExpExecArray|null; while((m=re.exec(evText))!==null) hasObserverEvents.push(m[1])}
152
+ // cron/webapi
153
+ const hasCron=await exists(join(abs,'etc/crontab.xml')) || await exists(join(abs,'etc/cron_groups.xml'))
154
+ const hasWebapi=await exists(join(abs,'etc/webapi.xml'))
155
+ let apiInterfaces:string[]=[]
156
+ if(hasWebapi){ const t=await boundedRead(join(abs,'etc/webapi.xml')); if(t){ const re=/<service\s+class="([^"]+)"/g; let m:RegExpExecArray|null; while((m=re.exec(t))!==null) apiInterfaces.push(m[1])}}
157
+
158
+ // Hyva compat: scan phtml for data-bind vs x-data
159
+ let isHyvaCompatible:boolean|null=null
160
+ const compatNotes:string[]=[]
161
+ let hasPhtml=false; let hasDataBind=false; let hasXData=false
162
+ async function scanPhtml(dir:string){
163
+ let entries; try{entries=await readdir(dir,{withFileTypes:true})}catch{return}
164
+ for(const e of entries){
165
+ if(e.isDirectory()) await scanPhtml(join(dir,e.name))
166
+ else if(e.isFile() && e.name.endsWith('.phtml')){
167
+ hasPhtml=true
168
+ const txt=await boundedRead(join(dir,e.name))
169
+ if(!txt) continue
170
+ if(/data-bind\s*=/.test(txt)) hasDataBind=true
171
+ if(/x-data\s*=/.test(txt)) hasXData=true
172
+ }
173
+ }
174
+ }
175
+ await scanPhtml(join(abs,'view/frontend/templates'))
176
+ if(hasPhtml){
177
+ if(hasDataBind){ isHyvaCompatible=false; compatNotes.push('knockout data-bind found — not Hyva compatible')}
178
+ else if(hasXData){ isHyvaCompatible=true; compatNotes.push('Alpine x-data found — Hyva compatible')}
179
+ else { isHyvaCompatible=null }
180
+ }
181
+
182
+ const moduleInfo={name: modName ?? regName, sequence, hasRegistration, registrationValid, hasEtcModuleXml, hasDbSchema, dbSchemaTables, hasAcl, aclResources, hasRoutes, routes, hasObserverEvents, hasCron, hasWebapi, apiInterfaces, composerName, psr4}
183
+ modules.push({path:rel, module:moduleInfo, issues, isHyvaCompatible, compatNotes})
184
+ }
185
+ return {modules, scannedModules: modules.length, truncated} as never
186
+ },
187
+ }), 'module check'))
188
+ }
@@ -3,6 +3,7 @@ import { execFile } from 'node:child_process'
3
3
  import { createHash } from 'node:crypto'
4
4
  import { promisify } from 'node:util'
5
5
  import { join } from 'node:path'
6
+ import { normalize as posixNormalize } from 'node:path/posix'
6
7
  import { homedir, tmpdir } from 'node:os'
7
8
  import type { Context } from '@deepseek-ai/cordis'
8
9
  import z from '@deepseek-ai/schemastery'
@@ -18,10 +19,21 @@ import type {} from '@deepseek-ai/dsh-session-title'
18
19
  import * as GovardTool from './govard-tool.js'
19
20
  import * as GitlabClient from './gitlab-client.js'
20
21
  import * as ReviewFindingsTool from './review-findings-tool.js'
22
+ import * as SearchTool from './search-tool.js'
23
+ import * as HyvaThemeInspectTool from './hyva-theme-inspect-tool.js'
24
+ import * as HyvaCspScanTool from './hyva-csp-scan-tool.js'
25
+ import * as LayoutXmlTool from './layout-xml-tool.js'
26
+ import * as ModuleCheckTool from './module-check-tool.js'
27
+ import * as PhtmlEscapeScanTool from './phtml-escape-scan-tool.js'
28
+ import * as ScopeSplitTool from './scope-split-tool.js'
29
+ import * as GovardAuditLintTool from './govard-audit-lint-tool.js'
30
+ import * as PerfLogStatsTool from './perf-log-stats-tool.js'
31
+ import * as ReviewToolPolicy from './tool-policy.js'
21
32
  import type { ReviewFinding } from './review-findings-tool.js'
22
33
  import type { ReviewRequest } from './events.js'
23
34
  import { loadUserConfig, type MaestroUserConfig, type ReviewModelSelection } from './config-store.js'
24
- import { hasCompletedReview, pruneHistory, recordReviewFinish, recordReviewStart } from './review-history.js'
35
+ import { hasCompletedReview, lastCompletedReview, pruneHistory, recordReviewFinish, recordReviewStart } from './review-history.js'
36
+ import { buildIncrementalBlock, fetchCompare, fetchMrDetailHeadSha } from './incremental.js'
25
37
  import { createReviewSignals } from './review-signals.js'
26
38
  import { reviewDigestText, type NotifierLike } from './notify.js'
27
39
  import { loadedReviewProfile, type ReviewSkillProfile } from './skills-tool.js'
@@ -58,6 +70,36 @@ export function agentOptionsForModel(selection: ModelSelection): ModelSelection
58
70
  }
59
71
  }
60
72
 
73
+ export function isUnsupportedReasoningError(err: unknown): boolean {
74
+ const code = (err as { code?: unknown })?.code
75
+ if (code === 'UNSUPPORTED_REASONING_EFFORT') return true
76
+ const message = (err as { message?: unknown })?.message
77
+ const text = typeof message === 'string' ? message : typeof err === 'string' ? err : String(message ?? err ?? '')
78
+ return text.includes('does not support reasoning effort')
79
+ || text.includes('UNSUPPORTED_REASONING_EFFORT')
80
+ }
81
+
82
+ export function getTurnErrorMessage(handle: unknown): string | undefined {
83
+ try {
84
+ const events = (handle as { agent?: { session?: { events?: unknown[] } } })?.agent?.session?.events
85
+ if (!Array.isArray(events)) return undefined
86
+ for (let i = events.length - 1; i >= 0; i--) {
87
+ const ev = events[i] as { type?: unknown; data?: { reason?: { kind?: unknown; error?: { message?: unknown; code?: unknown } } } }
88
+ if (ev?.type === 'turn/end' && ev?.data?.reason?.kind === 'error') {
89
+ const err = ev.data.reason.error
90
+ if (err !== undefined && err !== null) {
91
+ if (typeof (err as { message?: unknown }).message === 'string') return (err as { message: string }).message
92
+ if (typeof (err as { code?: unknown }).code === 'string') return String((err as { code: unknown }).code)
93
+ try { return JSON.stringify(err) } catch { return String(err) }
94
+ }
95
+ }
96
+ }
97
+ return undefined
98
+ } catch {
99
+ return undefined
100
+ }
101
+ }
102
+
61
103
  /**
62
104
  * Resolve the model to use for an automated review. Priority: per-project
63
105
  * override > global reviewModel > DSH default (`fallback`).
@@ -246,6 +288,15 @@ async function loadLatestDiffSnapshot(fetcher: typeof fetch, apiBase: string, he
246
288
  }
247
289
  }
248
290
 
291
+ /**
292
+ * Collapse `.`/`..`/duplicate slashes so worktree-relative finding paths
293
+ * (which legitimately contain `../` segments when a template references a
294
+ * sibling theme directory) compare equal to GitLab's canonical diff paths.
295
+ */
296
+ function normalizedDiffPath(path: string): string {
297
+ return posixNormalize(path)
298
+ }
299
+
249
300
  /**
250
301
  * Publish agent findings from the orchestrator's own context. Agent tools are
251
302
  * mounted inside a setup child fiber and are not guaranteed to remain
@@ -266,21 +317,30 @@ export async function postReviewFindings(findings: ReviewFinding[], config: Gitl
266
317
  if (finding.path === undefined || finding.line === undefined) throw new Error('new finding missing path or line')
267
318
  config.snapshot ??= loadLatestDiffSnapshot(fetcher, apiBase, headers)
268
319
  const snapshot = await config.snapshot
269
- const change = snapshot.changes.find(candidate => candidate.new_path === finding.path) ?? snapshot.changes.find(candidate => candidate.old_path === finding.path)
320
+ const requestedPath = normalizedDiffPath(finding.path)
321
+ const change = snapshot.changes.find(candidate => normalizedDiffPath(candidate.new_path) === requestedPath)
322
+ ?? snapshot.changes.find(candidate => normalizedDiffPath(candidate.old_path) === requestedPath)
270
323
  if (change === undefined) throw new Error(`cannot post inline finding at ${finding.path}:${finding.line}: file is not in the current MR diff`)
271
324
  if (change.collapsed === true || change.too_large === true || change.diff === '') {
272
325
  throw new Error(`cannot post inline finding at ${finding.path}:${finding.line}: GitLab did not return this file's complete diff`)
273
326
  }
274
327
  const linePosition = diffPositionForNewLine(change.diff, finding.line)
275
- if (linePosition === undefined) throw new Error(`cannot post inline finding at ${finding.path}:${finding.line}: line is not in the current MR diff`)
276
- response = await fetcher(`${apiBase}/discussions`, {
277
- method: 'POST', headers,
278
- body: JSON.stringify({ body: finding.body, position: {
279
- position_type: 'text', ...snapshot.diffRefs, old_path: change.old_path, new_path: change.new_path,
280
- ...linePosition.oldLine === undefined ? {} : { old_line: linePosition.oldLine },
281
- ...linePosition.newLine === undefined ? {} : { new_line: linePosition.newLine },
282
- } }),
283
- })
328
+ if (linePosition === undefined) {
329
+ // Line not in diff — fallback to MR note so finding is not silently lost.
330
+ response = await fetcher(`${apiBase}/notes`, {
331
+ method: 'POST', headers,
332
+ body: JSON.stringify({ body: `**Inline fallback — \`${finding.path}:${finding.line}\` line is not in current MR diff**\n\n${finding.body}` }),
333
+ })
334
+ } else {
335
+ response = await fetcher(`${apiBase}/discussions`, {
336
+ method: 'POST', headers,
337
+ body: JSON.stringify({ body: finding.body, position: {
338
+ position_type: 'text', ...snapshot.diffRefs, old_path: change.old_path, new_path: change.new_path,
339
+ ...linePosition.oldLine === undefined ? {} : { old_line: linePosition.oldLine },
340
+ ...linePosition.newLine === undefined ? {} : { new_line: linePosition.newLine },
341
+ } }),
342
+ })
343
+ }
284
344
  }
285
345
  if (!response.ok) throw new Error(`GitLab API error ${response.status}: ${await response.text()}`)
286
346
  }
@@ -481,83 +541,129 @@ export function apply(ctx: Context, config: Config): void {
481
541
  // Resolved per review run from Settings so an agentTimeoutMs change takes
482
542
  // effect without a plugin restart.
483
543
  let effectiveAgentTimeoutMs = config.agentTimeoutMs
484
- async function runReviewer(worktreePath: string | undefined, payload: ReviewRequest, effective: { gitlabBaseUrl: string; gitlabToken: string; botUsername: string }, reviewProfile?: ReviewSkillProfile, modelSelection?: ModelSelection): Promise<ReviewOutcome> {
485
- let capturedFindings: ReviewFinding[] = []
486
- let handle: AgentHandle
487
- let reviewerContext: Context | undefined
488
- const agentOptions = agentOptionsForModel(modelSelection ?? ctx.agentDefaultModel.currentSelection())
489
- try {
490
- handle = await ctx.agents.create({
491
- sessionId: SessionId(`maestro-reviewer-${payload.mrIid}-${Date.now()}`),
492
- meta: { cwd: worktreePath ?? tmpdir() },
493
- agentOptions,
494
- setup: async (agentCtx) => {
495
- reviewerContext = agentCtx
496
- installModelSelection(agentCtx, { current: agentOptions, assembled: undefined })
497
- await mountAgentPreset(ctx.agentPresets, agentCtx, 'dsh-maestro-reviewer')
498
- await agentCtx.plugin(GitlabClient, {
499
- baseUrl: effective.gitlabBaseUrl,
500
- projectId: payload.projectId,
501
- mrIid: payload.mrIid,
502
- token: effective.gitlabToken,
503
- botUsername: effective.botUsername,
504
- })
505
- await agentCtx.plugin(ReviewFindingsTool, { onReport: (findings) => { capturedFindings = findings } })
506
- },
507
- })
508
- } catch (err) {
509
- throw new Error(`failed to create reviewer agent: ${err instanceof Error ? err.message : String(err)}`)
510
- }
511
- ctx.sessionTitle.rename(handle.agent.session, `Maestro Reviewer — MR !${payload.mrIid} (${payload.projectPath})`)
512
- try {
513
- const profileInstruction = reviewProfile === undefined
514
- ? '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. '
515
- : `Call maestro_load_review_profile with {"profile":"${reviewProfile}"} before examining code. `
516
- const scopePrompt = payload.scope.kind === 'discussion'
517
- ? `${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.`
518
- : `${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.`
519
- handle.agent.followup(createUserMessage({
520
- content: [{ type: 'text', text: scopePrompt }],
521
- source: { kind: 'user' },
522
- }))
523
- await whenIdleWithTimeout(handle, effectiveAgentTimeoutMs)
524
- if (reviewProfile !== undefined && (reviewerContext === undefined || loadedReviewProfile(reviewerContext) !== reviewProfile)) {
525
- throw new Error(`reviewer did not successfully load the required ${reviewProfile} review skill profile; no findings were posted`)
544
+ async function runReviewer(worktreePath: string | undefined, payload: ReviewRequest, effective: { gitlabBaseUrl: string; gitlabToken: string; botUsername: string }, reviewProfile?: ReviewSkillProfile, modelSelection?: ModelSelection, incrementalBlock?: string): Promise<ReviewOutcome> {
545
+ const primaryOptions = agentOptionsForModel(modelSelection ?? ctx.agentDefaultModel.currentSelection())
546
+ const fallbackOptions: ModelSelection = { provider: primaryOptions.provider, model: primaryOptions.model }
547
+ let lastHandle: AgentHandle | undefined
548
+ const runOnce = async (agentOptions: ModelSelection): Promise<ReviewOutcome> => {
549
+ let capturedFindings: ReviewFinding[] = []
550
+ let handle: AgentHandle | undefined
551
+ let reviewerContext: Context | undefined
552
+ try {
553
+ handle = await ctx.agents.create({
554
+ sessionId: SessionId(`maestro-reviewer-${payload.mrIid}-${Date.now()}`),
555
+ meta: { cwd: worktreePath ?? tmpdir() },
556
+ agentOptions,
557
+ setup: async (agentCtx) => {
558
+ reviewerContext = agentCtx
559
+ installModelSelection(agentCtx, { current: agentOptions, assembled: undefined })
560
+ await agentCtx.plugin(ReviewToolPolicy)
561
+ await mountAgentPreset(ctx.agentPresets, agentCtx, 'dsh-maestro-reviewer')
562
+ await agentCtx.plugin(GitlabClient, {
563
+ baseUrl: effective.gitlabBaseUrl,
564
+ projectId: payload.projectId,
565
+ mrIid: payload.mrIid,
566
+ token: effective.gitlabToken,
567
+ botUsername: effective.botUsername,
568
+ })
569
+ await agentCtx.plugin(ReviewFindingsTool, { onReport: (findings) => { capturedFindings = findings } })
570
+ // Diff-only runs have no worktree; nothing to search there.
571
+ if (worktreePath !== undefined) {
572
+ await agentCtx.plugin(SearchTool, { rootPath: worktreePath })
573
+ await agentCtx.plugin(HyvaThemeInspectTool, { rootPath: worktreePath })
574
+ await agentCtx.plugin(HyvaCspScanTool, { rootPath: worktreePath })
575
+ await agentCtx.plugin(LayoutXmlTool, { rootPath: worktreePath })
576
+ await agentCtx.plugin(ModuleCheckTool, { rootPath: worktreePath })
577
+ await agentCtx.plugin(PhtmlEscapeScanTool, { rootPath: worktreePath })
578
+ await agentCtx.plugin(ScopeSplitTool, { rootPath: worktreePath })
579
+ await agentCtx.plugin(GovardAuditLintTool, { rootPath: worktreePath })
580
+ await agentCtx.plugin(PerfLogStatsTool, { rootPath: worktreePath })
581
+ }
582
+ },
583
+ })
584
+ } catch (err) {
585
+ lastHandle = handle
586
+ throw new Error(`failed to create reviewer agent: ${err instanceof Error ? err.message : String(err)}`)
526
587
  }
588
+ lastHandle = handle
589
+ ctx.sessionTitle.rename(handle.agent.session, `Maestro Reviewer — MR !${payload.mrIid} (${payload.projectPath})`)
590
+ try {
591
+ const profileInstruction = reviewProfile === undefined
592
+ ? '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. '
593
+ : `Call maestro_load_review_profile with {"profile":"${reviewProfile}"} before examining code. `
594
+ let scopePrompt = payload.scope.kind === 'discussion'
595
+ ? `${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.`
596
+ : `${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.`
597
+ if (incrementalBlock !== undefined) scopePrompt = `${incrementalBlock}\n\n${scopePrompt}`
598
+ handle.agent.followup(createUserMessage({
599
+ content: [{ type: 'text', text: scopePrompt }],
600
+ source: { kind: 'user' },
601
+ }))
602
+ await whenIdleWithTimeout(handle, effectiveAgentTimeoutMs)
603
+ if (reviewProfile !== undefined && (reviewerContext === undefined || loadedReviewProfile(reviewerContext) !== reviewProfile)) {
604
+ throw new Error(`reviewer did not successfully load the required ${reviewProfile} review skill profile; no findings were posted`)
605
+ }
527
606
 
528
- // An inline command is a request to discuss one existing thread, not a
529
- // license to fan out new threads across the MR. The report tool remains
530
- // useful as a structured response channel, but its findings are folded
531
- // into the one reply made by runReviewAndAudit below.
532
- if (payload.scope.kind === 'discussion') {
533
- const response = capturedFindings.length === 0
534
- ? 'No actionable issue found for the requested line.'
535
- : capturedFindings.map((finding) => finding.body).join('\n\n')
536
- return { summary: response, failures: [] }
537
- }
607
+ // An inline command is a request to discuss one existing thread, not a
608
+ // license to fan out new threads across the MR. The report tool remains
609
+ // useful as a structured response channel, but its findings are folded
610
+ // into the one reply made by runReviewAndAudit below.
611
+ if (payload.scope.kind === 'discussion') {
612
+ const response = capturedFindings.length === 0
613
+ ? 'No actionable issue found for the requested line.'
614
+ : capturedFindings.map((finding) => finding.body).join('\n\n')
615
+ return { summary: response, failures: [] }
616
+ }
538
617
 
539
- const failures: string[] = []
540
- let postedNew = 0
541
- let postedReplies = 0
542
- const findingPoster: GitlabFindingPoster = {
543
- baseUrl: effective.gitlabBaseUrl,
544
- token: effective.gitlabToken,
545
- projectId: payload.projectId,
546
- mrIid: payload.mrIid,
618
+ const failures: string[] = []
619
+ let postedNew = 0
620
+ let postedReplies = 0
621
+ const findingPoster: GitlabFindingPoster = {
622
+ baseUrl: effective.gitlabBaseUrl,
623
+ token: effective.gitlabToken,
624
+ projectId: payload.projectId,
625
+ mrIid: payload.mrIid,
626
+ }
627
+ for (const [index, finding] of capturedFindings.entries()) {
628
+ try {
629
+ await postReviewFindings([finding], findingPoster)
630
+ if (finding.status === 'new') postedNew++
631
+ else postedReplies++
632
+ } catch (err) {
633
+ const locator = finding.status === 'new' ? `${finding.path}:${finding.line}` : finding.discussionId
634
+ failures.push(`${locator}: ${err instanceof Error ? err.message : String(err)}`)
635
+ }
636
+ }
637
+ return { summary: `${postedNew} new inline comment(s), ${postedReplies} thread(s) updated.`, failures }
638
+ } finally {
639
+ await handle.dispose()
547
640
  }
548
- for (const [index, finding] of capturedFindings.entries()) {
641
+ }
642
+ try {
643
+ return await runOnce(primaryOptions)
644
+ } catch (err) {
645
+ const turnMsg = getTurnErrorMessage(lastHandle)
646
+ const isTurnUnsupported = turnMsg !== undefined && (turnMsg.includes('does not support reasoning effort') || turnMsg.includes('UNSUPPORTED_REASONING_EFFORT'))
647
+ const isUnsupported = isUnsupportedReasoningError(err) || isTurnUnsupported
648
+ if (primaryOptions.reasoningEffort !== undefined && isUnsupported) {
649
+ const originalMsg = turnMsg ?? (err instanceof Error ? err.message : String(err))
549
650
  try {
550
- await postReviewFindings([finding], findingPoster)
551
- if (finding.status === 'new') postedNew++
552
- else postedReplies++
553
- } catch (err) {
554
- const locator = finding.status === 'new' ? `${finding.path}:${finding.line}` : finding.discussionId
555
- failures.push(`${locator}: ${err instanceof Error ? err.message : String(err)}`)
651
+ const result = await runOnce(fallbackOptions)
652
+ console.warn(`maestro-orchestrator: Review model ${primaryOptions.provider}/${primaryOptions.model} with reasoningEffort "${String(primaryOptions.reasoningEffort)}" is unsupported; retried without reasoningEffort and succeeded`)
653
+ return result
654
+ } catch (retryErr) {
655
+ const retryMsg = getTurnErrorMessage(lastHandle) ?? (retryErr instanceof Error ? retryErr.message : String(retryErr))
656
+ throw new Error(`Review model ${primaryOptions.provider}/${primaryOptions.model} with reasoningEffort "${String(primaryOptions.reasoningEffort)}" is unsupported (${originalMsg}). Retried without reasoningEffort but still failed: ${retryMsg}. Remove reasoningEffort in Settings → Maestro → Review model or choose a reasoning-capable model.`)
556
657
  }
557
658
  }
558
- return { summary: `${postedNew} new inline comment(s), ${postedReplies} thread(s) updated.`, failures }
559
- } finally {
560
- await handle.dispose()
659
+ if (err instanceof Error && err.message.includes('did not successfully load the required')) {
660
+ const effort = String(primaryOptions.reasoningEffort)
661
+ const hint = primaryOptions.reasoningEffort === undefined
662
+ ? `${err.message}. Check that maestro-skills are installed and DSH was restarted after install.`
663
+ : `${err.message}. Model ${primaryOptions.provider}/${primaryOptions.model} with reasoningEffort "${effort}" may be unsupported — check Settings → Maestro → Review model. Original turn likely failed with UNSUPPORTED_REASONING_EFFORT — see session.jsonl.zstd turn/end.`
664
+ throw new Error(hint)
665
+ }
666
+ throw err
561
667
  }
562
668
  }
563
669
 
@@ -571,6 +677,7 @@ export function apply(ctx: Context, config: Config): void {
571
677
  agentOptions,
572
678
  setup: async (agentCtx) => {
573
679
  installModelSelection(agentCtx, { current: agentOptions, assembled: undefined })
680
+ await agentCtx.plugin(ReviewToolPolicy)
574
681
  await mountAgentPreset(ctx.agentPresets, agentCtx, 'dsh-maestro-auditor')
575
682
  await agentCtx.plugin(GovardTool, { rootPath: worktreePath })
576
683
  await agentCtx.plugin(GitlabClient, {
@@ -628,7 +735,10 @@ export function apply(ctx: Context, config: Config): void {
628
735
  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`)
629
736
  return
630
737
  }
738
+ const resolved = { ...effective, gitlabToken }
631
739
  const historyId = `${payload.projectId}-${payload.mrIid}-${Date.now()}`
740
+ const currentHeadSha = await fetchMrDetailHeadSha(resolved.gitlabBaseUrl, gitlabToken, payload.projectId, payload.mrIid)
741
+ .catch(() => undefined) as string | undefined
632
742
  await recordReviewStart({
633
743
  id: historyId,
634
744
  projectId: payload.projectId,
@@ -638,8 +748,19 @@ export function apply(ctx: Context, config: Config): void {
638
748
  scope: payload.scope.kind,
639
749
  trigger: payload.trigger,
640
750
  startedAt: Date.now(),
751
+ headSha: currentHeadSha,
641
752
  })
642
- const resolved = { ...effective, gitlabToken }
753
+ // H6 incremental context: what changed since the last completed review on this MR.
754
+ let incrementalBlock: string | undefined
755
+ try {
756
+ const prior = await lastCompletedReview(payload.projectId, payload.mrIid)
757
+ if (prior?.headSha !== undefined && currentHeadSha !== undefined && prior.headSha !== currentHeadSha) {
758
+ const compare = await fetchCompare(resolved.gitlabBaseUrl, gitlabToken, payload.projectId, prior.headSha, currentHeadSha)
759
+ incrementalBlock = buildIncrementalBlock(compare, prior.headSha, currentHeadSha)
760
+ }
761
+ } catch {
762
+ // Optional optimization only — never fail the review for it.
763
+ }
643
764
  const fallbackSelection: ModelSelection = (ctx.get?.('agentDefaultModel') as { currentSelection(): ModelSelection } | undefined)?.currentSelection()
644
765
  ?? (ctx as unknown as { agentDefaultModel?: { currentSelection(): ModelSelection } }).agentDefaultModel?.currentSelection()
645
766
  ?? { provider: 'fallback', model: 'fallback' }
@@ -700,7 +821,7 @@ export function apply(ctx: Context, config: Config): void {
700
821
  return
701
822
  }
702
823
  const diffBody = await runDiffOnlyReview(payload, {
703
- runReviewer: (p) => runReviewer(undefined, p, resolved, undefined, reviewModelSelection),
824
+ runReviewer: (p) => runReviewer(undefined, p, resolved, undefined, reviewModelSelection, incrementalBlock),
704
825
  postComment,
705
826
  replyToDiscussion,
706
827
  writeFailedReport,
@@ -714,7 +835,7 @@ export function apply(ctx: Context, config: Config): void {
714
835
  localRepoPath: mapping.localRepoPath,
715
836
  ensureWorktree,
716
837
  removeWorktree,
717
- runReviewer: (worktreePath, p) => runReviewer(worktreePath, p, resolved, mapping.reviewProfile ?? 'magento2', reviewModelSelection),
838
+ runReviewer: (worktreePath, p) => runReviewer(worktreePath, p, resolved, mapping.reviewProfile ?? 'magento2', reviewModelSelection, incrementalBlock),
718
839
  runAuditor: (worktreePath, p) => runAuditor(worktreePath, p, resolved, reviewModelSelection),
719
840
  postComment,
720
841
  replyToDiscussion,