iterate-plugin 2.12.1 → 2.12.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.
- package/dist/jobs.js +68 -0
- package/dist/review.js +47 -0
- package/dist/tools/fix.js +160 -156
- package/dist/tools/review.js +135 -114
- package/package.json +4 -3
- package/src/jobs.ts +97 -0
- package/src/review.ts +64 -0
- package/src/tools/fix.ts +4 -0
- package/src/tools/review.ts +27 -2
- package/src/types.ts +26 -0
package/dist/jobs.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/jobs.ts — dsh Job Panel integration for iterate tool executions.
|
|
3
|
+
*
|
|
4
|
+
* dsh's background-job registry (`ctx.jobs`, @deepseek-ai/dsh-jobs) lets
|
|
5
|
+
* plugins surface long-running work in the client's Job Panel
|
|
6
|
+
* (`conversation.session.header.actions` list). We register custom kinds via
|
|
7
|
+
* declaration merging and wrap tool executions so each `iterate_review` /
|
|
8
|
+
* `iterate_fix` call shows up as a tracked job (running -> completed/failed).
|
|
9
|
+
*
|
|
10
|
+
* Defensive by design (matches the plugin's overall philosophy):
|
|
11
|
+
* - `ctx.jobs` only exists when the dsh host loaded a job registry + a
|
|
12
|
+
* controller serves the calling owner (`@deepseek-ai/dsh-tool-jobs` or an
|
|
13
|
+
* equivalent). When it is missing, `start()` throws or is absent — we
|
|
14
|
+
* detect both and fall through to plain execution, so the Job Panel is a
|
|
15
|
+
* pure enhancement and never breaks a tool call.
|
|
16
|
+
* - The registry is memory-only and panel rows are read-only (no progress
|
|
17
|
+
* updates), so these jobs are completion records, not control channels.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Run `fn` wrapped in a dsh background job, settling it completed/failed
|
|
21
|
+
* with the execution's outcome. When the host exposes no job registry (or
|
|
22
|
+
* refuses the start), `fn` runs untouched and `null` is returned — the Job
|
|
23
|
+
* Panel is an enhancement, never a dependency.
|
|
24
|
+
*
|
|
25
|
+
* @param ctx the dsh plugin context (may or may not expose `jobs`).
|
|
26
|
+
* @param kind iterate job kind registered via {@link IterateJobKind}.
|
|
27
|
+
* @param label one-line job label shown in the panel.
|
|
28
|
+
* @param fn the tool execution to track.
|
|
29
|
+
* @returns the registry-issued job id, or `null` when unavailable.
|
|
30
|
+
*/
|
|
31
|
+
export async function runWithJob(ctx, kind, label, fn) {
|
|
32
|
+
const jobs = ctx?.jobs;
|
|
33
|
+
if (!jobs || typeof jobs.start !== 'function') {
|
|
34
|
+
return { result: await fn(), jobId: null };
|
|
35
|
+
}
|
|
36
|
+
let settle;
|
|
37
|
+
const done = new Promise((resolve) => {
|
|
38
|
+
settle = resolve;
|
|
39
|
+
});
|
|
40
|
+
let jobId = null;
|
|
41
|
+
try {
|
|
42
|
+
jobId = jobs.start({
|
|
43
|
+
kind,
|
|
44
|
+
label,
|
|
45
|
+
run: () => ({
|
|
46
|
+
done,
|
|
47
|
+
cancel: () => settle({ status: 'killed', detail: 'cancelled' }),
|
|
48
|
+
}),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// Registry present but refuses work (e.g. no controller serves this
|
|
53
|
+
// owner) — run without panel tracking.
|
|
54
|
+
return { result: await fn(), jobId: null };
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
const result = await fn();
|
|
58
|
+
settle({ status: 'completed', detail: 'done' });
|
|
59
|
+
return { result, jobId };
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
settle({
|
|
63
|
+
status: 'failed',
|
|
64
|
+
detail: error instanceof Error ? error.message : 'execution failed',
|
|
65
|
+
});
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
}
|
package/dist/review.js
CHANGED
|
@@ -464,6 +464,40 @@ export function sanitizeRounds(rounds, schemaValidation) {
|
|
|
464
464
|
};
|
|
465
465
|
});
|
|
466
466
|
}
|
|
467
|
+
/**
|
|
468
|
+
* Build the "attached visual context" instruction block for a reviewer prompt.
|
|
469
|
+
*
|
|
470
|
+
* ``path``/``data`` attachments (screenshots, mockups, failure repros) are
|
|
471
|
+
* evidence a reviewer must weigh alongside the code — this clause names each
|
|
472
|
+
* one and mandates that the reviewer inspect/consider it (e.g. by opening the
|
|
473
|
+
* file with a vision-capable tool or the ``image_to_text`` bridge) before
|
|
474
|
+
* judging. Pure string construction; returns ``""`` when there are none.
|
|
475
|
+
*/
|
|
476
|
+
export function attachmentClause(attachments) {
|
|
477
|
+
if (!attachments || attachments.length === 0)
|
|
478
|
+
return '';
|
|
479
|
+
const lines = [];
|
|
480
|
+
for (const a of attachments) {
|
|
481
|
+
if (!a || typeof a !== 'object')
|
|
482
|
+
continue;
|
|
483
|
+
if (typeof a.path === 'string' && a.path) {
|
|
484
|
+
lines.push(`- ${a.path}${typeof a.caption === 'string' && a.caption ? ` (${a.caption})` : ''}`);
|
|
485
|
+
}
|
|
486
|
+
else if (typeof a.data === 'string' && a.data) {
|
|
487
|
+
const kind = typeof a.media_type === 'string' && a.media_type ? a.media_type : 'image';
|
|
488
|
+
lines.push(`- inline ${kind} image${typeof a.caption === 'string' && a.caption ? ` (${a.caption})` : ''}`);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
if (lines.length === 0)
|
|
492
|
+
return '';
|
|
493
|
+
return ('ATTACHED VISUAL CONTEXT (mandatory): the following image attachment(s) were provided ' +
|
|
494
|
+
'with this review — each one is part of the evidence you must weigh:\n' +
|
|
495
|
+
lines.join('\n') +
|
|
496
|
+
'\nYou MUST inspect/consider EVERY attachment before judging your dimension (open it ' +
|
|
497
|
+
'with a vision-capable tool, or use image_to_text if your model cannot see images). ' +
|
|
498
|
+
'If an attachment is inaccessible, state that and judge solely on the code. Do not ' +
|
|
499
|
+
'ignore an attachment just because it is not code.');
|
|
500
|
+
}
|
|
467
501
|
/**
|
|
468
502
|
* Build the task prompt for one dimension's reviewer subagent.
|
|
469
503
|
* In dry-run mode, pass `alreadyKnown` (the findings from earlier rounds) so the
|
|
@@ -475,6 +509,10 @@ export function reviewerTaskPrompt(input) {
|
|
|
475
509
|
if (input.focus) {
|
|
476
510
|
parts.push(`FOCUS: ${input.focus}`);
|
|
477
511
|
}
|
|
512
|
+
const attached = attachmentClause(input.attachments);
|
|
513
|
+
if (attached) {
|
|
514
|
+
parts.push(attached);
|
|
515
|
+
}
|
|
478
516
|
if (input.scopeFiles && input.scopeFiles.length > 0) {
|
|
479
517
|
parts.push('COVERAGE RULE (mandatory): below is the exact file inventory you are ' +
|
|
480
518
|
'assigned to review. You MUST open EVERY file in this inventory with ' +
|
|
@@ -528,6 +566,13 @@ export function buildReviewPlan(input) {
|
|
|
528
566
|
const dimensions = Array.isArray(input.config.dimensions) ? input.config.dimensions : [];
|
|
529
567
|
const maxLines = input.config.atomic?.max_lines ?? 20;
|
|
530
568
|
const changedFiles = Array.isArray(input.changedFiles) ? input.changedFiles : [];
|
|
569
|
+
// Defensive parse: keep only well-formed attachment entries (path or data).
|
|
570
|
+
const attachments = Array.isArray(input.attachments)
|
|
571
|
+
? input.attachments.filter((a) => Boolean(a) &&
|
|
572
|
+
typeof a === 'object' &&
|
|
573
|
+
((typeof a.path === 'string' && a.path.length > 0) ||
|
|
574
|
+
(typeof a.data === 'string' && a.data.length > 0)))
|
|
575
|
+
: [];
|
|
531
576
|
// changed-only with zero detected changes → auto-fallback to full scope.
|
|
532
577
|
const effectiveChangedOnly = configuredScope === 'changed-only' && changedFiles.length > 0;
|
|
533
578
|
const scope = effectiveChangedOnly ? 'changed-only' : 'full';
|
|
@@ -575,6 +620,7 @@ export function buildReviewPlan(input) {
|
|
|
575
620
|
changedFiles: effectiveChangedOnly ? changedFiles : undefined,
|
|
576
621
|
scopeFiles: batch,
|
|
577
622
|
focus: focusMap.get(d),
|
|
623
|
+
attachments,
|
|
578
624
|
}),
|
|
579
625
|
findingsSchema: findingsSchema(),
|
|
580
626
|
});
|
|
@@ -589,5 +635,6 @@ export function buildReviewPlan(input) {
|
|
|
589
635
|
knownIntentional: input.knownIntentional ?? [],
|
|
590
636
|
changedFiles: effectiveChangedOnly ? changedFiles : [],
|
|
591
637
|
fallbackToFull,
|
|
638
|
+
attachments,
|
|
592
639
|
};
|
|
593
640
|
}
|
package/dist/tools/fix.js
CHANGED
|
@@ -20,6 +20,7 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, writeF
|
|
|
20
20
|
import { join, sep } from 'node:path';
|
|
21
21
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
22
22
|
import { loadEffectiveConfig, resolveProjectRootForExec } from "../config-loader.js";
|
|
23
|
+
import { runWithJob } from "../jobs.js";
|
|
23
24
|
import { countTouchedMethods } from "../method-scope.js";
|
|
24
25
|
import { fixBackupPath, fixRegistryPath, fixesDir } from "../paths.js";
|
|
25
26
|
import { appendDecisionEntry } from "./decision-log.js";
|
|
@@ -331,170 +332,173 @@ export function registerFixTool(ctx) {
|
|
|
331
332
|
],
|
|
332
333
|
},
|
|
333
334
|
async execute(args, exec) {
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
if (typeof args.round !== 'number' || !Number.isInteger(args.round) || args.round < 1) {
|
|
353
|
-
return { ok: false, error: 'round must be a positive integer' };
|
|
354
|
-
}
|
|
355
|
-
const finding = args.finding;
|
|
356
|
-
if (!finding || typeof finding !== 'object') {
|
|
357
|
-
return { ok: false, error: 'finding must be an object' };
|
|
358
|
-
}
|
|
359
|
-
if (typeof finding.file !== 'string' || finding.file.trim().length === 0) {
|
|
360
|
-
return { ok: false, error: 'finding.file must be a non-empty string' };
|
|
361
|
-
}
|
|
362
|
-
if (typeof finding.dimension !== 'string' || finding.dimension.trim().length === 0) {
|
|
363
|
-
return { ok: false, error: 'finding.dimension must be a non-empty string' };
|
|
364
|
-
}
|
|
365
|
-
// The finding must reference the file being fixed — the fix id and the
|
|
366
|
-
// rollback/diff target are derived from finding.file, so a mismatch
|
|
367
|
-
// would back up/restore the WRONG file.
|
|
368
|
-
if (finding.file !== file) {
|
|
369
|
-
return { ok: false, error: `finding.file ("${finding.file}") must match the file being fixed ("${file}")` };
|
|
370
|
-
}
|
|
371
|
-
// Full finding validation, mirroring the review schema: malformed
|
|
372
|
-
// findings would produce lossy registry/log entries and a degraded id.
|
|
373
|
-
const SEVERITY_SET = new Set(['critical', 'high', 'medium', 'low']);
|
|
374
|
-
if (!SEVERITY_SET.has(finding.severity)) {
|
|
375
|
-
return { ok: false, error: 'finding.severity must be one of critical/high/medium/low' };
|
|
376
|
-
}
|
|
377
|
-
if (typeof finding.summary !== 'string' || finding.summary.trim().length === 0) {
|
|
378
|
-
return { ok: false, error: 'finding.summary must be a non-empty string' };
|
|
379
|
-
}
|
|
380
|
-
if (typeof finding.is_atomic !== 'boolean') {
|
|
381
|
-
return { ok: false, error: 'finding.is_atomic must be a boolean' };
|
|
382
|
-
}
|
|
383
|
-
if (finding.line !== undefined && finding.line !== null &&
|
|
384
|
-
(typeof finding.line !== 'number' || !Number.isInteger(finding.line) || finding.line < 0)) {
|
|
385
|
-
return { ok: false, error: 'finding.line must be a non-negative integer (0 = whole-file)' };
|
|
386
|
-
}
|
|
387
|
-
const current = readProjectFile(projectRoot, file);
|
|
388
|
-
if (!current.ok)
|
|
389
|
-
return { ok: false, error: current.reason };
|
|
390
|
-
const hunks = diffLines(current.content, args.content);
|
|
391
|
-
const { added, removed } = countChangedLines(current.content, args.content);
|
|
392
|
-
if (!args.force && (added > maxLines || removed > maxLines)) {
|
|
393
|
-
return {
|
|
394
|
-
ok: false,
|
|
395
|
-
error: `Change to ${file} exceeds the atomic threshold (max_lines=${maxLines}, change is +${added}/-${removed}). ` +
|
|
396
|
-
'Either split it into smaller atomic fixes or pass force:true if this is a deliberate architectural change.',
|
|
397
|
-
};
|
|
398
|
-
}
|
|
399
|
-
const touchedMethods = countTouchedMethods(current.content, args.content, hunks);
|
|
400
|
-
if (!args.force && touchedMethods > maxAdjacentMethods) {
|
|
401
|
-
return {
|
|
402
|
-
ok: false,
|
|
403
|
-
error: `Change to ${file} touches ${touchedMethods} adjacent method(s), exceeds atomic.max_adjacent_methods (${maxAdjacentMethods}). ` +
|
|
404
|
-
'Split it into smaller atomic fixes or pass force:true if this is a deliberate multi-method change.',
|
|
405
|
-
};
|
|
406
|
-
}
|
|
407
|
-
const id = fixId(finding);
|
|
408
|
-
const registry = readRegistry(projectRoot);
|
|
409
|
-
if (findFixRecord(registry, id)) {
|
|
410
|
-
return { ok: false, error: `finding already fixed this run (id: ${id})`, id };
|
|
411
|
-
}
|
|
412
|
-
const target = resolveProjectFile(projectRoot, file);
|
|
413
|
-
if (!target.ok)
|
|
414
|
-
return { ok: false, error: target.reason };
|
|
415
|
-
// Personalization guards (SKILL.md Phase 2): protected_paths veto the
|
|
416
|
-
// fix outright; forbidden_fixes veto fix approaches appearing in the
|
|
417
|
-
// new content. Both are security-relevant, so they are enforced here
|
|
418
|
-
// in the tool, not left to the model.
|
|
419
|
-
const pers = config.personalization;
|
|
420
|
-
const protectedPaths = Array.isArray(pers?.protected_paths)
|
|
421
|
-
? pers.protected_paths.filter((p) => typeof p === 'string' && p.length > 0)
|
|
422
|
-
: [];
|
|
423
|
-
for (const pattern of protectedPaths) {
|
|
424
|
-
if (globMatch(file, pattern)) {
|
|
425
|
-
return { ok: false, error: `skipped: ${file} matches protected path "${pattern}" (personalization.protected_paths forbids modifying it)` };
|
|
335
|
+
const { result } = await runWithJob(ctx, 'iterate-fix', `iterate_fix ${typeof args.file === 'string' && args.file ? args.file : '(?)'}`, async () => {
|
|
336
|
+
const resolved = resolveProjectRootForExec(exec, args.path);
|
|
337
|
+
if (!resolved.ok)
|
|
338
|
+
return { ok: false, error: resolved.reason };
|
|
339
|
+
const projectRoot = resolved.root;
|
|
340
|
+
const { config } = loadEffectiveConfig(projectRoot);
|
|
341
|
+
const maxLines = config.atomic?.max_lines ?? 20;
|
|
342
|
+
const maxAdjacentMethods = config.atomic?.max_adjacent_methods ?? 3;
|
|
343
|
+
const file = typeof args.file === 'string' ? args.file : '';
|
|
344
|
+
if (!file)
|
|
345
|
+
return { ok: false, error: 'file is required' };
|
|
346
|
+
if (typeof args.content !== 'string')
|
|
347
|
+
return { ok: false, error: 'content must be a string' };
|
|
348
|
+
if (args.content.length > MAX_FIX_CONTENT_CHARS) {
|
|
349
|
+
return {
|
|
350
|
+
ok: false,
|
|
351
|
+
error: `content exceeds the ${MAX_FIX_CONTENT_CHARS}-character limit (got ${args.content.length})`,
|
|
352
|
+
};
|
|
426
353
|
}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
? pers.forbidden_fixes.filter((f) => typeof f === 'string' && f.length > 0)
|
|
430
|
-
: [];
|
|
431
|
-
for (const forbidden of forbiddenFixes) {
|
|
432
|
-
if (args.content.includes(forbidden)) {
|
|
433
|
-
return { ok: false, error: `fix uses a forbidden approach: "${forbidden}" appears in the new content (personalization.forbidden_fixes)` };
|
|
354
|
+
if (typeof args.round !== 'number' || !Number.isInteger(args.round) || args.round < 1) {
|
|
355
|
+
return { ok: false, error: 'round must be a positive integer' };
|
|
434
356
|
}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
}
|
|
451
|
-
const record = {
|
|
452
|
-
id,
|
|
453
|
-
timestamp,
|
|
454
|
-
round: args.round,
|
|
455
|
-
finding,
|
|
456
|
-
backupPath,
|
|
457
|
-
diffSummary: buildDiffSummary(hunks),
|
|
458
|
-
linesAdded: added,
|
|
459
|
-
linesRemoved: removed,
|
|
460
|
-
success: true,
|
|
461
|
-
};
|
|
462
|
-
const nextRegistry = upsertRecord(registry, record);
|
|
463
|
-
try {
|
|
464
|
-
writeFileSync(fixRegistryPath(projectRoot), JSON.stringify(nextRegistry, null, 2), 'utf-8');
|
|
465
|
-
}
|
|
466
|
-
catch (err) {
|
|
467
|
-
// Registry write failed → the file was already modified but no record
|
|
468
|
-
// exists, so a later rollback/diff could never see it and a retry would
|
|
469
|
-
// back up the already-fixed content as "original". Restore the file
|
|
470
|
-
// from the backup to leave the tree exactly as it was.
|
|
471
|
-
try {
|
|
472
|
-
copyFileSync(backupPath, target.resolved);
|
|
357
|
+
const finding = args.finding;
|
|
358
|
+
if (!finding || typeof finding !== 'object') {
|
|
359
|
+
return { ok: false, error: 'finding must be an object' };
|
|
360
|
+
}
|
|
361
|
+
if (typeof finding.file !== 'string' || finding.file.trim().length === 0) {
|
|
362
|
+
return { ok: false, error: 'finding.file must be a non-empty string' };
|
|
363
|
+
}
|
|
364
|
+
if (typeof finding.dimension !== 'string' || finding.dimension.trim().length === 0) {
|
|
365
|
+
return { ok: false, error: 'finding.dimension must be a non-empty string' };
|
|
366
|
+
}
|
|
367
|
+
// The finding must reference the file being fixed — the fix id and the
|
|
368
|
+
// rollback/diff target are derived from finding.file, so a mismatch
|
|
369
|
+
// would back up/restore the WRONG file.
|
|
370
|
+
if (finding.file !== file) {
|
|
371
|
+
return { ok: false, error: `finding.file ("${finding.file}") must match the file being fixed ("${file}")` };
|
|
473
372
|
}
|
|
474
|
-
|
|
373
|
+
// Full finding validation, mirroring the review schema: malformed
|
|
374
|
+
// findings would produce lossy registry/log entries and a degraded id.
|
|
375
|
+
const SEVERITY_SET = new Set(['critical', 'high', 'medium', 'low']);
|
|
376
|
+
if (!SEVERITY_SET.has(finding.severity)) {
|
|
377
|
+
return { ok: false, error: 'finding.severity must be one of critical/high/medium/low' };
|
|
378
|
+
}
|
|
379
|
+
if (typeof finding.summary !== 'string' || finding.summary.trim().length === 0) {
|
|
380
|
+
return { ok: false, error: 'finding.summary must be a non-empty string' };
|
|
381
|
+
}
|
|
382
|
+
if (typeof finding.is_atomic !== 'boolean') {
|
|
383
|
+
return { ok: false, error: 'finding.is_atomic must be a boolean' };
|
|
384
|
+
}
|
|
385
|
+
if (finding.line !== undefined && finding.line !== null &&
|
|
386
|
+
(typeof finding.line !== 'number' || !Number.isInteger(finding.line) || finding.line < 0)) {
|
|
387
|
+
return { ok: false, error: 'finding.line must be a non-negative integer (0 = whole-file)' };
|
|
388
|
+
}
|
|
389
|
+
const current = readProjectFile(projectRoot, file);
|
|
390
|
+
if (!current.ok)
|
|
391
|
+
return { ok: false, error: current.reason };
|
|
392
|
+
const hunks = diffLines(current.content, args.content);
|
|
393
|
+
const { added, removed } = countChangedLines(current.content, args.content);
|
|
394
|
+
if (!args.force && (added > maxLines || removed > maxLines)) {
|
|
475
395
|
return {
|
|
476
396
|
ok: false,
|
|
477
|
-
error: `
|
|
397
|
+
error: `Change to ${file} exceeds the atomic threshold (max_lines=${maxLines}, change is +${added}/-${removed}). ` +
|
|
398
|
+
'Either split it into smaller atomic fixes or pass force:true if this is a deliberate architectural change.',
|
|
478
399
|
};
|
|
479
400
|
}
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
401
|
+
const touchedMethods = countTouchedMethods(current.content, args.content, hunks);
|
|
402
|
+
if (!args.force && touchedMethods > maxAdjacentMethods) {
|
|
403
|
+
return {
|
|
404
|
+
ok: false,
|
|
405
|
+
error: `Change to ${file} touches ${touchedMethods} adjacent method(s), exceeds atomic.max_adjacent_methods (${maxAdjacentMethods}). ` +
|
|
406
|
+
'Split it into smaller atomic fixes or pass force:true if this is a deliberate multi-method change.',
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
const id = fixId(finding);
|
|
410
|
+
const registry = readRegistry(projectRoot);
|
|
411
|
+
if (findFixRecord(registry, id)) {
|
|
412
|
+
return { ok: false, error: `finding already fixed this run (id: ${id})`, id };
|
|
413
|
+
}
|
|
414
|
+
const target = resolveProjectFile(projectRoot, file);
|
|
415
|
+
if (!target.ok)
|
|
416
|
+
return { ok: false, error: target.reason };
|
|
417
|
+
// Personalization guards (SKILL.md Phase 2): protected_paths veto the
|
|
418
|
+
// fix outright; forbidden_fixes veto fix approaches appearing in the
|
|
419
|
+
// new content. Both are security-relevant, so they are enforced here
|
|
420
|
+
// in the tool, not left to the model.
|
|
421
|
+
const pers = config.personalization;
|
|
422
|
+
const protectedPaths = Array.isArray(pers?.protected_paths)
|
|
423
|
+
? pers.protected_paths.filter((p) => typeof p === 'string' && p.length > 0)
|
|
424
|
+
: [];
|
|
425
|
+
for (const pattern of protectedPaths) {
|
|
426
|
+
if (globMatch(file, pattern)) {
|
|
427
|
+
return { ok: false, error: `skipped: ${file} matches protected path "${pattern}" (personalization.protected_paths forbids modifying it)` };
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
const forbiddenFixes = Array.isArray(pers?.forbidden_fixes)
|
|
431
|
+
? pers.forbidden_fixes.filter((f) => typeof f === 'string' && f.length > 0)
|
|
432
|
+
: [];
|
|
433
|
+
for (const forbidden of forbiddenFixes) {
|
|
434
|
+
if (args.content.includes(forbidden)) {
|
|
435
|
+
return { ok: false, error: `fix uses a forbidden approach: "${forbidden}" appears in the new content (personalization.forbidden_fixes)` };
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
const timestamp = new Date().toISOString();
|
|
439
|
+
const backupPath = fixBackupPath(projectRoot, id, timestamp);
|
|
440
|
+
try {
|
|
441
|
+
mkdirSync(fixesDir(projectRoot), { recursive: true });
|
|
442
|
+
copyFileSync(target.resolved, backupPath);
|
|
443
|
+
}
|
|
444
|
+
catch (err) {
|
|
445
|
+
return { ok: false, error: `failed to create backup: ${String(err)}` };
|
|
446
|
+
}
|
|
447
|
+
try {
|
|
448
|
+
writeFileSync(target.resolved, args.content, 'utf-8');
|
|
449
|
+
}
|
|
450
|
+
catch (err) {
|
|
451
|
+
return { ok: false, error: `failed to write file: ${String(err)}` };
|
|
452
|
+
}
|
|
453
|
+
const record = {
|
|
454
|
+
id,
|
|
455
|
+
timestamp,
|
|
456
|
+
round: args.round,
|
|
457
|
+
finding,
|
|
458
|
+
backupPath,
|
|
459
|
+
diffSummary: buildDiffSummary(hunks),
|
|
460
|
+
linesAdded: added,
|
|
461
|
+
linesRemoved: removed,
|
|
462
|
+
success: true,
|
|
463
|
+
};
|
|
464
|
+
const nextRegistry = upsertRecord(registry, record);
|
|
465
|
+
try {
|
|
466
|
+
writeFileSync(fixRegistryPath(projectRoot), JSON.stringify(nextRegistry, null, 2), 'utf-8');
|
|
467
|
+
}
|
|
468
|
+
catch (err) {
|
|
469
|
+
// Registry write failed → the file was already modified but no record
|
|
470
|
+
// exists, so a later rollback/diff could never see it and a retry would
|
|
471
|
+
// back up the already-fixed content as "original". Restore the file
|
|
472
|
+
// from the backup to leave the tree exactly as it was.
|
|
473
|
+
try {
|
|
474
|
+
copyFileSync(backupPath, target.resolved);
|
|
475
|
+
}
|
|
476
|
+
catch (restoreErr) {
|
|
477
|
+
return {
|
|
478
|
+
ok: false,
|
|
479
|
+
error: `failed to write fix registry: ${String(err)}; additionally failed to restore ${file} from backup: ${String(restoreErr)}`,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
return { ok: false, error: `failed to write fix registry: ${String(err)} (file restored from backup)` };
|
|
483
|
+
}
|
|
484
|
+
appendDecisionEntry(projectRoot, {
|
|
485
|
+
timestamp,
|
|
486
|
+
round: args.round,
|
|
487
|
+
type: 'atomic_fix',
|
|
488
|
+
data: { id, file, finding: finding.summary, linesAdded: added, linesRemoved: removed },
|
|
489
|
+
});
|
|
490
|
+
return {
|
|
491
|
+
ok: true,
|
|
492
|
+
id,
|
|
493
|
+
file,
|
|
494
|
+
round: args.round,
|
|
495
|
+
linesAdded: added,
|
|
496
|
+
linesRemoved: removed,
|
|
497
|
+
diffSummary: record.diffSummary,
|
|
498
|
+
backupPath,
|
|
499
|
+
};
|
|
487
500
|
});
|
|
488
|
-
return
|
|
489
|
-
ok: true,
|
|
490
|
-
id,
|
|
491
|
-
file,
|
|
492
|
-
round: args.round,
|
|
493
|
-
linesAdded: added,
|
|
494
|
-
linesRemoved: removed,
|
|
495
|
-
diffSummary: record.diffSummary,
|
|
496
|
-
backupPath,
|
|
497
|
-
};
|
|
501
|
+
return result;
|
|
498
502
|
},
|
|
499
503
|
}));
|
|
500
504
|
}
|
package/dist/tools/review.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
2
2
|
import { loadEffectiveConfig, resolveProjectRootForExec } from "../config-loader.js";
|
|
3
|
+
import { runWithJob } from "../jobs.js";
|
|
3
4
|
import { buildReviewPlan, buildReviewReport, sanitizeRounds, validateRoundsSchema, } from "../review.js";
|
|
4
5
|
import { buildFinalReviewReport, metaReviewReport } from "../meta-review.js";
|
|
5
6
|
import { evidenceToPlain, verifyFindings } from "../evidence.js";
|
|
@@ -65,6 +66,15 @@ export function registerReviewTool(ctx) {
|
|
|
65
66
|
description: 'For `meta-review`: the ReviewReport JSON (as returned by `aggregate`) to audit for ' +
|
|
66
67
|
'internal consistency and produce the final review report.',
|
|
67
68
|
},
|
|
69
|
+
attachments: {
|
|
70
|
+
type: 'json',
|
|
71
|
+
description: 'Optional (plan only): image/visual attachments to thread into the review, e.g. ' +
|
|
72
|
+
'[{"path":"screens/hits.png","caption":"reproduced layout bug"}]. Each entry: ' +
|
|
73
|
+
'{path?, data?, media_type?, caption?} — path resolves relative to the project root, ' +
|
|
74
|
+
'data is a base64 payload (media_type e.g. image/png), caption gives human context. ' +
|
|
75
|
+
'Injected as a mandatory clause into every dimension reviewer prompt so screenshots/' +
|
|
76
|
+
'mockups/failure repros are weighed alongside the code.',
|
|
77
|
+
},
|
|
68
78
|
fixedCount: {
|
|
69
79
|
type: 'integer',
|
|
70
80
|
description: 'For `aggregate` (normal mode only): number of atomic fixes applied so far. ' +
|
|
@@ -107,134 +117,145 @@ export function registerReviewTool(ctx) {
|
|
|
107
117
|
],
|
|
108
118
|
},
|
|
109
119
|
async execute(args, exec) {
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const projectRoot = resolved.root;
|
|
115
|
-
// Effective config = defaults merged with project overrides. Never
|
|
116
|
-
// null, so `plan`/`aggregate` work even without a config file.
|
|
117
|
-
const { config } = loadEffectiveConfig(projectRoot);
|
|
118
|
-
const mode = args.mode ?? 'dry-run';
|
|
119
|
-
if (args.operation === 'plan') {
|
|
120
|
-
const maxReviewRounds = args.maxReviewRounds ?? config.max_rounds ?? DEFAULT_MAX_REVIEW_ROUNDS;
|
|
121
|
-
const knownIntentional = config.personalization
|
|
122
|
-
?.known_intentional;
|
|
123
|
-
// changed-only scope: resolve the changed-file set against
|
|
124
|
-
// git.target_branch before building the plan so reviewers get the
|
|
125
|
-
// concrete file list (and the plan auto-falls back to full when there
|
|
126
|
-
// are no changes). git failures degrade to a full-scope plan.
|
|
127
|
-
let changedFiles;
|
|
128
|
-
if (config.review?.scope === 'changed-only') {
|
|
129
|
-
const gitScope = await resolveChangedFiles(projectRoot, config.git?.target_branch ?? 'main');
|
|
130
|
-
changedFiles = gitScope.changedFiles;
|
|
120
|
+
const { result } = await runWithJob(ctx, 'iterate-review', `iterate_review ${String(args.operation ?? '')} (${String(args.mode ?? 'dry-run')})`, async () => {
|
|
121
|
+
const resolved = resolveProjectRootForExec(exec, args.path);
|
|
122
|
+
if (!resolved.ok) {
|
|
123
|
+
return { operation: args.operation, error: resolved.reason };
|
|
131
124
|
}
|
|
132
|
-
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
125
|
+
const projectRoot = resolved.root;
|
|
126
|
+
// Effective config = defaults merged with project overrides. Never
|
|
127
|
+
// null, so `plan`/`aggregate` work even without a config file.
|
|
128
|
+
const { config } = loadEffectiveConfig(projectRoot);
|
|
129
|
+
const mode = args.mode ?? 'dry-run';
|
|
130
|
+
if (args.operation === 'plan') {
|
|
131
|
+
const maxReviewRounds = args.maxReviewRounds ?? config.max_rounds ?? DEFAULT_MAX_REVIEW_ROUNDS;
|
|
132
|
+
const knownIntentional = config.personalization
|
|
133
|
+
?.known_intentional;
|
|
134
|
+
// changed-only scope: resolve the changed-file set against
|
|
135
|
+
// git.target_branch before building the plan so reviewers get the
|
|
136
|
+
// concrete file list (and the plan auto-falls back to full when there
|
|
137
|
+
// are no changes). git failures degrade to a full-scope plan.
|
|
138
|
+
let changedFiles;
|
|
139
|
+
if (config.review?.scope === 'changed-only') {
|
|
140
|
+
const gitScope = await resolveChangedFiles(projectRoot, config.git?.target_branch ?? 'main');
|
|
141
|
+
changedFiles = gitScope.changedFiles;
|
|
142
|
+
}
|
|
143
|
+
// Full-codebase review: pre-collect the source inventory so
|
|
144
|
+
// buildReviewPlan can batch it into per-chunk reviewer tasks
|
|
145
|
+
// (coverage enforcement).
|
|
146
|
+
let scopeFiles;
|
|
147
|
+
if (config.review?.scope === 'full') {
|
|
148
|
+
scopeFiles = collectScopeFiles(projectRoot, { scope: 'full' });
|
|
149
|
+
}
|
|
150
|
+
// Thread image/visual attachments (screenshots/mockups/failure repros)
|
|
151
|
+
// into the plan so every reviewer prompt weighs them alongside code.
|
|
152
|
+
const attachments = Array.isArray(args.attachments)
|
|
153
|
+
? args.attachments.filter((a) => Boolean(a) &&
|
|
154
|
+
typeof a === 'object' &&
|
|
155
|
+
((typeof a.path === 'string' && a.path.length > 0) ||
|
|
156
|
+
(typeof a.data === 'string' && a.data.length > 0)))
|
|
150
157
|
: [];
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
if (
|
|
158
|
+
const plan = buildReviewPlan({ config, mode, maxReviewRounds, knownIntentional, changedFiles, scopeFiles, attachments });
|
|
159
|
+
return { operation: 'plan', mode, found: true, plan: plan };
|
|
160
|
+
}
|
|
161
|
+
if (args.operation === 'aggregate') {
|
|
162
|
+
const rawRounds = Array.isArray(args.rounds) ? args.rounds : [];
|
|
163
|
+
const rounds = rawRounds
|
|
164
|
+
.map((r) => {
|
|
165
|
+
const rr = r;
|
|
166
|
+
const findings = Array.isArray(rr?.findings) ? rr.findings : [];
|
|
167
|
+
const readFiles = Array.isArray(rr?.readFiles)
|
|
168
|
+
? rr.readFiles.filter((f) => typeof f === 'string')
|
|
169
|
+
: [];
|
|
170
|
+
return { round: typeof rr?.round === 'number' ? rr.round : 0, findings, readFiles };
|
|
171
|
+
})
|
|
172
|
+
.filter((r) => r.round > 0);
|
|
173
|
+
if (rounds.length === 0) {
|
|
174
|
+
return {
|
|
175
|
+
operation: 'aggregate',
|
|
176
|
+
mode,
|
|
177
|
+
error: 'rounds must be a non-empty array of {round, findings}.',
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
const maxReviewRounds = args.maxReviewRounds ?? config.max_rounds ?? DEFAULT_MAX_REVIEW_ROUNDS;
|
|
181
|
+
const goal = args.goal ?? config.goal ?? '';
|
|
182
|
+
const dimensions = config.dimensions ?? [];
|
|
183
|
+
// Output schema validation gate (reviewer.output_schema_validation,
|
|
184
|
+
// default true): validate every round's findings against the findings
|
|
185
|
+
// schema, then drop schema-invalid entries before the deterministic
|
|
186
|
+
// core so malformed reviewer output can never crash dedupe/sort or
|
|
187
|
+
// leak into fixes. The `schemaValidation` array is surfaced so the
|
|
188
|
+
// workflow can retry failing rounds (≤2 times) with a strict-JSON
|
|
189
|
+
// nudge. When disabled, non-object entries are still dropped for
|
|
190
|
+
// crash-safety.
|
|
191
|
+
const schemaEnabled = config.reviewer?.output_schema_validation !== false;
|
|
192
|
+
const schemaValidation = schemaEnabled ? validateRoundsSchema(rounds) : null;
|
|
193
|
+
const cleanRounds = sanitizeRounds(rounds, schemaValidation);
|
|
194
|
+
const report = buildReviewReport({
|
|
195
|
+
mode,
|
|
196
|
+
goal,
|
|
197
|
+
dimensions,
|
|
198
|
+
maxReviewRounds,
|
|
199
|
+
rounds: cleanRounds,
|
|
200
|
+
knownIntentional: args.knownIntentional,
|
|
201
|
+
fixedCount: typeof args.fixedCount === 'number' ? args.fixedCount : undefined,
|
|
202
|
+
});
|
|
155
203
|
return {
|
|
156
204
|
operation: 'aggregate',
|
|
157
205
|
mode,
|
|
158
|
-
|
|
206
|
+
report: report,
|
|
207
|
+
schemaValidation: (schemaValidation ?? null),
|
|
159
208
|
};
|
|
160
209
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
210
|
+
if (args.operation === 'meta-review') {
|
|
211
|
+
const source = args.report;
|
|
212
|
+
if (!source || typeof source !== 'object') {
|
|
213
|
+
return {
|
|
214
|
+
operation: 'meta-review',
|
|
215
|
+
mode,
|
|
216
|
+
error: 'report must be a ReviewReport JSON object (as returned by `aggregate`).',
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
const audit = metaReviewReport(source);
|
|
220
|
+
// Hard code-evidence gate (default on): every finding's file/line is
|
|
221
|
+
// validated against real files on disk before folding into the final
|
|
222
|
+
// verdict. Disable via config `reviewer.evidence_validation: false`.
|
|
223
|
+
const evidenceEnabled = config.reviewer?.evidence_validation !== false;
|
|
224
|
+
const findings = Array.isArray(source.findings) ? source.findings : [];
|
|
225
|
+
const evidence = evidenceEnabled ? verifyFindings(projectRoot, findings) : null;
|
|
226
|
+
// Prompt-informative coverage: compare the reviewer's self-reported
|
|
227
|
+
// reads against the assigned scope inventory (never flips the
|
|
228
|
+
// verdict). Disable via config `reviewer.coverage_validation: false`.
|
|
229
|
+
const coverageEnabled = config.reviewer?.coverage_validation !== false;
|
|
230
|
+
let coverage = null;
|
|
231
|
+
if (coverageEnabled) {
|
|
232
|
+
const assigned = collectScopeFiles(projectRoot, {
|
|
233
|
+
scope: config.review?.scope === 'changed-only' ? 'changed-only' : 'full',
|
|
234
|
+
});
|
|
235
|
+
const readFiles = Array.isArray(source.readFiles)
|
|
236
|
+
? source.readFiles
|
|
237
|
+
: null;
|
|
238
|
+
if (readFiles && readFiles.length > 0) {
|
|
239
|
+
coverage = computeCoverage(assigned, readFiles);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const finalReport = buildFinalReviewReport(source, { evidence, coverage });
|
|
194
243
|
return {
|
|
195
244
|
operation: 'meta-review',
|
|
196
245
|
mode,
|
|
197
|
-
|
|
246
|
+
found: true,
|
|
247
|
+
report: audit,
|
|
248
|
+
evidence: evidence ? evidenceToPlain(evidence) : null,
|
|
249
|
+
coverage: coverage ? coverageToDict(coverage) : null,
|
|
250
|
+
finalReport: finalReport,
|
|
198
251
|
};
|
|
199
252
|
}
|
|
200
|
-
const audit = metaReviewReport(source);
|
|
201
|
-
// Hard code-evidence gate (default on): every finding's file/line is
|
|
202
|
-
// validated against real files on disk before folding into the final
|
|
203
|
-
// verdict. Disable via config `reviewer.evidence_validation: false`.
|
|
204
|
-
const evidenceEnabled = config.reviewer?.evidence_validation !== false;
|
|
205
|
-
const findings = Array.isArray(source.findings) ? source.findings : [];
|
|
206
|
-
const evidence = evidenceEnabled ? verifyFindings(projectRoot, findings) : null;
|
|
207
|
-
// Prompt-informative coverage: compare the reviewer's self-reported
|
|
208
|
-
// reads against the assigned scope inventory (never flips the
|
|
209
|
-
// verdict). Disable via config `reviewer.coverage_validation: false`.
|
|
210
|
-
const coverageEnabled = config.reviewer?.coverage_validation !== false;
|
|
211
|
-
let coverage = null;
|
|
212
|
-
if (coverageEnabled) {
|
|
213
|
-
const assigned = collectScopeFiles(projectRoot, {
|
|
214
|
-
scope: config.review?.scope === 'changed-only' ? 'changed-only' : 'full',
|
|
215
|
-
});
|
|
216
|
-
const readFiles = Array.isArray(source.readFiles)
|
|
217
|
-
? source.readFiles
|
|
218
|
-
: null;
|
|
219
|
-
if (readFiles && readFiles.length > 0) {
|
|
220
|
-
coverage = computeCoverage(assigned, readFiles);
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
const finalReport = buildFinalReviewReport(source, { evidence, coverage });
|
|
224
253
|
return {
|
|
225
|
-
operation:
|
|
226
|
-
|
|
227
|
-
found: true,
|
|
228
|
-
report: audit,
|
|
229
|
-
evidence: evidence ? evidenceToPlain(evidence) : null,
|
|
230
|
-
coverage: coverage ? coverageToDict(coverage) : null,
|
|
231
|
-
finalReport: finalReport,
|
|
254
|
+
operation: args.operation,
|
|
255
|
+
error: `Unknown operation "${args.operation}". Use "plan", "aggregate", or "meta-review".`,
|
|
232
256
|
};
|
|
233
|
-
}
|
|
234
|
-
return
|
|
235
|
-
operation: args.operation,
|
|
236
|
-
error: `Unknown operation "${args.operation}". Use "plan", "aggregate", or "meta-review".`,
|
|
237
|
-
};
|
|
257
|
+
});
|
|
258
|
+
return result;
|
|
238
259
|
},
|
|
239
260
|
}));
|
|
240
261
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iterate-plugin",
|
|
3
|
-
"version": "2.12.
|
|
3
|
+
"version": "2.12.2",
|
|
4
4
|
"description": "dsh plugin that turns the iterate skill into an autonomous closed-loop harness: plan -> parallel review xN -> atomic fixes -> validate -> loop -> auto-stop, plus a dry-run pure-review mode with multi-round convergence and a meta-review that audits the report and emits a final review report.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -61,11 +61,12 @@
|
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
63
|
"@deepseek-ai/cordis": "4.0.1",
|
|
64
|
-
"@deepseek-ai/dsh-tools": "0.1.1-rc.
|
|
64
|
+
"@deepseek-ai/dsh-tools": "0.1.1-rc.2",
|
|
65
65
|
"js-yaml": "4.3.1"
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|
|
68
|
-
"@deepseek-ai/dsh-
|
|
68
|
+
"@deepseek-ai/dsh-jobs": "^0.1.1-rc.2",
|
|
69
|
+
"@deepseek-ai/dsh-session": "0.1.1-rc.2",
|
|
69
70
|
"@types/js-yaml": "4.0.9",
|
|
70
71
|
"@types/node": "22.15.0",
|
|
71
72
|
"@types/react": "19.2.2",
|
package/src/jobs.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/jobs.ts — dsh Job Panel integration for iterate tool executions.
|
|
3
|
+
*
|
|
4
|
+
* dsh's background-job registry (`ctx.jobs`, @deepseek-ai/dsh-jobs) lets
|
|
5
|
+
* plugins surface long-running work in the client's Job Panel
|
|
6
|
+
* (`conversation.session.header.actions` list). We register custom kinds via
|
|
7
|
+
* declaration merging and wrap tool executions so each `iterate_review` /
|
|
8
|
+
* `iterate_fix` call shows up as a tracked job (running -> completed/failed).
|
|
9
|
+
*
|
|
10
|
+
* Defensive by design (matches the plugin's overall philosophy):
|
|
11
|
+
* - `ctx.jobs` only exists when the dsh host loaded a job registry + a
|
|
12
|
+
* controller serves the calling owner (`@deepseek-ai/dsh-tool-jobs` or an
|
|
13
|
+
* equivalent). When it is missing, `start()` throws or is absent — we
|
|
14
|
+
* detect both and fall through to plain execution, so the Job Panel is a
|
|
15
|
+
* pure enhancement and never breaks a tool call.
|
|
16
|
+
* - The registry is memory-only and panel rows are read-only (no progress
|
|
17
|
+
* updates), so these jobs are completion records, not control channels.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { JobOutcome, JobRegistry } from '@deepseek-ai/dsh-jobs'
|
|
21
|
+
|
|
22
|
+
/** Extend dsh's producer-kind registry with iterate's custom kinds. */
|
|
23
|
+
declare module '@deepseek-ai/dsh-jobs' {
|
|
24
|
+
interface JobKindMap {
|
|
25
|
+
'iterate-review': 'iterate-review'
|
|
26
|
+
'iterate-fix': 'iterate-fix'
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Custom job kinds this plugin registers. */
|
|
31
|
+
export type IterateJobKind = 'iterate-review' | 'iterate-fix'
|
|
32
|
+
|
|
33
|
+
/** Shape of the `ctx.jobs` surface we rely on (duck-typed for safety). */
|
|
34
|
+
interface JobsLike {
|
|
35
|
+
start(spec: {
|
|
36
|
+
kind: IterateJobKind
|
|
37
|
+
label: string
|
|
38
|
+
run(): { done: Promise<JobOutcome>; cancel?: () => void }
|
|
39
|
+
}): string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Run `fn` wrapped in a dsh background job, settling it completed/failed
|
|
44
|
+
* with the execution's outcome. When the host exposes no job registry (or
|
|
45
|
+
* refuses the start), `fn` runs untouched and `null` is returned — the Job
|
|
46
|
+
* Panel is an enhancement, never a dependency.
|
|
47
|
+
*
|
|
48
|
+
* @param ctx the dsh plugin context (may or may not expose `jobs`).
|
|
49
|
+
* @param kind iterate job kind registered via {@link IterateJobKind}.
|
|
50
|
+
* @param label one-line job label shown in the panel.
|
|
51
|
+
* @param fn the tool execution to track.
|
|
52
|
+
* @returns the registry-issued job id, or `null` when unavailable.
|
|
53
|
+
*/
|
|
54
|
+
export async function runWithJob<T>(
|
|
55
|
+
ctx: unknown,
|
|
56
|
+
kind: IterateJobKind,
|
|
57
|
+
label: string,
|
|
58
|
+
fn: () => Promise<T> | T,
|
|
59
|
+
): Promise<{ result: T; jobId: string | null }> {
|
|
60
|
+
const jobs = (ctx as { jobs?: JobsLike } | undefined)?.jobs
|
|
61
|
+
if (!jobs || typeof jobs.start !== 'function') {
|
|
62
|
+
return { result: await fn(), jobId: null }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let settle!: (outcome: JobOutcome) => void
|
|
66
|
+
const done = new Promise<JobOutcome>((resolve) => {
|
|
67
|
+
settle = resolve
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
let jobId: string | null = null
|
|
71
|
+
try {
|
|
72
|
+
jobId = jobs.start({
|
|
73
|
+
kind,
|
|
74
|
+
label,
|
|
75
|
+
run: () => ({
|
|
76
|
+
done,
|
|
77
|
+
cancel: () => settle({ status: 'killed', detail: 'cancelled' }),
|
|
78
|
+
}),
|
|
79
|
+
})
|
|
80
|
+
} catch {
|
|
81
|
+
// Registry present but refuses work (e.g. no controller serves this
|
|
82
|
+
// owner) — run without panel tracking.
|
|
83
|
+
return { result: await fn(), jobId: null }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const result = await fn()
|
|
88
|
+
settle({ status: 'completed', detail: 'done' })
|
|
89
|
+
return { result, jobId }
|
|
90
|
+
} catch (error) {
|
|
91
|
+
settle({
|
|
92
|
+
status: 'failed',
|
|
93
|
+
detail: error instanceof Error ? error.message : 'execution failed',
|
|
94
|
+
})
|
|
95
|
+
throw error
|
|
96
|
+
}
|
|
97
|
+
}
|
package/src/review.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import type {
|
|
21
21
|
IterateConfig,
|
|
22
22
|
KnownIntentional,
|
|
23
|
+
ReviewAttachment,
|
|
23
24
|
ReviewFinding,
|
|
24
25
|
ReviewReport,
|
|
25
26
|
ReviewRound,
|
|
@@ -546,6 +547,39 @@ export function sanitizeRounds(
|
|
|
546
547
|
})
|
|
547
548
|
}
|
|
548
549
|
|
|
550
|
+
/**
|
|
551
|
+
* Build the "attached visual context" instruction block for a reviewer prompt.
|
|
552
|
+
*
|
|
553
|
+
* ``path``/``data`` attachments (screenshots, mockups, failure repros) are
|
|
554
|
+
* evidence a reviewer must weigh alongside the code — this clause names each
|
|
555
|
+
* one and mandates that the reviewer inspect/consider it (e.g. by opening the
|
|
556
|
+
* file with a vision-capable tool or the ``image_to_text`` bridge) before
|
|
557
|
+
* judging. Pure string construction; returns ``""`` when there are none.
|
|
558
|
+
*/
|
|
559
|
+
export function attachmentClause(attachments: ReviewAttachment[] | undefined): string {
|
|
560
|
+
if (!attachments || attachments.length === 0) return ''
|
|
561
|
+
const lines: string[] = []
|
|
562
|
+
for (const a of attachments) {
|
|
563
|
+
if (!a || typeof a !== 'object') continue
|
|
564
|
+
if (typeof a.path === 'string' && a.path) {
|
|
565
|
+
lines.push(`- ${a.path}${typeof a.caption === 'string' && a.caption ? ` (${a.caption})` : ''}`)
|
|
566
|
+
} else if (typeof a.data === 'string' && a.data) {
|
|
567
|
+
const kind = typeof a.media_type === 'string' && a.media_type ? a.media_type : 'image'
|
|
568
|
+
lines.push(`- inline ${kind} image${typeof a.caption === 'string' && a.caption ? ` (${a.caption})` : ''}`)
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
if (lines.length === 0) return ''
|
|
572
|
+
return (
|
|
573
|
+
'ATTACHED VISUAL CONTEXT (mandatory): the following image attachment(s) were provided ' +
|
|
574
|
+
'with this review — each one is part of the evidence you must weigh:\n' +
|
|
575
|
+
lines.join('\n') +
|
|
576
|
+
'\nYou MUST inspect/consider EVERY attachment before judging your dimension (open it ' +
|
|
577
|
+
'with a vision-capable tool, or use image_to_text if your model cannot see images). ' +
|
|
578
|
+
'If an attachment is inaccessible, state that and judge solely on the code. Do not ' +
|
|
579
|
+
'ignore an attachment just because it is not code.'
|
|
580
|
+
)
|
|
581
|
+
}
|
|
582
|
+
|
|
549
583
|
/**
|
|
550
584
|
* Build the task prompt for one dimension's reviewer subagent.
|
|
551
585
|
* In dry-run mode, pass `alreadyKnown` (the findings from earlier rounds) so the
|
|
@@ -581,6 +615,12 @@ export function reviewerTaskPrompt(input: {
|
|
|
581
615
|
* review concentrates on the areas the user cares about.
|
|
582
616
|
*/
|
|
583
617
|
focus?: string
|
|
618
|
+
/**
|
|
619
|
+
* Image/visual attachments to weigh alongside the code (screenshots,
|
|
620
|
+
* mockups, failure repros). Injected as a mandatory review-aware clause so
|
|
621
|
+
* every dimension reviewer inspects/considers them before judging.
|
|
622
|
+
*/
|
|
623
|
+
attachments?: ReviewAttachment[]
|
|
584
624
|
}): string {
|
|
585
625
|
const parts: string[] = []
|
|
586
626
|
parts.push(
|
|
@@ -591,6 +631,10 @@ export function reviewerTaskPrompt(input: {
|
|
|
591
631
|
if (input.focus) {
|
|
592
632
|
parts.push(`FOCUS: ${input.focus}`)
|
|
593
633
|
}
|
|
634
|
+
const attached = attachmentClause(input.attachments)
|
|
635
|
+
if (attached) {
|
|
636
|
+
parts.push(attached)
|
|
637
|
+
}
|
|
594
638
|
if (input.scopeFiles && input.scopeFiles.length > 0) {
|
|
595
639
|
parts.push(
|
|
596
640
|
'COVERAGE RULE (mandatory): below is the exact file inventory you are ' +
|
|
@@ -672,6 +716,12 @@ export function buildReviewPlan(input: {
|
|
|
672
716
|
* complete inventory it must open file-by-file.
|
|
673
717
|
*/
|
|
674
718
|
scopeFiles?: string[]
|
|
719
|
+
/**
|
|
720
|
+
* Image/visual attachments to thread into the review. Injected as a
|
|
721
|
+
* mandatory clause into every dimension's reviewer prompt and surfaced on
|
|
722
|
+
* the returned plan so the orchestrator/report can reference them.
|
|
723
|
+
*/
|
|
724
|
+
attachments?: ReviewAttachment[]
|
|
675
725
|
}): {
|
|
676
726
|
mode: 'normal' | 'dry-run'
|
|
677
727
|
goal: string
|
|
@@ -683,6 +733,8 @@ export function buildReviewPlan(input: {
|
|
|
683
733
|
changedFiles: string[]
|
|
684
734
|
/** True when scope was `changed-only` but no changes were found. */
|
|
685
735
|
fallbackToFull: boolean
|
|
736
|
+
/** The attachments threaded into every reviewer prompt (empty when none). */
|
|
737
|
+
attachments: ReviewAttachment[]
|
|
686
738
|
} {
|
|
687
739
|
// Defensive reads: a malformed config (e.g. `dimensions` as a non-array, or
|
|
688
740
|
// `review`/`atomic` missing) must degrade to sane defaults instead of
|
|
@@ -693,6 +745,16 @@ export function buildReviewPlan(input: {
|
|
|
693
745
|
const dimensions = Array.isArray(input.config.dimensions) ? input.config.dimensions : []
|
|
694
746
|
const maxLines = input.config.atomic?.max_lines ?? 20
|
|
695
747
|
const changedFiles = Array.isArray(input.changedFiles) ? input.changedFiles : []
|
|
748
|
+
// Defensive parse: keep only well-formed attachment entries (path or data).
|
|
749
|
+
const attachments = Array.isArray(input.attachments)
|
|
750
|
+
? input.attachments.filter(
|
|
751
|
+
(a): a is ReviewAttachment =>
|
|
752
|
+
Boolean(a) &&
|
|
753
|
+
typeof a === 'object' &&
|
|
754
|
+
((typeof a.path === 'string' && a.path.length > 0) ||
|
|
755
|
+
(typeof a.data === 'string' && a.data.length > 0)),
|
|
756
|
+
)
|
|
757
|
+
: []
|
|
696
758
|
// changed-only with zero detected changes → auto-fallback to full scope.
|
|
697
759
|
const effectiveChangedOnly = configuredScope === 'changed-only' && changedFiles.length > 0
|
|
698
760
|
const scope: 'full' | 'changed-only' = effectiveChangedOnly ? 'changed-only' : 'full'
|
|
@@ -744,6 +806,7 @@ export function buildReviewPlan(input: {
|
|
|
744
806
|
changedFiles: effectiveChangedOnly ? changedFiles : undefined,
|
|
745
807
|
scopeFiles: batch,
|
|
746
808
|
focus: focusMap.get(d),
|
|
809
|
+
attachments,
|
|
747
810
|
}),
|
|
748
811
|
findingsSchema: findingsSchema(),
|
|
749
812
|
})
|
|
@@ -759,5 +822,6 @@ export function buildReviewPlan(input: {
|
|
|
759
822
|
knownIntentional: input.knownIntentional ?? [],
|
|
760
823
|
changedFiles: effectiveChangedOnly ? changedFiles : [],
|
|
761
824
|
fallbackToFull,
|
|
825
|
+
attachments,
|
|
762
826
|
}
|
|
763
827
|
}
|
package/src/tools/fix.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { join, sep } from 'node:path'
|
|
|
22
22
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
23
23
|
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
|
24
24
|
import { loadEffectiveConfig, resolveProjectRootForExec } from '../config-loader.ts'
|
|
25
|
+
import { runWithJob } from '../jobs.ts'
|
|
25
26
|
import { countTouchedMethods } from '../method-scope.ts'
|
|
26
27
|
import { fixBackupPath, fixRegistryPath, fixesDir } from '../paths.ts'
|
|
27
28
|
import { appendDecisionEntry } from './decision-log.ts'
|
|
@@ -335,6 +336,7 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
|
|
|
335
336
|
},
|
|
336
337
|
|
|
337
338
|
async execute(args, exec) {
|
|
339
|
+
const { result } = await runWithJob(ctx, 'iterate-fix', `iterate_fix ${typeof args.file === 'string' && args.file ? args.file : '(?)'}`, async () => {
|
|
338
340
|
const resolved = resolveProjectRootForExec(exec, args.path)
|
|
339
341
|
if (!resolved.ok) return { ok: false, error: resolved.reason }
|
|
340
342
|
const projectRoot = resolved.root
|
|
@@ -504,6 +506,8 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
|
|
|
504
506
|
diffSummary: record.diffSummary,
|
|
505
507
|
backupPath,
|
|
506
508
|
}
|
|
509
|
+
})
|
|
510
|
+
return result
|
|
507
511
|
},
|
|
508
512
|
}),
|
|
509
513
|
)
|
package/src/tools/review.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
2
|
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
|
3
3
|
import { loadEffectiveConfig, resolveProjectRootForExec } from '../config-loader.ts'
|
|
4
|
+
import { runWithJob } from '../jobs.ts'
|
|
4
5
|
import {
|
|
5
6
|
buildReviewPlan,
|
|
6
7
|
buildReviewReport,
|
|
@@ -15,7 +16,7 @@ import {
|
|
|
15
16
|
coverageToDict,
|
|
16
17
|
} from '../review-scope.ts'
|
|
17
18
|
import { resolveChangedFiles } from '../git-scope.ts'
|
|
18
|
-
import type { KnownIntentional, ReviewFinding, ReviewReport, ReviewRound } from '../types.ts'
|
|
19
|
+
import type { KnownIntentional, ReviewAttachment, ReviewFinding, ReviewReport, ReviewRound } from '../types.ts'
|
|
19
20
|
import type { CoverageResult } from '../review-scope.ts'
|
|
20
21
|
|
|
21
22
|
/** Default round cap when neither the arg nor config provides one. */
|
|
@@ -85,6 +86,16 @@ export function registerReviewTool(ctx: { tools: { register: (def: ReturnType<ty
|
|
|
85
86
|
'For `meta-review`: the ReviewReport JSON (as returned by `aggregate`) to audit for ' +
|
|
86
87
|
'internal consistency and produce the final review report.',
|
|
87
88
|
},
|
|
89
|
+
attachments: {
|
|
90
|
+
type: 'json',
|
|
91
|
+
description:
|
|
92
|
+
'Optional (plan only): image/visual attachments to thread into the review, e.g. ' +
|
|
93
|
+
'[{"path":"screens/hits.png","caption":"reproduced layout bug"}]. Each entry: ' +
|
|
94
|
+
'{path?, data?, media_type?, caption?} — path resolves relative to the project root, ' +
|
|
95
|
+
'data is a base64 payload (media_type e.g. image/png), caption gives human context. ' +
|
|
96
|
+
'Injected as a mandatory clause into every dimension reviewer prompt so screenshots/' +
|
|
97
|
+
'mockups/failure repros are weighed alongside the code.',
|
|
98
|
+
},
|
|
88
99
|
fixedCount: {
|
|
89
100
|
type: 'integer',
|
|
90
101
|
description:
|
|
@@ -132,6 +143,7 @@ export function registerReviewTool(ctx: { tools: { register: (def: ReturnType<ty
|
|
|
132
143
|
},
|
|
133
144
|
|
|
134
145
|
async execute(args, exec) {
|
|
146
|
+
const { result } = await runWithJob(ctx, 'iterate-review', `iterate_review ${String(args.operation ?? '')} (${String(args.mode ?? 'dry-run')})`, async () => {
|
|
135
147
|
const resolved = resolveProjectRootForExec(exec, args.path)
|
|
136
148
|
if (!resolved.ok) {
|
|
137
149
|
return { operation: args.operation, error: resolved.reason }
|
|
@@ -162,7 +174,18 @@ export function registerReviewTool(ctx: { tools: { register: (def: ReturnType<ty
|
|
|
162
174
|
if (config.review?.scope === 'full') {
|
|
163
175
|
scopeFiles = collectScopeFiles(projectRoot, { scope: 'full' })
|
|
164
176
|
}
|
|
165
|
-
|
|
177
|
+
// Thread image/visual attachments (screenshots/mockups/failure repros)
|
|
178
|
+
// into the plan so every reviewer prompt weighs them alongside code.
|
|
179
|
+
const attachments = Array.isArray(args.attachments)
|
|
180
|
+
? (args.attachments as ReviewAttachment[]).filter(
|
|
181
|
+
(a): a is ReviewAttachment =>
|
|
182
|
+
Boolean(a) &&
|
|
183
|
+
typeof a === 'object' &&
|
|
184
|
+
((typeof a.path === 'string' && a.path.length > 0) ||
|
|
185
|
+
(typeof a.data === 'string' && a.data.length > 0)),
|
|
186
|
+
)
|
|
187
|
+
: []
|
|
188
|
+
const plan = buildReviewPlan({ config, mode, maxReviewRounds, knownIntentional, changedFiles, scopeFiles, attachments })
|
|
166
189
|
return { operation: 'plan', mode, found: true, plan: plan as unknown as JsonValue }
|
|
167
190
|
}
|
|
168
191
|
|
|
@@ -268,6 +291,8 @@ export function registerReviewTool(ctx: { tools: { register: (def: ReturnType<ty
|
|
|
268
291
|
operation: args.operation,
|
|
269
292
|
error: `Unknown operation "${args.operation}". Use "plan", "aggregate", or "meta-review".`,
|
|
270
293
|
}
|
|
294
|
+
})
|
|
295
|
+
return result
|
|
271
296
|
},
|
|
272
297
|
}),
|
|
273
298
|
)
|
package/src/types.ts
CHANGED
|
@@ -16,6 +16,13 @@ export interface IterateConfig {
|
|
|
16
16
|
command_whitelist: string[]
|
|
17
17
|
commands: Record<string, string[]>
|
|
18
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* LLM reasoning effort for review passes ('low' | 'medium' | 'high').
|
|
21
|
+
* Absent → follow the provider default. The harness forwards it into the
|
|
22
|
+
* OpenAI-compatible request body; the plugin surfaces it in the settings
|
|
23
|
+
* panel and review plan (dsh 0.1.1-rc.7+ exposes the same 'low' effort).
|
|
24
|
+
*/
|
|
25
|
+
reasoning_effort?: 'low' | 'medium' | 'high'
|
|
19
26
|
reviewer: {
|
|
20
27
|
output_schema_validation: boolean
|
|
21
28
|
evidence_validation: boolean
|
|
@@ -70,6 +77,25 @@ export interface ValidationResult {
|
|
|
70
77
|
durationMs: number
|
|
71
78
|
}
|
|
72
79
|
|
|
80
|
+
/**
|
|
81
|
+
* An image (or other visual) attachment threaded into a review.
|
|
82
|
+
*
|
|
83
|
+
* The user/context can attach screenshots, UI mockups, or reproduced-failure
|
|
84
|
+
* images that reviewers should weigh alongside the code. Only ``path`` or
|
|
85
|
+
* ``data`` need be present; ``media_type`` describes ``data`` (base64);
|
|
86
|
+
* ``caption`` supplies human context for the reviewer prompt.
|
|
87
|
+
*/
|
|
88
|
+
export interface ReviewAttachment {
|
|
89
|
+
/** Local path to the image (resolved relative to the project root). */
|
|
90
|
+
path?: string
|
|
91
|
+
/** Base64-encoded image content (alternative to ``path``). */
|
|
92
|
+
data?: string
|
|
93
|
+
/** MIME type of ``data`` (e.g. image/png, image/jpeg, image/webp). */
|
|
94
|
+
media_type?: string
|
|
95
|
+
/** Short human caption explaining what the attachment shows and why it matters. */
|
|
96
|
+
caption?: string
|
|
97
|
+
}
|
|
98
|
+
|
|
73
99
|
/** A single finding from a dimension review */
|
|
74
100
|
export interface ReviewFinding {
|
|
75
101
|
dimension: string
|