iterate-plugin 2.12.1 → 2.12.3
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/README.md +2 -1
- package/README.zh-CN.md +2 -1
- package/dist/git-scope.js +61 -7
- 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/lib/client.js +356 -100
- package/lib/parse.js +93 -0
- package/package.json +6 -4
- package/src/client/index.ts +265 -44
- package/src/git-scope.ts +48 -7
- package/src/jobs.ts +97 -0
- package/src/review.ts +64 -0
- package/src/tools/checkpoint.ts +1 -1
- package/src/tools/config.ts +1 -1
- package/src/tools/decision-log.ts +1 -1
- package/src/tools/fix.ts +5 -1
- package/src/tools/history.ts +1 -1
- package/src/tools/prune.ts +1 -1
- package/src/tools/review.ts +28 -3
- package/src/tools/transcript.ts +1 -1
- package/src/tools/triage.ts +1 -1
- package/src/types.ts +26 -0
package/README.md
CHANGED
|
@@ -95,7 +95,8 @@ Besides 13 pure-function tools, it ships a **build-free Web UI layer** (triage p
|
|
|
95
95
|
|
|
96
96
|
| UI component | Mounted slot | Function |
|
|
97
97
|
| --- | --- | --- |
|
|
98
|
-
| ConvergenceDashboard | `conversation.input.dock` | Live round progress bar, severity stats, dimension badges, trend mini-chart above the input; normal mode also shows fix-count badges |
|
|
98
|
+
| ConvergenceDashboard | `conversation.input.dock` | Live round progress bar, severity stats, dimension badges, trend mini-chart above the input; normal mode also shows fix-count badges; plus a live workflow-phase chip (current phase + running/stopped) |
|
|
99
|
+
| ObservatoryPanel | `conversation.input.dock` | Seven-tab runtime observatory below the input: live activity stream (type filter), review threads (expand/collapse all), convergence trend, finding locations (severity/dimension/search filter), fixes + rollback, checkpoint resume, decision timeline (type/round filter + search); one-click export of all observatory data to JSON (download, copy fallback) |
|
|
99
100
|
| TriagePanel | `conversation.chat.turnTail` | Per-finding y/n/a triage, filtering, batch (incl. select-all), keyboard shortcuts, localStorage persistence, copy-YAML / apply-instruction |
|
|
100
101
|
| StatsCard | `conversation.chat.turnTail` | When no findings remain: convergence stats, round history table, trend chart, completion summary |
|
|
101
102
|
| iterate theme skin | `theme.overrideTokens` | Warm-amber 13-dsw-token override, light/dark modes, togglable in settings |
|
package/README.zh-CN.md
CHANGED
|
@@ -95,7 +95,8 @@ dsh plugin --profile web add iterate-plugin
|
|
|
95
95
|
|
|
96
96
|
| UI 组件 | 挂载槽位 | 功能 |
|
|
97
97
|
|---------|---------|------|
|
|
98
|
-
| 收敛看板 `ConvergenceDashboard` | `conversation.input.dock` | 输入框上方实时显示轮次进度条、严重度统计、维度徽章、趋势迷你图,normal
|
|
98
|
+
| 收敛看板 `ConvergenceDashboard` | `conversation.input.dock` | 输入框上方实时显示轮次进度条、严重度统计、维度徽章、趋势迷你图,normal 模式另显示修复计数徽章;并显示运行阶段芯片(当前工作流阶段 + 运行中/已结束) |
|
|
99
|
+
| 运行时观测台 `ObservatoryPanel` | `conversation.input.dock` | 输入框下方七个标签页:实时活动流(支持按活动类型筛选)、审查线程(支持全部展开/全部收起)、收敛趋势、发现定位(支持按严重度/维度/关键词筛选)、修复与回滚、断点恢复、决策时间线(支持按类型/轮次筛选与关键词搜索);支持一键导出全部观测数据为 JSON(优先下载,失败回退复制) |
|
|
99
100
|
| Findings 分诊面板 `TriagePanel` | `conversation.chat.turnTail` | 逐条 y/n/a 判定,支持筛选、批量(含一键全选所有 findings)、键盘快捷键、localStorage 持久化、复制 YAML/应用指令 |
|
|
100
101
|
| 收敛统计卡片 `StatsCard` | `conversation.chat.turnTail` | 无 findings 时显示收敛统计、历史轮次表、趋势图、完成摘要 |
|
|
101
102
|
| iterate 主题皮肤 | `theme.overrideTokens` | 暖琥珀配色的 13 个 dsw token 覆盖,明暗双模式,可在设置页开关 |
|
package/dist/git-scope.js
CHANGED
|
@@ -29,6 +29,64 @@ import { join } from 'node:path';
|
|
|
29
29
|
* NUL is present (callers that did not pass -z) fall back to newline-split
|
|
30
30
|
* with C-style quote/escape unescaping for core.quotePath output.
|
|
31
31
|
*/
|
|
32
|
+
/**
|
|
33
|
+
* Decode the quoted body of a git core.quotePath output line into the real
|
|
34
|
+
* filename bytes, then interpret them as UTF-8.
|
|
35
|
+
*
|
|
36
|
+
* Single-pass and escape-atomic: each `\` consumes exactly one escape (\" \\
|
|
37
|
+
* \t \n or a 3-digit octal for a raw byte), so a literal `\\303` in a filename
|
|
38
|
+
* (escaped backslash + literal "303") is decoded as the byte `\` followed by
|
|
39
|
+
* ASCII "303" rather than as the single byte 0xC3. Ordinary characters in the
|
|
40
|
+
* quoted body are ASCII (git always octal-escapes non-ASCII bytes), so they map
|
|
41
|
+
* 1:1 to bytes.
|
|
42
|
+
*/
|
|
43
|
+
function decodeQuotedPath(content) {
|
|
44
|
+
const bytes = [];
|
|
45
|
+
let i = 0;
|
|
46
|
+
while (i < content.length) {
|
|
47
|
+
const ch = content[i];
|
|
48
|
+
if (ch !== '\\') {
|
|
49
|
+
bytes.push(ch.charCodeAt(0));
|
|
50
|
+
i++;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const next = content[i + 1];
|
|
54
|
+
if (next === '"') {
|
|
55
|
+
bytes.push(0x22);
|
|
56
|
+
i += 2;
|
|
57
|
+
}
|
|
58
|
+
else if (next === '\\') {
|
|
59
|
+
bytes.push(0x5c);
|
|
60
|
+
i += 2;
|
|
61
|
+
}
|
|
62
|
+
else if (next === 't') {
|
|
63
|
+
bytes.push(0x09);
|
|
64
|
+
i += 2;
|
|
65
|
+
}
|
|
66
|
+
else if (next === 'n') {
|
|
67
|
+
bytes.push(0x0a);
|
|
68
|
+
i += 2;
|
|
69
|
+
}
|
|
70
|
+
else if (next !== undefined && next >= '0' && next <= '7') {
|
|
71
|
+
const oct = content.slice(i + 1, i + 4);
|
|
72
|
+
if (oct.length === 3 && /^[0-7]{3}$/.test(oct)) {
|
|
73
|
+
bytes.push(parseInt(oct, 8));
|
|
74
|
+
i += 4;
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
// Malformed octal — keep the backslash literally.
|
|
78
|
+
bytes.push(0x5c);
|
|
79
|
+
i++;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
// Unknown escape — keep the backslash literally.
|
|
84
|
+
bytes.push(0x5c);
|
|
85
|
+
i++;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return Buffer.from(bytes).toString('utf-8');
|
|
89
|
+
}
|
|
32
90
|
export function parseChangedFiles(stdout) {
|
|
33
91
|
if (stdout.includes('\0')) {
|
|
34
92
|
return stdout.split('\0').map((s) => s.trim()).filter((s) => s.length > 0);
|
|
@@ -38,16 +96,12 @@ export function parseChangedFiles(stdout) {
|
|
|
38
96
|
.map((line) => {
|
|
39
97
|
const trimmed = line.trim();
|
|
40
98
|
// git core.quotePath wraps paths with special characters in "..."; the
|
|
41
|
-
// content uses C-style escapes (\" \\ \t \n and \ooo octal for
|
|
99
|
+
// content uses C-style escapes (\" \\ \t \n) and \ooo octal escapes for
|
|
100
|
+
// non-ASCII bytes (which are raw UTF-8 BYTES, not Latin-1 code points).
|
|
42
101
|
const quoted = trimmed.match(/^"(.*)"$/);
|
|
43
102
|
if (!quoted)
|
|
44
103
|
return trimmed;
|
|
45
|
-
return quoted[1]
|
|
46
|
-
.replace(/\\"/g, '"')
|
|
47
|
-
.replace(/\\\\/g, '\\')
|
|
48
|
-
.replace(/\\t/g, '\t')
|
|
49
|
-
.replace(/\\n/g, '\n')
|
|
50
|
-
.replace(/\\([0-7]{3})/g, (_m, oct) => String.fromCharCode(parseInt(oct, 8)));
|
|
104
|
+
return decodeQuotedPath(quoted[1]);
|
|
51
105
|
})
|
|
52
106
|
.filter((line) => line.length > 0);
|
|
53
107
|
}
|
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
|
}
|