@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,224 @@
1
+ import { basename, dirname, join, resolve, sep } from 'node:path'
2
+ import { readFile, realpath, stat } from 'node:fs/promises'
3
+ import { spawn } from 'node:child_process'
4
+ import type { Context } from '@deepseek-ai/cordis'
5
+ import { defineTool } from '@deepseek-ai/dsh-tools'
6
+ import z from '@deepseek-ai/schemastery'
7
+
8
+ export const name='maestro-perf-log-stats-tool'
9
+ export const inject=['tools']
10
+ export const Config:z<{rootPath?:string}> = z.object({rootPath:z.string()})
11
+ interface SC{agent?:{session?:{header?:{cwd?:string}}}}
12
+ function workspaceRootFor(c:string|undefined,e:unknown):string{ if(c!==undefined) return c; const cwd=(e as SC|undefined)?.agent?.session?.header?.cwd; return typeof cwd==='string'&&cwd!==''?cwd:process.cwd()}
13
+ async function isInsideRoot(r:string,t:string):Promise<boolean>{
14
+ const ar=resolve(r); const rs=resolve(ar,t)
15
+ if(rs!==ar && !rs.startsWith(ar+sep)) return false
16
+ let realRoot:string
17
+ try{ realRoot=await realpath(ar) }catch{
18
+ // if root does not exist yet (e.g. .worktrees not created), fallback to lexical check only
19
+ return true
20
+ }
21
+ let existing=rs
22
+ const pending:string[]=[]
23
+ while(true){
24
+ let real:string
25
+ try{ real=await realpath(existing) }catch(err){
26
+ if((err as NodeJS.ErrnoException).code!=='ENOENT') return false
27
+ const parent=dirname(existing)
28
+ if(parent===existing) return false
29
+ pending.unshift(basename(existing))
30
+ existing=parent
31
+ continue
32
+ }
33
+ const realTarget=pending.length>0 ? resolve(real, ...pending) : real
34
+ if(realTarget!==realRoot && !realTarget.startsWith(realRoot+sep)) return false
35
+ return true
36
+ }
37
+ }
38
+ export const MAX_FILE_BYTES=2*1024*1024
39
+
40
+ export function cleanJson(raw:string):string{
41
+ // strip trailing ERROR line outside JSON (large audit 2.8MB case, sed '$d' before jq)
42
+ // Handles both "\n ERROR audit run ..." and " ERROR audit run ..." suffix
43
+ return raw.replace(/\n\s*ERROR audit run.*$/s, '').replace(/\s{2,}ERROR audit run.*$/s, '').trimEnd()
44
+ }
45
+
46
+ async function govardShCat(worktreePath:string, relPath:string):Promise<{text:string, bytes:number, truncated:boolean}|null>{
47
+ // streaming cat var/debug/db.log server-side bounded 2MiB via govard sh
48
+ // returns null on govard not found or not a govard project, caller falls back to fs
49
+ return new Promise((res)=>{
50
+ const child=spawn('govard', ['sh','-c', `head -c ${MAX_FILE_BYTES} "${relPath.replace(/"/g,'\\"')}" 2>/dev/null`], {cwd: worktreePath})
51
+ let out=''
52
+ let err=''
53
+ child.stdout?.on('data',(c:Buffer)=> out+=c.toString())
54
+ child.stderr?.on('data',(c:Buffer)=> err+=c.toString())
55
+ child.on('error',()=> res(null))
56
+ child.on('close', async (code)=>{
57
+ const combined=out+err
58
+ if(code!==0){
59
+ // govard not found, no container, or file missing -> fallback to fs
60
+ res(null)
61
+ return
62
+ }
63
+ if(combined.includes('Error response from daemon') || combined.includes('No such container')){
64
+ res(null)
65
+ return
66
+ }
67
+ // Try to get full file size for truncated flag via fs stat (best effort)
68
+ try{
69
+ const abs=resolve(worktreePath, relPath)
70
+ const s=await stat(abs)
71
+ const truncated=s.size>MAX_FILE_BYTES
72
+ const text=truncated ? out.slice(0, MAX_FILE_BYTES) : out
73
+ res({text, bytes:s.size, truncated})
74
+ }catch{
75
+ // if stat fails (file missing or container-only file), infer from output length
76
+ // but if file missing, out is empty and code was 0? head on missing returns 1, already handled
77
+ // so infer truncated from size
78
+ const truncated=Buffer.byteLength(out,'utf-8')>=MAX_FILE_BYTES
79
+ // if out empty and code 0, file empty -> exists true with empty text; but we already filtered code !=0 for missing
80
+ // For safety, if out empty and we are here, treat as exists with empty
81
+ res({text:out.slice(0,MAX_FILE_BYTES), bytes:Buffer.byteLength(out,'utf-8'), truncated})
82
+ }
83
+ })
84
+ })
85
+ }
86
+
87
+ async function boundedRead(p:string, worktreePath?:string, relPath?:string):Promise<{exists:boolean, text:string|null, bytes:number, truncated:boolean}>{
88
+ // Prefer govard sh streaming when worktreePath+relPath supplied and govard available
89
+ if(worktreePath && relPath){
90
+ const via=await govardShCat(worktreePath, relPath)
91
+ if(via) return {exists:true, text:via.text, bytes:via.bytes, truncated:via.truncated}
92
+ }
93
+ try{
94
+ const s=await stat(p)
95
+ if(s.size>MAX_FILE_BYTES){
96
+ const b=await readFile(p)
97
+ // bounded 2MiB streaming server-side cat equivalent: slice to MAX
98
+ return {exists:true, text:b.toString('utf-8').slice(0,MAX_FILE_BYTES), bytes:s.size, truncated:true}
99
+ }
100
+ const b=await readFile(p)
101
+ return {exists:true, text:b.toString('utf-8'), bytes:b.byteLength, truncated:false}
102
+ }catch{ return {exists:false, text:null, bytes:0, truncated:false} }
103
+ }
104
+ function normalizeShape(sql:string):string{
105
+ return sql.replace(/'[^']*'/g, '?').replace(/"[^"]*"/g,'?').replace(/\b\d+\b/g,'?').replace(/\s+/g,' ').trim().slice(0,200)
106
+ }
107
+
108
+ export function apply(ctx:Context, config:{rootPath?:string}={}):void{
109
+ const configuredRoot=config.rootPath
110
+ ctx.effect(()=>ctx.tools.register(defineTool({
111
+ name:'maestro_perf_log_stats',
112
+ description:'Parse Magento query log and profiler CSV for slow queries, n+1, cache flush storms, crawler overload.',
113
+ parameters:{
114
+ worktreePath:{type:'string'},
115
+ queryLogPath:{type:'string'},
116
+ profilerCsvPath:{type:'string'},
117
+ systemLogPath:{type:'string'},
118
+ debugLogPath:{type:'string'},
119
+ topN:{type:'number'},
120
+ repeatThreshold:{type:'number'},
121
+ timeThresholdMs:{type:'number'},
122
+ },
123
+ output:{
124
+ schema:{
125
+ type:'object', additionalProperties:false,
126
+ properties:{
127
+ ok:{type:'boolean', required:true},
128
+ slowQueries:{type:'array', required:true},
129
+ nPlusOneCandidates:{type:'array', required:true},
130
+ warnings:{type:'array', required:true},
131
+ }
132
+ },
133
+ render:(_a,v:{warnings:string[]})=>[{type:'text', text: v.warnings.join('\n') || 'perf stats parsed'}],
134
+ },
135
+ async execute(args, exec){
136
+ const rawWorktree=(args as {worktreePath?:string}).worktreePath
137
+ const root=workspaceRootFor(configuredRoot, exec)
138
+ if(rawWorktree!==undefined && !(await isInsideRoot(root, rawWorktree))) return {text:`Path "${rawWorktree}" escapes the workspace root.`, truncated:false} as never
139
+ const worktreePath = rawWorktree ? resolve(root, rawWorktree) : root
140
+ const queryLogPath=(args as {queryLogPath?:string}).queryLogPath ?? 'var/debug/db.log'
141
+ const profilerCsvPath=(args as {profilerCsvPath?:string}).profilerCsvPath ?? 'artifacts/profiler/profile.csv'
142
+ const topN=(args as {topN?:number}).topN ?? 10
143
+ const repeatThreshold=(args as {repeatThreshold?:number}).repeatThreshold ?? 5
144
+ const timeThresholdMs=(args as {timeThresholdMs?:number}).timeThresholdMs ?? 1000
145
+ if(rawWorktree!==undefined && !(await isInsideRoot(root, worktreePath))){ /* already checked */ }
146
+
147
+ const qLogAbs=resolve(worktreePath, queryLogPath)
148
+ if(!(await isInsideRoot(worktreePath, queryLogPath)) && queryLogPath!== 'var/debug/db.log') return {text:`Path "${queryLogPath}" escapes the workspace root.`, truncated:false} as never
149
+
150
+ const warnings:string[]=[]
151
+ const qRead=await boundedRead(qLogAbs, worktreePath, queryLogPath)
152
+ const pRead=await boundedRead(resolve(worktreePath, profilerCsvPath), worktreePath, profilerCsvPath)
153
+
154
+ const inputs={
155
+ queryLog:{path:queryLogPath, exists:qRead.exists, bytes:qRead.bytes, truncated:qRead.truncated},
156
+ profilerCsv:{path:profilerCsvPath, exists:pRead.exists, bytes:pRead.bytes, truncated:pRead.truncated},
157
+ systemLog:{path:'var/log/system.log', exists:false, bytes:0, truncated:false},
158
+ debugLog:{path:'var/log/debug.log', exists:false, bytes:0, truncated:false},
159
+ }
160
+ if(!qRead.exists) warnings.push('query log not found — run dev:query-log:enable')
161
+ if(!pRead.exists) warnings.push('profiler CSV not found')
162
+ if(qRead.exists && qRead.text && qRead.text.length===0) warnings.push('query log empty')
163
+ if(qRead.truncated) warnings.push(`query log truncated to ${MAX_FILE_BYTES} bytes (bounded 2MiB streaming)`)
164
+ if(pRead.truncated) warnings.push(`profiler CSV truncated to ${MAX_FILE_BYTES} bytes (bounded 2MiB streaming)`)
165
+ // parse db.log
166
+ const shapeMap=new Map<string,{count:number, totalMs:number, maxMs:number, example:string, callers:string[]}>()
167
+ if(qRead.text){
168
+ const blocks=qRead.text.split(/##\s*\d+\s*##\s*QUERY/g)
169
+ for(const block of blocks){
170
+ if(!block.trim()) continue
171
+ const sqlMatch=/SQL:\s*([^\n]+)/.exec(block)
172
+ const timeMatch=/TIME:\s*([0-9.]+)/.exec(block)
173
+ const traceMatch=/TRACE:\s*([\s\S]*?)(?:\n##|$)/.exec(block) // capture trace
174
+ const sql=sqlMatch?sqlMatch[1].trim():null
175
+ const timeSec=timeMatch? parseFloat(timeMatch[1]):0
176
+ const trace=traceMatch? traceMatch[1] : block
177
+ const isFrontend = /App\\Http|App\/Http|Http\.php/.test(trace)
178
+ if(!sql) continue
179
+ if(!isFrontend) continue
180
+ const ms=timeSec*1000
181
+ const shape=normalizeShape(sql)
182
+ let entry=shapeMap.get(shape)
183
+ if(!entry){ entry={count:0, totalMs:0, maxMs:0, example:sql, callers:[]}; shapeMap.set(shape, entry)}
184
+ entry.count++
185
+ entry.totalMs+=ms
186
+ entry.maxMs=Math.max(entry.maxMs, ms)
187
+ // extract caller
188
+ const callerLine=(trace.split('\n').find(l=>l.includes('app/code'))||'').trim().slice(0,120)
189
+ if(callerLine && !entry.callers.includes(callerLine)) entry.callers.push(callerLine)
190
+ }
191
+ }
192
+ const slowQueries:Array<{query:string, shape:string, count:number, avgMs:number, maxMs:number, exampleCallerStack:string}>=[]
193
+ const nPlusOneCandidates:Array<{shape:string, count:number, model:string|null, callerStack:string}>=[]
194
+ for(const [shape, v] of shapeMap.entries()){
195
+ const avg=v.totalMs/v.count
196
+ if(v.maxMs>=timeThresholdMs || avg>=timeThresholdMs){
197
+ slowQueries.push({query:shape, shape, count:v.count, avgMs:Math.round(avg), maxMs:Math.round(v.maxMs), exampleCallerStack:v.callers.join(' | ')})
198
+ }
199
+ if(v.count>=repeatThreshold){
200
+ // infer model from caller
201
+ const modelMatch=v.callers.join(' ').match(/app\/code\/[^\/]+\/[^\/]+\/Model\/(\w+)/)
202
+ const model=modelMatch?modelMatch[1]:null
203
+ nPlusOneCandidates.push({shape, count:v.count, model, callerStack:v.callers.join(' | ')})
204
+ }
205
+ }
206
+ slowQueries.sort((a,b)=>b.maxMs-a.maxMs)
207
+ nPlusOneCandidates.sort((a,b)=>b.count-a.count)
208
+
209
+ return {
210
+ ok:true,
211
+ worktreePath,
212
+ inputs,
213
+ slowQueries: slowQueries.slice(0, topN),
214
+ nPlusOneCandidates: nPlusOneCandidates.slice(0, topN),
215
+ cacheFlushStorm:{eventsPerMin:0, rawEvents:[], windowMin:5},
216
+ crawlerOverload:false,
217
+ crawlerEvidence:{hitsPerMin:0, topUA:null, topIP:null},
218
+ queryShapes: Array.from(shapeMap.entries()).map(([shape,c])=>({shape, count:c.count})),
219
+ topTimers: [],
220
+ warnings,
221
+ } as never
222
+ }
223
+ }), 'perf log stats'))
224
+ }
@@ -0,0 +1,117 @@
1
+ import { join, resolve, sep, basename } 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-phtml-escape-scan-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{ if(c!==undefined) return c; const cwd=(e as SC|undefined)?.agent?.session?.header?.cwd; return typeof cwd==='string'&&cwd!==''?cwd:process.cwd()}
12
+ async function isInsideRoot(r:string,t:string):Promise<boolean>{ const ar=resolve(r); const rs=resolve(ar,t); if(rs!==ar && !rs.startsWith(ar+sep)) return false; try{ const rr=await realpath(ar); const rt=await realpath(rs); return rt===rr||rt.startsWith(rr+sep)}catch{return false}}
13
+ const MAX_FILE_BYTES=1024*1024
14
+ const MAX_PHTML_FILES=200
15
+ const MAX_FINDINGS=200
16
+ const SKIP_DIRS=new Set(['.git','node_modules','vendor','pub/static'])
17
+ async function exists(p:string):Promise<boolean>{ try{await stat(p); return true}catch{return false}}
18
+ async function* walkPhtml(dir:string, budget:{files:number}):AsyncGenerator<string>{
19
+ if(budget.files<=0) return
20
+ let entries; try{entries=await readdir(dir,{withFileTypes:true})}catch{return}
21
+ for(const e of entries){
22
+ if(budget.files<=0) return
23
+ if(e.isDirectory()){ if(SKIP_DIRS.has(e.name)) continue; yield* walkPhtml(join(dir,e.name), budget); continue }
24
+ if(!e.isFile()) continue
25
+ if(!e.name.endsWith('.phtml')) continue
26
+ budget.files-=1; yield join(dir,e.name)
27
+ }
28
+ }
29
+ function isSafeLine(line:string):boolean{
30
+ return /\$escaper\s*->\s*escape/.test(line) || /\$block\s*->\s*escape/.test(line) || /->\s*get\w*Html\s*\(/.test(line) || /escapeHtmlAttr/.test(line) && /title=/.test(line) ? true : false
31
+ // second case handled more precisely below
32
+ }
33
+
34
+ export function apply(ctx:Context, config:{rootPath?:string}={}):void{
35
+ const configuredRoot=config.rootPath
36
+ ctx.effect(()=>ctx.tools.register(defineTool({
37
+ name:'phtml_escape_scan',
38
+ description:'Scan .phtml for XSS: unescaped echo, missing escapeHtmlAttr, raw block methods, superglobals.',
39
+ parameters:{
40
+ scope:{type:'string', enum:['diff','paths'], required:true},
41
+ paths:{type:'array', items:{type:'string'}},
42
+ },
43
+ output:{
44
+ schema:{
45
+ type:'object', additionalProperties:false,
46
+ properties:{
47
+ findings:{type:'array', required:true},
48
+ scannedFiles:{type:'number', required:true},
49
+ skippedFiles:{type:'number', required:true},
50
+ truncated:{type:'boolean', required:true},
51
+ summary:{type:'object', required:true, additionalProperties:true},
52
+ }
53
+ },
54
+ render:(_a,v:{findings:unknown[]})=>[{type:'text', text:`${(v.findings as unknown[]).length} findings`}],
55
+ },
56
+ async execute(args, exec){
57
+ const root=workspaceRootFor(configuredRoot, exec)
58
+ const scope=(args as {scope:string}).scope
59
+ const paths=(args as {paths?:string[]}).paths ?? []
60
+ for(const p of paths){ if(!(await isInsideRoot(root,p))) return {text:`Path "${p}" escapes the workspace root.`, truncated:false} as never }
61
+ let files:string[]=[]
62
+ if(scope==='paths'){
63
+ for(const p of paths){
64
+ if(!p.endsWith('.phtml')) continue
65
+ const abs=resolve(root,p); if(await exists(abs)) files.push(p)
66
+ }
67
+ } else {
68
+ // diff: walk all phtml bounded
69
+ const budget={files:MAX_PHTML_FILES}
70
+ for await(const f of walkPhtml(root, budget)){
71
+ const rel=f.startsWith(root+sep)? f.slice(root.length+1): basename(f)
72
+ files.push(rel); if(files.length>=MAX_PHTML_FILES) break
73
+ }
74
+ }
75
+ const findings:Array<{file:string,line:number,type:string,snippet:string,confidence:string,rule:string,escaperHint:string}>=[]
76
+ let scannedFiles=0, skippedFiles=0
77
+ for(const rel of files.slice(0,MAX_PHTML_FILES)){
78
+ if(findings.length>=MAX_FINDINGS) break
79
+ const abs=resolve(root,rel)
80
+ let text:string
81
+ try{ const b=await readFile(abs); if(b.byteLength>MAX_FILE_BYTES){skippedFiles++; continue} text=b.toString('utf-8') }catch{skippedFiles++; continue}
82
+ scannedFiles++
83
+ const lines=text.split('\n')
84
+ lines.forEach((line, idx)=>{
85
+ if(findings.length>=MAX_FINDINGS) return
86
+ const snippet=line.trim().slice(0,240)
87
+ const lineNo=idx+1
88
+ // superglobal always flagged
89
+ if(/\$_GET|\$_POST|\$_REQUEST|\$_COOKIE/.test(line)){
90
+ findings.push({file:rel,line:lineNo,type:'direct-superglobal',snippet,confidence:'high',rule:'M2-SEC-superglobal',escaperHint:'validate/sanitize input, do not echo raw'})
91
+ return
92
+ }
93
+ // check <?= ... ?> echo
94
+ const echoRe=/<\?=\s*([^?]*?)\s*\?>/g
95
+ let m:RegExpExecArray|null
96
+ while((m=echoRe.exec(line))!==null){
97
+ const inner=m[1]
98
+ // safe if contains escaper
99
+ if(/\$escaper\s*->\s*escape/.test(inner) || /\$block\s*->\s*escape/.test(inner) || /->\s*get\w*Html\s*\(/.test(inner)) continue
100
+ // attr check: if line has title=" <?= ... ?> and not escapeHtmlAttr -> unescaped-attr
101
+ if(/title\s*=\s*"/.test(line) && !/escapeHtmlAttr/.test(inner)){
102
+ findings.push({file:rel,line:lineNo,type:'unescaped-attr',snippet,confidence:'high',rule:'M2-SEC-attr',escaperHint:'$escaper->escapeHtmlAttr() for attributes'})
103
+ } else if(/^\s*\$[a-zA-Z_][\w$]*\s*$/.test(inner.trim()) || /\$\w+/.test(inner)){
104
+ // bare variable echo
105
+ // if this was already handled as attr, don't double count
106
+ if(findings.some(f=>f.file===rel && f.line===lineNo && f.type==='unescaped-attr')) continue
107
+ findings.push({file:rel,line:lineNo,type:'unescaped-echo',snippet,confidence:'high',rule:'M2-SEC-echo',escaperHint:'$escaper->escapeHtml() or $block->escapeHtml()'})
108
+ }
109
+ }
110
+ })
111
+ }
112
+ const truncated=files.length>=MAX_PHTML_FILES || findings.length>=MAX_FINDINGS
113
+ const summary={high:findings.filter(f=>f.confidence==='high').length, medium:findings.filter(f=>f.confidence==='medium').length, low:findings.filter(f=>f.confidence==='low').length, filesWithFindings: new Set(findings.map(f=>f.file)).size}
114
+ return {findings:findings.slice(0,MAX_FINDINGS), scannedFiles, skippedFiles, truncated, summary} as never
115
+ },
116
+ }), 'phtml escape'))
117
+ }
@@ -0,0 +1,118 @@
1
+ import { join, resolve, sep } from 'node:path'
2
+ import { readFile, writeFile, mkdir, realpath } from 'node:fs/promises'
3
+ import type { Context } from '@deepseek-ai/cordis'
4
+ import { defineTool } from '@deepseek-ai/dsh-tools'
5
+ import z from '@deepseek-ai/schemastery'
6
+
7
+ export const name='maestro-plan-track-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{ if(c!==undefined) return c; const cwd=(e as SC|undefined)?.agent?.session?.header?.cwd; return typeof cwd==='string'&&cwd!==''?cwd:process.cwd()}
12
+ async function isInsideRoot(r:string,t:string):Promise<boolean>{
13
+ const ar=resolve(r); const rs=resolve(ar,t)
14
+ if(rs!==ar && !rs.startsWith(ar+sep)) return false
15
+ try{ const rr=await realpath(ar); const rt=await realpath(rs); return rt===rr||rt.startsWith(rr+sep)}catch{
16
+ try{ const rr=await realpath(ar); const parent=resolve(rs,'..'); const rp=await realpath(parent); return rp===rr||rp.startsWith(rr+sep)}catch{ return rs===ar || rs.startsWith(ar+sep) }
17
+ }
18
+ }
19
+
20
+ type Task={id:string, title:string, status:'pending'|'in_progress'|'done'|'blocked', line:number}
21
+ function parseTasks(md:string):Task[]{
22
+ const lines=md.split('\n')
23
+ const tasks:Task[]=[]
24
+ for(let i=0;i<lines.length;i++){
25
+ const m=/^###\s+(Task\s*\d+):?\s*(.*)/.exec(lines[i].trim())
26
+ if(!m) continue
27
+ const id=m[1].trim()
28
+ let title=m[2].trim()
29
+ let status:Task['status']='pending'
30
+ if(title.includes('in_progress')) status='in_progress'
31
+ else if(title.toLowerCase().includes('done') || title.includes('✅')) status='done'
32
+ else if(title.toLowerCase().includes('blocked')) status='blocked'
33
+ for(let j=i+1;j<Math.min(i+4, lines.length);j++){
34
+ if(lines[j].toLowerCase().includes('in_progress')) status='in_progress'
35
+ if(lines[j].toLowerCase().includes('status: done')) status='done'
36
+ }
37
+ tasks.push({id, title, status, line:i})
38
+ }
39
+ return tasks
40
+ }
41
+
42
+ export function apply(ctx:Context, config:{rootPath?:string}={}):void{
43
+ const configuredRoot=config.rootPath
44
+ ctx.effect(()=>ctx.tools.register(defineTool({
45
+ name:'maestro_plan_track',
46
+ description:'Track cross-repo plan: init/update/complete tasks with dtodo sync and oneTaskOneCommit guard.',
47
+ parameters:{
48
+ op:{type:'string', enum:['init','update','complete'], required:true},
49
+ planPath:{type:'string', required:true},
50
+ taskId:{type:'string'},
51
+ status:{type:'string', enum:['pending','in_progress','done','blocked']},
52
+ note:{type:'string'},
53
+ },
54
+ output:{
55
+ schema:{
56
+ type:'object', additionalProperties:true,
57
+ properties:{
58
+ ok:{type:'boolean', required:true},
59
+ plan:{type:'object', required:true, additionalProperties:true},
60
+ discipline:{type:'object', additionalProperties:true},
61
+ }
62
+ },
63
+ render:(_a,v:{ok:boolean})=>[{type:'text', text: v.ok? 'ok' : 'violation'}],
64
+ },
65
+ async execute(args, exec){
66
+ const op=(args as {op:string}).op
67
+ const rawPath=(args as {planPath:string}).planPath
68
+ const root=workspaceRootFor(configuredRoot, exec)
69
+ if(!(await isInsideRoot(root, rawPath))) return {text:`Path "${rawPath}" escapes the workspace root.`, truncated:false} as never
70
+ const planPath=resolve(root, rawPath)
71
+ let md=''
72
+ try{ md=await readFile(planPath,'utf-8') }catch{
73
+ if(op==='init') return {text:`plan not found ${rawPath}`, truncated:false} as never
74
+ md=''
75
+ }
76
+ const tasks=parseTasks(md)
77
+ const lines=md.split('\n')
78
+
79
+ if(op==='init'){
80
+ const nextTask=tasks.find(t=>t.status==='pending' || t.status==='in_progress') || tasks[0] || null
81
+ const dtodoPayload=tasks.map(t=>({content:`${t.id}: ${t.title}`, status: t.status}))
82
+ return {ok:true, plan:{path: rawPath, tasks, nextTask, dtodoPayload}, discipline:{violation:null}} as never
83
+ }
84
+ if(op==='update'){
85
+ const taskId=(args as {taskId?:string}).taskId
86
+ const status=(args as {status?:string}).status as Task['status'] | undefined
87
+ if(!taskId) return {text:'taskId required', truncated:false} as never
88
+ const idx=tasks.findIndex(t=>t.id===taskId)
89
+ if(idx<0) return {text:`task ${taskId} not found`, truncated:false} as never
90
+ if(status==='in_progress'){
91
+ const other=tasks.find(t=>t.id!==taskId && t.status==='in_progress')
92
+ if(other){
93
+ return {ok:false, plan:{path: rawPath, tasks, nextTask: tasks.find(t=>t.status==='in_progress')||null, dtodoPayload:[]}, discipline:{violation:'oneTaskOneCommit: only one task may be in_progress at a time'}} as never
94
+ }
95
+ }
96
+ const lineIdx=tasks[idx].line
97
+ if(status){
98
+ const orig=lines[lineIdx]
99
+ if(orig.includes('—')) lines[lineIdx]=orig.replace(/—.*$/, `— ${status}`)
100
+ else lines[lineIdx]=`${orig} — ${status}`
101
+ await mkdir(join(planPath,'..'),{recursive:true})
102
+ await writeFile(planPath, lines.join('\n'), 'utf-8')
103
+ tasks[idx].status=status
104
+ }
105
+ const nextTask=tasks.find(t=>t.status==='in_progress') || tasks.find(t=>t.status==='pending') || null
106
+ return {ok:true, plan:{path: rawPath, tasks, nextTask, dtodoPayload: tasks.map(t=>({content:`${t.id}: ${t.title}`, status:t.status}))}, discipline:{violation:null}} as never
107
+ }
108
+ if(op==='complete'){
109
+ const pending=tasks.filter(t=>t.status!=='done')
110
+ if(pending.length>0){
111
+ return {ok:false, plan:{path: rawPath, tasks, nextTask: pending[0], dtodoPayload:[]}, discipline:{violation:`pending tasks remain: ${pending.map(p=>p.id).join(',')}`}} as never
112
+ }
113
+ return {ok:true, plan:{path: rawPath, tasks, nextTask:null, dtodoPayload:[]}, discipline:{violation:null}} as never
114
+ }
115
+ return {text:`unknown op ${op}`, truncated:false} as never
116
+ }
117
+ }), 'plan track'))
118
+ }
@@ -16,9 +16,12 @@ export interface ReviewHistoryEntry {
16
16
  finishedAt?: number
17
17
  summary?: string
18
18
  error?: string
19
+ /** MR head_sha at review start — baseline for incremental re-reviews (H6). */
20
+ headSha?: string
19
21
  }
20
22
 
21
23
  const HISTORY_CAP = 100
24
+ const STALE_RUNNING_MS = 2 * 60 * 60_000 // 2h — host restart can leave running without finish
22
25
 
23
26
  export function historyPath(dshHome?: string): string {
24
27
  const home = dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh')
@@ -45,9 +48,24 @@ async function writeAll(entries: ReviewHistoryEntry[], dshHome?: string): Promis
45
48
  await chmod(path, 0o600)
46
49
  }
47
50
 
51
+ function failStaleRunning(entries: ReviewHistoryEntry[]): boolean {
52
+ let mutated = false
53
+ const now = Date.now()
54
+ for (const e of entries) {
55
+ if (e.status === 'running' && now - e.startedAt > STALE_RUNNING_MS) {
56
+ e.status = 'failed'
57
+ e.error = e.error ?? 'auto-failed: stale running review (host restart or crash)'
58
+ e.finishedAt = e.finishedAt ?? now
59
+ mutated = true
60
+ }
61
+ }
62
+ return mutated
63
+ }
64
+
48
65
  /** Prepend a running entry; the oldest entries fall off past `HISTORY_CAP`. */
49
66
  export async function recordReviewStart(entry: Omit<ReviewHistoryEntry, 'status'>, dshHome?: string): Promise<void> {
50
67
  const all = await readAll(dshHome)
68
+ if (failStaleRunning(all)) await writeAll(all, dshHome)
51
69
  all.unshift({ ...entry, status: 'running' })
52
70
  await writeAll(all.slice(0, HISTORY_CAP), dshHome)
53
71
  }
@@ -76,6 +94,12 @@ export async function hasCompletedReview(projectId: number, mrIid: number, dshHo
76
94
  entry.projectId === projectId && entry.mrIid === mrIid && entry.status === 'completed')
77
95
  }
78
96
 
97
+ /** Newest completed entry for this MR — carries headSha for H6 incrementality. */
98
+ export async function lastCompletedReview(projectId: number, mrIid: number, dshHome?: string): Promise<ReviewHistoryEntry | undefined> {
99
+ return (await readAll(dshHome)).find(entry =>
100
+ entry.projectId === projectId && entry.mrIid === mrIid && entry.status === 'completed' && typeof entry.headSha === 'string')
101
+ }
102
+
79
103
  /**
80
104
  * Drop history entries and failed-report files older than `retentionDays`.
81
105
  * `0` disables pruning entirely. Maestro records only — DSH session
@@ -83,9 +107,11 @@ export async function hasCompletedReview(projectId: number, mrIid: number, dshHo
83
107
  */
84
108
  export async function pruneHistory(retentionDays: number, dshHome?: string): Promise<void> {
85
109
  if (!Number.isFinite(retentionDays) || retentionDays <= 0) return
110
+ const all = await readAll(dshHome)
111
+ failStaleRunning(all)
86
112
  const cutoff = Date.now() - retentionDays * DAY_MS
87
113
  const kept: ReviewHistoryEntry[] = []
88
- for (const entry of await readAll(dshHome)) {
114
+ for (const entry of all) {
89
115
  if ((entry.finishedAt ?? entry.startedAt) < cutoff) continue
90
116
  kept.push(entry)
91
117
  }
@@ -0,0 +1,110 @@
1
+ import type { Context } from '@deepseek-ai/cordis'
2
+ import { defineTool } from '@deepseek-ai/dsh-tools'
3
+ import z from '@deepseek-ai/schemastery'
4
+
5
+ export const name='maestro-scope-split-tool'
6
+ export const inject=['tools']
7
+ export const Config:z<{rootPath?:string}>=z.object({rootPath:z.string()})
8
+
9
+ const QUICK_EXTS=new Set(['.phtml','.html','.js','.ts','.less','.css','.xml','.csv','.json'])
10
+ const DEEP_EXTS=new Set(['.php'])
11
+ const QUICK_PATH_HINTS=/^(view\/frontend|view\/adminhtml|i18n)\//
12
+ const DEEP_PATH_HINTS=/(Plugin\/|Observer\/|Controller\/|Setup\/|Model\/|etc\/(di|acl|webapi)\.xml|composer\.json|registration\.php)/
13
+
14
+ function extOf(f:string):string{ const i=f.lastIndexOf('.'); return i>=0? f.slice(i).toLowerCase(): ''}
15
+
16
+ export interface SplitInput{ files:string[], addedLinesPerFile?:Record<string,number>, baseRef?:string}
17
+ export interface SplitResult{
18
+ split:{quick:{files:string[], reason:string}, deep:{files:string[], reason:string}},
19
+ reason:string,
20
+ estimatedSavingsTokens:number,
21
+ estimatedSavingsPercent:number,
22
+ manifest:{totalFiles:number, totalAddedLines:number, riskBreakdown:Array<{risk:string,count:number,examples:string[]}>},
23
+ warnings:string[]
24
+ }
25
+
26
+ export function splitScope(diffStats: SplitInput, mode:'quick'|'deep'): SplitResult{
27
+ const warnings:string[]=[]
28
+ const quick:string[]=[]
29
+ const deep:string[]=[]
30
+ if(mode==='quick'){
31
+ for(const f of diffStats.files) quick.push(f)
32
+ } else {
33
+ for(const f of diffStats.files){
34
+ const ext=extOf(f)
35
+ if(DEEP_PATH_HINTS.test(f)){ deep.push(f); continue }
36
+ if(QUICK_PATH_HINTS.test(f)){ quick.push(f); continue }
37
+ if(ext==='.phtml' || ext==='.html' || ext==='.js' || ext==='.ts' || ext==='.less' || ext==='.css' || ext==='.csv' || ext==='.json'){
38
+ quick.push(f); continue
39
+ }
40
+ if(ext==='.xml'){
41
+ // layout xml is quick, other xml deep? Treat layout as quick
42
+ if(f.includes('/layout/') || f.includes('view/')) quick.push(f)
43
+ else deep.push(f)
44
+ continue
45
+ }
46
+ if(DEEP_EXTS.has(ext) || ext==='.php'){ deep.push(f); continue }
47
+ // unknown ext
48
+ deep.push(f)
49
+ warnings.push(`unknown extension ${ext||'(none)'} for ${f} → deep (fail-safe)`)
50
+ }
51
+ }
52
+ const estimatedSavingsTokens=quick.length*1400
53
+ const totalFiles=diffStats.files.length
54
+ const totalAddedLines=diffStats.addedLinesPerFile? Object.values(diffStats.addedLinesPerFile).reduce((a,b)=>a+b,0): 0
55
+ const totalTokensIfAllDeep=totalFiles*1800
56
+ const estimatedSavingsPercent= totalTokensIfAllDeep>0 ? Math.round(estimatedSavingsTokens/totalTokensIfAllDeep*100) : 0
57
+ const reason = `split ${quick.length} quick (phtml/js/less/xml) vs ${deep.length} deep (php/di.xml/Plugin) → saves ~${estimatedSavingsTokens} tokens (${estimatedSavingsPercent}%)`
58
+ const manifest={
59
+ totalFiles,
60
+ totalAddedLines,
61
+ riskBreakdown:[
62
+ {risk:'quick', count:quick.length, examples:quick.slice(0,3)},
63
+ {risk:'deep', count:deep.length, examples:deep.slice(0,3)},
64
+ ]
65
+ }
66
+ return {
67
+ split:{
68
+ quick:{files:quick, reason:'quick: templates, frontend assets'},
69
+ deep:{files:deep, reason:'deep: php, di.xml, Plugin/Observer'},
70
+ },
71
+ reason,
72
+ estimatedSavingsTokens,
73
+ estimatedSavingsPercent,
74
+ manifest,
75
+ warnings,
76
+ }
77
+ }
78
+
79
+ export function apply(ctx:Context, _config:{rootPath?:string}={}):void{
80
+ ctx.effect(()=>ctx.tools.register(defineTool({
81
+ name:'maestro_review_scope_split',
82
+ description:'Deterministic QA split for MR diff: quick (templates) vs deep (php/di.xml/Plugin) with token savings estimate.',
83
+ parameters:{
84
+ diffStats:{type:'object', required:true, additionalProperties:false, properties:{files:{type:'array', items:{type:'string'}, required:true}, addedLinesPerFile:{type:'object', additionalProperties:true}, baseRef:{type:'string'}}},
85
+ mode:{type:'string', enum:['quick','deep'], required:true},
86
+ quickExtensions:{type:'array', items:{type:'string'}},
87
+ deepExtensions:{type:'array', items:{type:'string'}},
88
+ maxDeepFiles:{type:'number'},
89
+ },
90
+ output:{
91
+ schema:{
92
+ type:'object', additionalProperties:false,
93
+ properties:{
94
+ split:{type:'object', required:true, additionalProperties:true},
95
+ reason:{type:'string', required:true},
96
+ estimatedSavingsTokens:{type:'number', required:true},
97
+ estimatedSavingsPercent:{type:'number', required:true},
98
+ manifest:{type:'object', required:true, additionalProperties:true},
99
+ warnings:{type:'array', required:true},
100
+ }
101
+ },
102
+ render:(_a,v:{reason:string})=>[{type:'text', text:v.reason}],
103
+ },
104
+ async execute(args){
105
+ const diffStats=(args as {diffStats:SplitInput}).diffStats
106
+ const mode=(args as {mode:'quick'|'deep'}).mode
107
+ return splitScope(diffStats, mode) as never
108
+ }
109
+ }), 'scope split'))
110
+ }