@feng0531/dsh-code-review 0.82.3 → 0.83.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  本文件介绍对使用者有意义的变化。安装与参数见 [README](README.md),本次发布正文见 [发布说明](RELEASE-NOTES.md)。历史提交和安装包保留在 [GitHub Releases](https://github.com/f-e-n-g-0531/dsh-code-review/releases);本文不把开发日志当作功能介绍。
4
4
 
5
+ ## 0.83.0 — 后台等待与真实进度
6
+
7
+ 新增code_review_wait等待变化/终态,任务结果明确nextAction,避免只报任务启动就结束请求。进度以批准清单实际处理数计算,未知分母不伪造百分比;终态返回统一报告,预览确认不跳过。
8
+
5
9
  ## 0.82.3 — 综合失败隔离与一次性最终报告
6
10
 
7
11
  保留主审和窗口成果,综合失败独立披露并保持整体partial。新增两次JSON尝试的脱敏诊断;全项目只渲染一份去重后的最终报告,不逐批拼接。
package/README.md CHANGED
@@ -211,7 +211,7 @@ Git / SVN 历史审查也能使用这些参数;依赖和调用方按对应版
211
211
  1. 调用code_review_start,phase设为preview,previewArgs填原预览参数(例如reviewMode=project)。
212
212
  2. 用code_review_status和jobId查询;完成后读取resultSummary中的预览范围、模型和previewId。
213
213
  3. 展示预览,确认后调用code_review_start,phase设为execute,传previewId与confirmed=true。
214
- 4. 查询审查任务;offset/limit分页取回检查点、文件状态和发现;单个检查点内的大清单用checkpointIndex指定其零基索引,再用offset/limit取回内部全部条目。可调用code_review_cancel取消。
214
+ 4. 持续调用code_review_wait等待进度变化或终态,不因启动返回任务ID就结束用户请求。可展示阶段与“处理数/总数”,不分批汇报问题;抓取阶段分母未知则不显示虚假百分比。等待返回仍running就继续等待,不重新启动。任务结束后取回并统一交付一份最终报告。查询审查任务;offset/limit分页取回检查点、文件状态和发现;单个检查点内的大清单用checkpointIndex指定其零基索引,再用offset/limit取回内部全部条目。可调用code_review_cancel取消。
215
215
 
216
216
  报告保存在宿主profile的data/dsh-code-review/jobs目录;没有profile环境时使用用户主目录.dsh下的数据目录,不写入业务仓库。内容可能含源码片段,应按源码敏感级别保护。本次启动调用结束不自动取消后台任务;明确取消、停用插件或宿主退出会停止它。
217
217
 
package/RELEASE-NOTES.md CHANGED
@@ -1,11 +1,11 @@
1
- # v0.82.3 发布说明
1
+ # v0.83.0 发布说明
2
2
 
3
- v0.82.2冻结候选仅完成CI,未触发发布;后续真实模型验收确认纠正响应附加字段与空输入契约问题,集中修正后使用新补丁标签,不移动旧标签。
3
+ ## 审查任务等待与进度
4
4
 
5
- 修复综合阶段失败误伤已完成主审的状态表达:文件内/跨文件综合异常单独保留stageFailures,窗口和主审完成记录不丢失,整体仍partial,不声称链路审核通过。取消和显式预算中断语义不变。
5
+ 新增code_review_wait:等待后台任务进度变化或终态,不把返回任务ID当成审核完成。工具描述和运行结果引导Agent保持请求,预览完成时展示范围等待确认;已确认执行则等待完成后统一交付一份报告。
6
6
 
7
- 模型JSON失败增加脱敏诊断:可见/总输出字节数、输入大小、结束原因和请求输出额度;明确纠正也失败时保留两次尝试,不把原始输出写进错误消息。核查当前宿主LLM服务未找到可确认的response_format/json_schema入口,不盲目传入未支持参数。使用逐阶段schema提示与明确纠正;修复纠正后擅加字段导致verdicts再次校验失败的问题,严格信封保持。无效检索请求整批不执行,明确纠正后重新生成,身份/权限不扩大。modelContract对null等非对象安全返回。
7
+ 状态返回真实阶段、elapsedMs、文件处理数/总数、主审完成/失败/阻断数量、waitRevision及nextAction。抓取阶段没有批准分母时percent为null;处理百分比不是通过率,也不证明综合完成。wait每次最多等待25秒,只结束该次等待,不取消后台任务;继续用同一jobId等待,不能重新启动重复审核。
8
8
 
9
- 全项目对外输出一份合并报告,不再拼接每批报告。内部检查点和分段是恢复机制,不要求使用者自行拼报告;失败综合及未完成范围单独列出。去重按同路径/定位/标题/证据完全相同合并,不把相似问题强行合并。
9
+ 终态wait直接返回已保存的统一报告,nested report.status可能仍partial,不能把任务completed解释为通过。用户/宿主取消、任务归属、预览确认保持不变。
10
10
 
11
- 用法见 [README](README.md)。本补丁不自动安装/重启DSH,不修改业务工作副本。
11
+ 本功能提供Agent持续等待的工具协议和可见进度,不修改DSH核心,也不能强制所有模型绝不提前结束;实际应用应按nextAction持续调用,不能用提示文本冒充宿主主动消息唤醒。用法见 [README](README.md)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feng0531/dsh-code-review",
3
- "version": "0.82.3",
3
+ "version": "0.83.0",
4
4
  "private": false,
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
@@ -6,9 +6,10 @@ export function registerBackgroundReview(ctx,service,{directory}={}){
6
6
  const root=directory??path.join(process.env.DSH_PROFILE_DIR??path.join(os.homedir(),'.dsh'),'data','dsh-code-review','jobs');
7
7
  const jobs=createReviewJobs({directory:root});
8
8
  const output={schema:{type:'object',additionalProperties:false,properties:{json:{type:'string'},markdown:{type:'string'}},required:['json','markdown']},render:(_a,v)=>[{type:'text',text:v.markdown||v.json}]};
9
- const respond=value=>({json:JSON.stringify(value),markdown:''});
10
- ctx.tools.register({name:'code_review_start',description:'Start a durable background preview or confirmed review. Returns a job ID immediately. Preview phase never sends source. For execute supply the approved previewId and confirmed=true. Use code_review_status to collect progress/results; code_review_cancel to stop. Does not automatically resume after host restart or grant new source approval.',parameters:{type:'object',additionalProperties:false,properties:{phase:{type:'string',enum:['preview','execute']},previewArgs:{type:'object',description:'Same parameters as code_review_preview; do not include execute fields.'},previewId:{type:'string'},confirmed:{type:'boolean'}},required:['phase']},output,async execute(args,exec){if(!args||Object.keys(args).some(k=>!['phase','previewArgs','previewId','confirmed'].includes(k))||!['preview','execute'].includes(args.phase))throw new Error('Invalid background phase');if(args.phase==='execute'&&(args.confirmed!==true||typeof args.previewId!=='string'||args.previewArgs!==undefined))throw new Error('Confirmed preview required');if(args.phase==='preview'&&(args.previewId!==undefined||args.confirmed!==undefined))throw new Error('Preview cannot confirm execution');return respond(await jobs.start(exec,async({signal,checkpoint})=>{const original=exec.agent,sessionId=original.session.header.id??original.session.id,detached={...exec,agent:original,signal,reviewCheckpoint:async value=>{const active=exec.agent;if(active!==original||(active.session.header.id??active.session.id)!==sessionId){throw new Error('Background Agent/session changed');}await checkpoint(value);}};await checkpoint({stage:args.phase==='preview'?'capturing-preview':'revalidating-approved-source'});return args.phase==='preview'?service.preview(args.previewArgs??{},detached):service.execute({previewId:args.previewId,confirmed:true},detached);}));}});
9
+ const respond=value=>{const p=value.progress;const markdown=value.finalMarkdown??(p?['审查任务:'+value.status,'当前阶段:'+p.stage,'进度:'+(p.total===null?'正在准备,文件总量尚未确定':p.processed+'/'+p.total+' 个文件已处理('+p.percent+'%),其中主审完成 '+p.counts.completed+'、失败 '+p.counts.failed+'、阻断 '+p.counts.blocked+';处理比例不等于通过率'),value.nextAction??''].join(String.fromCharCode(10)):value.jobId?'后台任务已接受,尚未完成。继续调用 code_review_wait 等待终态;不要以任务启动作为最终审核结论。':'');return {json:JSON.stringify(value),markdown};};
10
+ ctx.tools.register({name:'code_review_start',description:'Start a durable background preview or confirmed review. Returns a job ID immediately, NOT task completion. Keep the user request active using code_review_wait until terminal status; never final-answer only that a task started. Preview phase never sends source. For execute supply the approved previewId and confirmed=true. Use code_review_status to collect progress/results; code_review_cancel to stop. Does not automatically resume after host restart or grant new source approval.',parameters:{type:'object',additionalProperties:false,properties:{phase:{type:'string',enum:['preview','execute']},previewArgs:{type:'object',description:'Same parameters as code_review_preview; do not include execute fields.'},previewId:{type:'string'},confirmed:{type:'boolean'}},required:['phase']},output,async execute(args,exec){if(!args||Object.keys(args).some(k=>!['phase','previewArgs','previewId','confirmed'].includes(k))||!['preview','execute'].includes(args.phase))throw new Error('Invalid background phase');if(args.phase==='execute'&&(args.confirmed!==true||typeof args.previewId!=='string'||args.previewArgs!==undefined))throw new Error('Confirmed preview required');if(args.phase==='preview'&&(args.previewId!==undefined||args.confirmed!==undefined))throw new Error('Preview cannot confirm execution');return respond(await jobs.start(exec,async({signal,checkpoint})=>{const original=exec.agent,sessionId=original.session.header.id??original.session.id,detached={...exec,agent:original,signal,reviewCheckpoint:async value=>{const active=exec.agent;if(active!==original||(active.session.header.id??active.session.id)!==sessionId){throw new Error('Background Agent/session changed');}await checkpoint(value);}};await checkpoint({stage:args.phase==='preview'?'capturing-preview':'revalidating-approved-source'});return args.phase==='preview'?service.preview(args.previewArgs??{},detached):service.execute({previewId:args.previewId,confirmed:true},detached);}));}});
11
11
  ctx.tools.register({name:'code_review_status',description:'Read a background review task owned by this session, directory and model. Returns saved progress or final preview/report. completed task means the tool returned; inspect nested report coverage/status. Interrupted tasks retain checkpoints but require new preview and approval to resend.',parameters:{type:'object',additionalProperties:false,properties:{jobId:{type:'string'},offset:{type:'integer',minimum:0},checkpointIndex:{type:'integer',minimum:0,description:'Read one checkpoint nested files/findings page; offset/limit apply within it.'},limit:{type:'integer',minimum:1,maximum:100}},required:['jobId']},output,async execute(args,exec){return respond(await jobs.status(args.jobId,exec,{offset:args.offset??0,limit:args.limit??20,checkpointIndex:args.checkpointIndex}));}});
12
12
  ctx.tools.register({name:'code_review_cancel',description:'Cancel a background review task owned by this session. Does not discard saved checkpoints. No source is sent by cancellation.',parameters:{type:'object',additionalProperties:false,properties:{jobId:{type:'string'}},required:['jobId']},output,async execute(args,exec){return respond(await jobs.cancel(args.jobId,exec));}});
13
+ ctx.tools.register({name:'code_review_wait',description:'Wait for review progress or terminal result without ending the user request. Call again while queued/running; show concise progress, not partial findings. A wait timeout does not cancel review or mean task completion. Preview completion requires confirmation; execute completion requires one final consolidated report.',parameters:{type:'object',additionalProperties:false,properties:{jobId:{type:'string'},after:{type:'integer',minimum:0}},required:['jobId']},output,async execute(args,exec){await jobs.wait(args.jobId,exec,{after:args.after,signal:exec.signal});const status=await jobs.status(args.jobId,exec,{offset:0,limit:20});if(!['queued','running'].includes(status.status)){const full=await jobs.status(args.jobId,exec);if(full.result?.markdown)status.finalMarkdown=full.result.markdown;}return respond(status);}});
13
14
  ctx.on('dispose',()=>jobs.dispose());return jobs;
14
15
  }
@@ -0,0 +1,2 @@
1
+ import {recoveryCoverage} from './recovery-coverage.mjs';
2
+ export function jobProgress(record){const r=recoveryCoverage(record),counts={completed:0,excluded:0,blocked:0,failed:0,cancelled:0,pending:0};for(const f of r.files)counts[f.status]=(counts[f.status]??0)+1;const terminal=!['queued','running'].includes(record.status);return {stage:record.stage,terminal,elapsedMs:Date.now()-Date.parse(record.createdAt),total:r.manifestSaved?r.files.length:null,counts,processed:r.files.filter(f=>!['pending'].includes(f.status)).length,percent:r.manifestSaved&&r.files.length?Math.floor(100*r.files.filter(f=>!['pending'].includes(f.status)).length/r.files.length):null,notice:'Percent measures terminal primary-file processing, not successful review or synthesis completion. Unknown capture denominator is null, never invented.'};}
@@ -1,3 +1,4 @@
1
+ import {jobProgress} from './job-progress.mjs';
1
2
  import {recoveryCoverage} from './recovery-coverage.mjs';
2
3
  import {randomUUID} from 'node:crypto';
3
4
  import {mkdir,writeFile,rename,readFile,lstat,realpath} from 'node:fs/promises';
@@ -6,20 +7,21 @@ import path from 'node:path';
6
7
  // Report storage is host-owned, never inside a reviewed working copy.
7
8
  export function createReviewJobs({directory}={}){
8
9
  if(typeof directory!=='string'||!path.isAbsolute(directory))throw new Error('Absolute host report directory required');
9
- const jobs=new Map();let disposed=false,canonicalDirectory;
10
+ const jobs=new Map(),listeners=new Map();const changed=id=>{for(const fn of listeners.get(id)??[])fn();listeners.delete(id);};let disposed=false,canonicalDirectory;
10
11
  const identity=exec=>{const a=exec?.agent,s=a?.session;if(!s||!a.options?.provider||!a.options?.model||!s.header?.cwd)throw new Error('Review job requires session identity');const id=s.header.id??s.id;if(typeof id!=='string'||!id)throw new Error('Durable session id required');return {sessionId:id,cwd:s.header.cwd,provider:a.options.provider,model:a.options.model,reasoningEffort:a.options.reasoningEffort??null};};
11
12
  const same=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
12
13
  const pageCheckpoint=(c,offset,limit)=>({...c,...(c.files?{files:c.files.slice(offset,offset+limit),totalFiles:c.files.length}:{}),...(c.findings?{findings:c.findings.slice(offset,offset+limit),totalFindings:c.findings.length}: {})});
13
14
  const filename=id=>{if(typeof id!=='string'||!/^[a-f0-9-]{36}$/.test(id))throw new Error('Invalid review job id');return path.join(directory,id+'.json');};
14
15
  async function safeDirectory(){await mkdir(directory,{recursive:true,mode:0o700});const stat=await lstat(directory),resolved=await realpath(directory);if(stat.isSymbolicLink()||!stat.isDirectory()||(canonicalDirectory!==undefined&&resolved!==canonicalDirectory))throw new Error('Unsafe report directory');canonicalDirectory??=resolved;}
15
- async function save(job){await safeDirectory();const tmp=path.join(directory,job.id+'.'+randomUUID()+'.tmp');await writeFile(tmp,JSON.stringify(job.record),{mode:0o600,flag:'wx'});await rename(tmp,filename(job.id));}
16
+ async function save(job){await safeDirectory();const tmp=path.join(directory,job.id+'.'+randomUUID()+'.tmp');await writeFile(tmp,JSON.stringify(job.record),{mode:0o600,flag:'wx'});await rename(tmp,filename(job.id));changed(job.id);}
16
17
  async function owned(id,exec){const who=identity(exec),job=jobs.get(id);if(job){if(!same(who,job.record.owner))throw new Error('Foreign review job');return job.record;}await safeDirectory();const name=filename(id),stat=await lstat(name);if(stat.isSymbolicLink()||!stat.isFile())throw new Error('Unsafe report file');const r=JSON.parse(await readFile(name,'utf8'));if(!same(who,r.owner))throw new Error('Foreign review job');if(['queued','running'].includes(r.status)){r.status='interrupted';r.notice='Host restarted or plugin disposed. Saved checkpoints are available; resending requires a fresh preview and approval.';}return r;}
17
18
  return {
18
19
  async start(exec,work){if(disposed)throw new Error('Review job service disposed');exec.signal?.throwIfAborted();const who=identity(exec);const relative=path.relative(path.resolve(who.cwd),directory);if(relative===''||(!relative.startsWith('..'+path.sep)&&relative!=='..'&&!path.isAbsolute(relative)))throw new Error('Report store must be outside reviewed workspace');if([...jobs.values()].some(j=>j.record.owner.sessionId===who.sessionId&&['queued','running'].includes(j.record.status)))throw new Error('Session review job already active');const id=randomUUID(),controller=new AbortController(),job={id,controller,record:{jobId:id,owner:who,status:'queued',stage:'queued',checkpoints:[],createdAt:new Date().toISOString()},pending:Promise.resolve()};jobs.set(id,job);try{await save(job);}catch(error){jobs.delete(id);throw error;}const enqueue=fn=>{job.pending=job.pending.then(fn);return job.pending;};
19
20
  // Own cancellation explicitly. The short start RPC ending is NOT task cancellation.
20
- job.done=(async()=>{await Promise.resolve();try{job.record.status='running';await enqueue(()=>save(job));const result=await work({signal:controller.signal,checkpoint:async value=>enqueue(async()=>{job.record.stage=value.stage??'reviewing';job.record.checkpoints.push(structuredClone(value));await save(job);})});job.record.result=result;job.record.status=controller.signal.aborted?'cancelled':'completed';}catch(error){job.record.status=controller.signal.aborted?'cancelled':'failed';job.record.error=error?.message??String(error);}finally{job.record.finishedAt=new Date().toISOString();try{await enqueue(()=>save(job));}catch(error){job.record.status='failed';job.record.persistenceError=error.message;}}})();return {jobId:id,status:'queued',notice:'Background job accepted. Poll status; start is not a completed review. Explicit cancel or plugin disposal stops it.'};
21
+ job.done=(async()=>{await Promise.resolve();try{job.record.status='running';await enqueue(()=>save(job));const result=await work({signal:controller.signal,checkpoint:async value=>enqueue(async()=>{job.record.stage=value.stage??'reviewing';job.record.checkpoints.push(structuredClone(value));await save(job);})});job.record.result=result;job.record.status=controller.signal.aborted?'cancelled':'completed';}catch(error){job.record.status=controller.signal.aborted?'cancelled':'failed';job.record.error=error?.message??String(error);}finally{job.record.finishedAt=new Date().toISOString();try{await enqueue(()=>save(job));}catch(error){job.record.status='failed';job.record.persistenceError=error.message;}}})();return {jobId:id,status:'queued',nextAction:'Call code_review_wait until terminal status; do not finish the user request at start.',notice:'Background job accepted. Poll status; start is not a completed review. Explicit cancel or plugin disposal stops it.'};
21
22
  },
22
- async status(id,exec,page){const r=structuredClone(await owned(id,exec));r.recovery=recoveryCoverage(r);if(!page)return r;const {offset=0,limit=20,checkpointIndex}=page;if(!Number.isSafeInteger(offset)||offset<0||!Number.isSafeInteger(limit)||limit<1||limit>100)throw new Error('Invalid result page');if(checkpointIndex!==undefined){if(!Number.isSafeInteger(checkpointIndex)||checkpointIndex<0||checkpointIndex>=r.checkpoints.length)throw new Error('Invalid checkpoint index');return {jobId:r.jobId,status:r.status,checkpointIndex,checkpoint:pageCheckpoint(r.checkpoints[checkpointIndex],offset,limit),page:{offset,limit}};}const result=r.result;const report=result?.report;return {...r,result:undefined,recovery:{...r.recovery,unfinishedStages:r.recovery.unfinishedStages.slice(offset,offset+limit),files:r.recovery.files.slice(offset,offset+limit),windows:r.recovery.windows.slice(offset,offset+limit),suggestedPaths:r.recovery.suggestedPaths.slice(offset,offset+limit),totalObservedFiles:r.recovery.files.length},checkpoints:r.checkpoints.slice(offset,offset+limit).map(c=>pageCheckpoint(c,0,limit)),checkpointPage:{offset,limit,total:r.checkpoints.length},...(result?{resultSummary:report?{status:report.status,coverage:report.coverage,model:report.model,snapshotId:report.snapshotId}:({...result,files:result.files?.slice(offset,offset+limit),filePage:result.files?{offset,limit,total:result.files.length}:undefined}),resultPage:report?{files:report.files?.slice(offset,offset+limit),findings:report.findings?.slice(offset,offset+limit),totalFiles:report.files?.length,totalFindings:report.findings?.length}:undefined}: {})};},
23
+ async status(id,exec,page){const r=structuredClone(await owned(id,exec));r.recovery=recoveryCoverage(r);r.progress=jobProgress(r);r.waitRevision=r.checkpoints.length;r.nextAction=['queued','running'].includes(r.status)?'Call code_review_wait; do not end the user review request while task runs.':r.result?.previewId?'Show preview and request user confirmation before execute.':'Collect final report and unfinished scope, then provide one final answer.';if(!page)return r;const {offset=0,limit=20,checkpointIndex}=page;if(!Number.isSafeInteger(offset)||offset<0||!Number.isSafeInteger(limit)||limit<1||limit>100)throw new Error('Invalid result page');if(checkpointIndex!==undefined){if(!Number.isSafeInteger(checkpointIndex)||checkpointIndex<0||checkpointIndex>=r.checkpoints.length)throw new Error('Invalid checkpoint index');return {jobId:r.jobId,status:r.status,checkpointIndex,checkpoint:pageCheckpoint(r.checkpoints[checkpointIndex],offset,limit),page:{offset,limit}};}const result=r.result;const report=result?.report;return {...r,result:undefined,recovery:{...r.recovery,unfinishedStages:r.recovery.unfinishedStages.slice(offset,offset+limit),files:r.recovery.files.slice(offset,offset+limit),windows:r.recovery.windows.slice(offset,offset+limit),suggestedPaths:r.recovery.suggestedPaths.slice(offset,offset+limit),totalObservedFiles:r.recovery.files.length},checkpoints:r.checkpoints.slice(offset,offset+limit).map(c=>pageCheckpoint(c,0,limit)),checkpointPage:{offset,limit,total:r.checkpoints.length},...(result?{resultSummary:report?{status:report.status,coverage:report.coverage,model:report.model,snapshotId:report.snapshotId}:({...result,files:result.files?.slice(offset,offset+limit),filePage:result.files?{offset,limit,total:result.files.length}:undefined}),resultPage:report?{files:report.files?.slice(offset,offset+limit),findings:report.findings?.slice(offset,offset+limit),totalFiles:report.files?.length,totalFindings:report.findings?.length}:undefined}: {})};},
24
+ async wait(id,exec,{after,timeoutMs=25000,signal}={}){const r=await owned(id,exec);if(!Number.isSafeInteger(timeoutMs)||timeoutMs<1||timeoutMs>60000)throw new Error('Invalid wait duration');if(after!==undefined&&(!Number.isSafeInteger(after)||after<0))throw new Error('Invalid wait revision');const revision=r.checkpoints.length;if(!['queued','running'].includes(r.status)||(after!==undefined&&after!==revision))return {changed:true};signal?.throwIfAborted();await new Promise((resolve,reject)=>{let timer;const cleanup=()=>{clearTimeout(timer);signal?.removeEventListener('abort',abort);listeners.get(id)?.delete(done);};const done=()=>{cleanup();resolve();},abort=()=>{cleanup();reject(signal.reason);};if(!listeners.has(id))listeners.set(id,new Set());listeners.get(id).add(done);signal?.addEventListener('abort',abort,{once:true});timer=setTimeout(done,timeoutMs);const latest=jobs.get(id)?.record;if(latest&&(latest.checkpoints.length!==revision||!['queued','running'].includes(latest.status)))done();if(signal?.aborted)abort();});return {changed:true};},
23
25
  async cancel(id,exec){await owned(id,exec);const j=jobs.get(id);if(j)j.controller.abort(new Error('User cancelled review job'));return {jobId:id,cancelRequested:!!j};},
24
26
  async dispose(){disposed=true;for(const j of jobs.values())j.controller.abort(new Error('Review plugin disposed'));await Promise.all([...jobs.values()].map(j=>j.done));}
25
27
  };