iterate-plugin 2.3.6 → 2.4.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/README.md +4 -2
- package/lib/client.js +635 -0
- package/lib/parse.js +459 -0
- package/package.json +20 -8
- package/src/config-loader.ts +27 -1
- package/src/index.ts +6 -4
- package/src/review.ts +22 -7
- package/src/skill-prompt.ts +33 -15
- package/src/tools/config.ts +6 -2
- package/src/tools/context.ts +7 -1
- package/src/tools/decision-log.ts +16 -1
- package/src/tools/review.ts +6 -2
- package/src/tools/triage.ts +370 -0
- package/src/tools/validate.ts +36 -3
package/src/skill-prompt.ts
CHANGED
|
@@ -47,7 +47,8 @@ const plan = (planRes && planRes.plan) ? planRes.plan : null
|
|
|
47
47
|
if (!plan || !Array.isArray(plan.dimensions)) throw new Error('plan failed: iterate_review did not return a valid plan')
|
|
48
48
|
const dims = plan.dimensions.map(d => d.id)
|
|
49
49
|
const maxRounds = plan.maxReviewRounds
|
|
50
|
-
const
|
|
50
|
+
const knownIntentional = (plan.knownIntentional || []) // config personalization filter, applied in aggregate
|
|
51
|
+
let known = [] // cumulative DEDUPED findings fed back to reviewers
|
|
51
52
|
const rounds = [] // raw per-round findings
|
|
52
53
|
|
|
53
54
|
phase('review')
|
|
@@ -60,13 +61,15 @@ for (let r = 1; r <= maxRounds; r++) {
|
|
|
60
61
|
)))
|
|
61
62
|
const thisRound = { round: r, findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])) }
|
|
62
63
|
rounds.push(thisRound)
|
|
63
|
-
|
|
64
|
-
// Check convergence deterministically
|
|
64
|
+
// Deterministic aggregate: cross-round dedupe + known_intentional filter + severity sort.
|
|
65
65
|
const agg = await agent(
|
|
66
|
-
'Call iterate_review({operation:"aggregate", mode:"dry-run", rounds:' + JSON.stringify(rounds) + ', maxReviewRounds:' + maxRounds + '}) and return the report JSON.',
|
|
66
|
+
'Call iterate_review({operation:"aggregate", mode:"dry-run", rounds:' + JSON.stringify(rounds) + ', maxReviewRounds:' + maxRounds + ', knownIntentional:' + JSON.stringify(knownIntentional) + '}) and return the report JSON.',
|
|
67
67
|
{ label: 'review:aggregate:r' + r }
|
|
68
68
|
)
|
|
69
|
-
|
|
69
|
+
// Feed the DEDUPED + already-filtered set back (not raw findings) so the known
|
|
70
|
+
// list stays bounded and reviewers never see the same issue twice.
|
|
71
|
+
if (agg && agg.report && Array.isArray(agg.report.findings)) known = agg.report.findings
|
|
72
|
+
if (agg && agg.report && agg.report.convergence && agg.report.convergence.findingsByRound[r-1] === 0) {
|
|
70
73
|
log('round ' + r + ' found 0 new findings — converged')
|
|
71
74
|
break
|
|
72
75
|
}
|
|
@@ -74,7 +77,7 @@ for (let r = 1; r <= maxRounds; r++) {
|
|
|
74
77
|
|
|
75
78
|
phase('report')
|
|
76
79
|
const finalAgg = await agent(
|
|
77
|
-
'Call iterate_review({operation:"aggregate", mode:"dry-run", rounds:' + JSON.stringify(rounds) + ', maxReviewRounds:' + maxRounds + '}) and return the report JSON.',
|
|
80
|
+
'Call iterate_review({operation:"aggregate", mode:"dry-run", rounds:' + JSON.stringify(rounds) + ', maxReviewRounds:' + maxRounds + ', knownIntentional:' + JSON.stringify(knownIntentional) + '}) and return the report JSON.',
|
|
78
81
|
{ label: 'review:aggregate:final' }
|
|
79
82
|
)
|
|
80
83
|
const report = (finalAgg && finalAgg.report) ? finalAgg.report : null
|
|
@@ -124,16 +127,19 @@ Canonical script — reproduce this structure exactly (adjust dims via the plan)
|
|
|
124
127
|
\`\`\`js
|
|
125
128
|
// args = { mode: "normal", maxRounds? }
|
|
126
129
|
phase('plan')
|
|
127
|
-
await agent(
|
|
130
|
+
const configRes = await agent(
|
|
128
131
|
'Call iterate_config({ validate: true }) and return the config JSON.',
|
|
129
132
|
{ label: 'config:read' }
|
|
130
133
|
)
|
|
134
|
+
const cfg = (configRes && configRes.config) ? configRes.config : null
|
|
135
|
+
const atomicMaxLines = (cfg && cfg.atomic && cfg.atomic.max_lines) ? cfg.atomic.max_lines : 20
|
|
131
136
|
const planRes = await agent(
|
|
132
137
|
'Call iterate_review({operation:"plan", mode:"normal", maxReviewRounds:' + (args.maxRounds || 3) + '}) and return the plan JSON.',
|
|
133
138
|
{ label: 'review:plan' }
|
|
134
139
|
)
|
|
135
140
|
const plan = (planRes && planRes.plan) ? planRes.plan : null
|
|
136
141
|
if (!plan || !Array.isArray(plan.dimensions)) throw new Error('plan failed: iterate_review did not return a valid plan')
|
|
142
|
+
const knownIntentional = (plan.knownIntentional || []) // config personalization filter, applied in aggregate
|
|
137
143
|
const dims = plan.dimensions.map(d => d.id)
|
|
138
144
|
const maxRounds = plan.maxReviewRounds
|
|
139
145
|
const rounds = [] // findings per review round (each on the then-current code state)
|
|
@@ -154,7 +160,7 @@ for (let r = 1; r <= maxRounds; r++) {
|
|
|
154
160
|
|
|
155
161
|
// Deterministic dedupe / known_intentional filter / severity sort for this round.
|
|
156
162
|
const agg = await agent(
|
|
157
|
-
'Call iterate_review({operation:"aggregate", mode:"normal", rounds:' + JSON.stringify([thisRound]) + '}) and return the report JSON.',
|
|
163
|
+
'Call iterate_review({operation:"aggregate", mode:"normal", rounds:' + JSON.stringify([thisRound]) + ', knownIntentional:' + JSON.stringify(knownIntentional) + '}) and return the report JSON.',
|
|
158
164
|
{ label: 'review:aggregate:r' + r }
|
|
159
165
|
)
|
|
160
166
|
const findings = (agg && agg.report && agg.report.findings) ? agg.report.findings : thisRound.findings
|
|
@@ -162,14 +168,25 @@ for (let r = 1; r <= maxRounds; r++) {
|
|
|
162
168
|
const remaining = findings.filter(f => f.is_atomic !== true)
|
|
163
169
|
|
|
164
170
|
if (atomic.length > 0) {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
171
|
+
// Group atomic fixes by file. All edits to the SAME file are applied by a
|
|
172
|
+
// single fixer agent (serial within a file), avoiding concurrent-write
|
|
173
|
+
// races; different files still run in parallel.
|
|
174
|
+
const byFile = {}
|
|
175
|
+
atomic.forEach(f => { (byFile[f.file] = byFile[f.file] || []).push(f) })
|
|
176
|
+
await parallel(Object.keys(byFile).map(file => () => agent(
|
|
177
|
+
'Apply ALL of these fixes to ' + file + ' in ONE pass with the smallest possible changes ' +
|
|
178
|
+
'(each <= ' + atomicMaxLines + ' lines, single function). ' + JSON.stringify(byFile[file]) + '. Verify the edit locally before finishing.',
|
|
179
|
+
{ label: 'fix:' + file, phase: 'fix' }
|
|
169
180
|
)))
|
|
170
181
|
fixedCount += atomic.length
|
|
171
182
|
}
|
|
172
|
-
|
|
183
|
+
|
|
184
|
+
// Cross-round dedupe of architectural findings before accumulating.
|
|
185
|
+
const seenKeys = architectural.map(a => a.file + '|' + a.dimension + '|' + a.summary)
|
|
186
|
+
for (const f of remaining) {
|
|
187
|
+
const key = f.file + '|' + f.dimension + '|' + f.summary
|
|
188
|
+
if (seenKeys.indexOf(key) < 0) { architectural.push(f); seenKeys.push(key) }
|
|
189
|
+
}
|
|
173
190
|
|
|
174
191
|
await agent(
|
|
175
192
|
'Call iterate_validate for each command in iterate.config.yaml validation.commands and return all {command, exitCode} results.',
|
|
@@ -209,6 +226,7 @@ return {
|
|
|
209
226
|
Key rules for normal mode:
|
|
210
227
|
- Fixers are the ONLY agents allowed to write files; reviewers read only. Architectural findings are reported, never auto-fixed.
|
|
211
228
|
- Aggregate the current round deterministically (\`report.findings\`) before fixing, so fixes act on deduped/filtered/sorted findings.
|
|
229
|
+
- Apply atomic fixes **per file**: one fixer agent handles all findings for a given file serially, so the same file is never edited concurrently; different files are fixed in parallel.
|
|
212
230
|
- Validate after every round of fixes; validation results are logged, not silently dropped.
|
|
213
231
|
- Stop when a round produces nothing to fix (converged) or maxReviewRounds is reached.
|
|
214
232
|
- Every round and the final report go to the append-only decision log.
|
|
@@ -218,10 +236,10 @@ Key rules for normal mode:
|
|
|
218
236
|
"severity": "critical" | "high" | "medium" | "low", "summary": string (one line),
|
|
219
237
|
"failure_scenario": string (how/when it fails), "suggested_fix": string (the concrete fix),
|
|
220
238
|
"is_atomic": boolean (true if fix ≤ max_lines within a single file/function) }
|
|
221
|
-
Atomic = is_atomic true (single file, single function, ≤
|
|
239
|
+
Atomic = is_atomic true (single file, single function, ≤ config.atomic.max_lines lines change). Architectural = everything else.
|
|
222
240
|
|
|
223
241
|
### Workflow meta
|
|
224
242
|
Always pass \`meta: { name: "iterate", description: "Autonomous iterate loop" }\`.
|
|
225
243
|
|
|
226
244
|
Always end with a clear summary: total findings, count by severity, fixes applied (normal) or convergence stats (dry-run), and remaining architectural findings.
|
|
227
|
-
`
|
|
245
|
+
`
|
package/src/tools/config.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
2
|
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
|
3
|
-
import { loadEffectiveConfig, validateConfig } from '../config-loader.ts'
|
|
3
|
+
import { loadEffectiveConfig, validateConfig, resolveProjectRoot } from '../config-loader.ts'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Register the `iterate_config` tool.
|
|
@@ -53,7 +53,11 @@ export function registerConfigTool(ctx: { tools: { register: (def: ReturnType<ty
|
|
|
53
53
|
},
|
|
54
54
|
|
|
55
55
|
async execute(args) {
|
|
56
|
-
const
|
|
56
|
+
const resolved = resolveProjectRoot(args.path)
|
|
57
|
+
if (!resolved.ok) {
|
|
58
|
+
return { found: false, error: resolved.reason }
|
|
59
|
+
}
|
|
60
|
+
const projectRoot = resolved.root
|
|
57
61
|
// Effective config = defaults (Master) merged with any project-root
|
|
58
62
|
// overrides. Never null: a project without a config file runs on the
|
|
59
63
|
// built-in defaults, so the workflow stays usable out of the box.
|
package/src/tools/context.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { readFileSync, existsSync } from 'node:fs'
|
|
|
2
2
|
import { join, dirname, resolve } from 'node:path'
|
|
3
3
|
import { fileURLToPath } from 'node:url'
|
|
4
4
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
5
|
+
import { resolveProjectRoot } from '../config-loader.ts'
|
|
5
6
|
|
|
6
7
|
/** How many ancestor directories we walk up looking for a SKILL.md. */
|
|
7
8
|
const MAX_SKILL_DIR_LOOKUP_DEPTH = 12
|
|
@@ -115,6 +116,7 @@ export function registerContextTool(ctx: { tools: { register: (def: ReturnType<t
|
|
|
115
116
|
skill: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
116
117
|
project: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
117
118
|
skillSource: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
119
|
+
error: { type: 'string' },
|
|
118
120
|
searched: { type: 'array', items: { type: 'string' } },
|
|
119
121
|
},
|
|
120
122
|
},
|
|
@@ -130,7 +132,11 @@ export function registerContextTool(ctx: { tools: { register: (def: ReturnType<t
|
|
|
130
132
|
},
|
|
131
133
|
|
|
132
134
|
async execute(args) {
|
|
133
|
-
const
|
|
135
|
+
const resolved = resolveProjectRoot(args.path)
|
|
136
|
+
if (!resolved.ok) {
|
|
137
|
+
return { found: false, error: resolved.reason, searched: [] }
|
|
138
|
+
}
|
|
139
|
+
const projectRoot = resolved.root
|
|
134
140
|
const requested = (args.files ?? '')
|
|
135
141
|
.split(',')
|
|
136
142
|
.map((s) => s.trim().toLowerCase())
|
|
@@ -2,6 +2,7 @@ import { appendFileSync, readFileSync, mkdirSync, existsSync } from 'node:fs'
|
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
4
4
|
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
|
5
|
+
import { resolveProjectRoot } from '../config-loader.ts'
|
|
5
6
|
import type { DecisionLogEntry } from '../types.ts'
|
|
6
7
|
|
|
7
8
|
const LOG_DIR = '.iterate'
|
|
@@ -81,6 +82,16 @@ export function registerDecisionLogTool(ctx: { tools: { register: (def: ReturnTy
|
|
|
81
82
|
description:
|
|
82
83
|
'Entry type (required for append): round_start, review_result, atomic_fix, ' +
|
|
83
84
|
'architectural_fix, revert, validation, decision, report.',
|
|
85
|
+
enum: [
|
|
86
|
+
'round_start',
|
|
87
|
+
'review_result',
|
|
88
|
+
'atomic_fix',
|
|
89
|
+
'architectural_fix',
|
|
90
|
+
'revert',
|
|
91
|
+
'validation',
|
|
92
|
+
'decision',
|
|
93
|
+
'report',
|
|
94
|
+
],
|
|
84
95
|
},
|
|
85
96
|
round: {
|
|
86
97
|
type: 'integer',
|
|
@@ -116,7 +127,11 @@ export function registerDecisionLogTool(ctx: { tools: { register: (def: ReturnTy
|
|
|
116
127
|
},
|
|
117
128
|
|
|
118
129
|
async execute(args) {
|
|
119
|
-
const
|
|
130
|
+
const resolved = resolveProjectRoot(args.path)
|
|
131
|
+
if (!resolved.ok) {
|
|
132
|
+
return { operation: args.operation, error: resolved.reason }
|
|
133
|
+
}
|
|
134
|
+
const projectRoot = resolved.root
|
|
120
135
|
|
|
121
136
|
if (args.operation === 'read') {
|
|
122
137
|
const entries = readEntries(projectRoot)
|
package/src/tools/review.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
2
|
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
|
3
|
-
import { loadEffectiveConfig } from '../config-loader.ts'
|
|
3
|
+
import { loadEffectiveConfig, resolveProjectRoot } from '../config-loader.ts'
|
|
4
4
|
import { buildReviewPlan, buildReviewReport } from '../review.ts'
|
|
5
5
|
import { buildFinalReviewReport, metaReviewReport } from '../meta-review.ts'
|
|
6
6
|
import type { KnownIntentional, ReviewFinding, ReviewReport, ReviewRound } from '../types.ts'
|
|
@@ -98,7 +98,11 @@ export function registerReviewTool(ctx: { tools: { register: (def: ReturnType<ty
|
|
|
98
98
|
},
|
|
99
99
|
|
|
100
100
|
async execute(args) {
|
|
101
|
-
const
|
|
101
|
+
const resolved = resolveProjectRoot(args.path)
|
|
102
|
+
if (!resolved.ok) {
|
|
103
|
+
return { operation: args.operation, error: resolved.reason }
|
|
104
|
+
}
|
|
105
|
+
const projectRoot = resolved.root
|
|
102
106
|
// Effective config = defaults merged with project overrides. Never
|
|
103
107
|
// null, so `plan`/`aggregate` work even without a config file.
|
|
104
108
|
const { config } = loadEffectiveConfig(projectRoot)
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
4
|
+
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
|
5
|
+
import yaml from 'js-yaml'
|
|
6
|
+
import { resolveProjectRoot } from '../config-loader.ts'
|
|
7
|
+
import type { KnownIntentional } from '../types.ts'
|
|
8
|
+
|
|
9
|
+
const CONFIG_FILE = 'iterate.config.yaml'
|
|
10
|
+
|
|
11
|
+
/** Personalization key that holds the known-intentional list. */
|
|
12
|
+
const PERSONALIZATION_KEY = 'personalization'
|
|
13
|
+
const KNOWN_INTENTIONAL_KEY = 'known_intentional'
|
|
14
|
+
|
|
15
|
+
/** Whole-file marker line (matches review.ts filterKnownIntentional semantics). */
|
|
16
|
+
const WHOLE_FILE_LINE = 0
|
|
17
|
+
|
|
18
|
+
// ─── Pure helpers (exported for unit tests) ─────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Normalize a caller-supplied `line` value.
|
|
22
|
+
* Returns a positive integer, or `undefined` when the value is absent,
|
|
23
|
+
* non-numeric, or non-positive (which is the "whole file" semantics).
|
|
24
|
+
*
|
|
25
|
+
* @param {unknown} line
|
|
26
|
+
* @returns {number | undefined}
|
|
27
|
+
*/
|
|
28
|
+
export function normalizeEntryLine(line: unknown): number | undefined {
|
|
29
|
+
if (typeof line !== 'number' || !Number.isInteger(line)) return undefined
|
|
30
|
+
if (line <= 0) return undefined
|
|
31
|
+
return line
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Validate an array of triage entries. Each entry must be an object with
|
|
36
|
+
* non-empty string `file` / `dimension` / `reason`, and an optional positive
|
|
37
|
+
* integer `line`.
|
|
38
|
+
*
|
|
39
|
+
* @param {unknown} entries
|
|
40
|
+
* @returns {string[]} Validation error messages (empty when valid).
|
|
41
|
+
*/
|
|
42
|
+
export function validateTriageEntries(entries: unknown): string[] {
|
|
43
|
+
const errors: string[] = []
|
|
44
|
+
if (!Array.isArray(entries)) {
|
|
45
|
+
errors.push('entries must be an array')
|
|
46
|
+
return errors
|
|
47
|
+
}
|
|
48
|
+
for (let i = 0; i < entries.length; i++) {
|
|
49
|
+
const prefix = `entries[${i}]`
|
|
50
|
+
const e = entries[i]
|
|
51
|
+
if (!e || typeof e !== 'object') {
|
|
52
|
+
errors.push(`${prefix} must be an object`)
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
const entry = e as Record<string, unknown>
|
|
56
|
+
if (typeof entry.file !== 'string' || entry.file.trim().length === 0) {
|
|
57
|
+
errors.push(`${prefix}.file must be a non-empty string`)
|
|
58
|
+
}
|
|
59
|
+
if (typeof entry.dimension !== 'string' || entry.dimension.trim().length === 0) {
|
|
60
|
+
errors.push(`${prefix}.dimension must be a non-empty string`)
|
|
61
|
+
}
|
|
62
|
+
if (typeof entry.reason !== 'string' || entry.reason.trim().length === 0) {
|
|
63
|
+
errors.push(`${prefix}.reason must be a non-empty string`)
|
|
64
|
+
}
|
|
65
|
+
if (entry.line !== undefined && normalizeEntryLine(entry.line) === undefined) {
|
|
66
|
+
errors.push(`${prefix}.line must be a positive integer when present`)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return errors
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Build the dedupe key for a known-intentional entry.
|
|
74
|
+
* Semantics mirror review.ts filterKnownIntentional: a whole-file entry
|
|
75
|
+
* (`line` 0/undefined) is distinct from a line-specific one.
|
|
76
|
+
*
|
|
77
|
+
* @param {KnownIntentional} entry
|
|
78
|
+
* @returns {string}
|
|
79
|
+
*/
|
|
80
|
+
export function entryKey(entry: KnownIntentional): string {
|
|
81
|
+
const line = normalizeEntryLine(entry.line) ?? WHOLE_FILE_LINE
|
|
82
|
+
return `${entry.file}|${entry.dimension}|${line}`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Merge incoming entries into the existing known-intentional list.
|
|
87
|
+
* Existing entries are never mutated; incoming entries whose key already
|
|
88
|
+
* exists are skipped. Returns the merged list plus add/skip counts.
|
|
89
|
+
*
|
|
90
|
+
* @param {KnownIntentional[]} existing
|
|
91
|
+
* @param {KnownIntentional[]} incoming
|
|
92
|
+
* @returns {{ merged: KnownIntentional[], added: number, skipped: number }}
|
|
93
|
+
*/
|
|
94
|
+
export function mergeKnownIntentional(
|
|
95
|
+
existing: KnownIntentional[],
|
|
96
|
+
incoming: KnownIntentional[],
|
|
97
|
+
): { merged: KnownIntentional[]; added: number; skipped: number } {
|
|
98
|
+
const seen = new Set<string>()
|
|
99
|
+
const merged: KnownIntentional[] = []
|
|
100
|
+
for (const entry of existing) {
|
|
101
|
+
const key = entryKey(entry)
|
|
102
|
+
if (!seen.has(key)) {
|
|
103
|
+
seen.add(key)
|
|
104
|
+
merged.push(entry)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
let added = 0
|
|
108
|
+
let skipped = 0
|
|
109
|
+
for (const entry of incoming) {
|
|
110
|
+
const key = entryKey(entry)
|
|
111
|
+
if (seen.has(key)) {
|
|
112
|
+
skipped++
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
seen.add(key)
|
|
116
|
+
merged.push(entry)
|
|
117
|
+
added++
|
|
118
|
+
}
|
|
119
|
+
return { merged, added, skipped }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Build a NEW config object with `personalization.known_intentional` set to
|
|
124
|
+
* the merged entries. All other top-level fields are preserved unchanged.
|
|
125
|
+
* Returns a deep-enough copy so the caller can serialize it safely.
|
|
126
|
+
*
|
|
127
|
+
* @param {Record<string, unknown>} config
|
|
128
|
+
* @param {KnownIntentional[]} entries
|
|
129
|
+
* @returns {Record<string, unknown>}
|
|
130
|
+
*/
|
|
131
|
+
export function buildConfigWithKnownIntentional(
|
|
132
|
+
config: Record<string, unknown>,
|
|
133
|
+
entries: KnownIntentional[],
|
|
134
|
+
): Record<string, unknown> {
|
|
135
|
+
const next: Record<string, unknown> = { ...config }
|
|
136
|
+
const personalization =
|
|
137
|
+
next[PERSONALIZATION_KEY] && typeof next[PERSONALIZATION_KEY] === 'object'
|
|
138
|
+
? { ...(next[PERSONALIZATION_KEY] as Record<string, unknown>) }
|
|
139
|
+
: {}
|
|
140
|
+
personalization[KNOWN_INTENTIONAL_KEY] = entries
|
|
141
|
+
next[PERSONALIZATION_KEY] = personalization
|
|
142
|
+
return next
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Read the raw known-intentional list from a config object (may be absent). */
|
|
146
|
+
export function readKnownIntentional(
|
|
147
|
+
config: Record<string, unknown>,
|
|
148
|
+
): KnownIntentional[] {
|
|
149
|
+
const personalization = config[PERSONALIZATION_KEY]
|
|
150
|
+
if (!personalization || typeof personalization !== 'object') return []
|
|
151
|
+
const known = (personalization as Record<string, unknown>)[KNOWN_INTENTIONAL_KEY]
|
|
152
|
+
if (!Array.isArray(known)) return []
|
|
153
|
+
return known.filter(
|
|
154
|
+
(e): e is KnownIntentional =>
|
|
155
|
+
!!e &&
|
|
156
|
+
typeof e === 'object' &&
|
|
157
|
+
typeof (e as Record<string, unknown>).file === 'string',
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Build a filesystem-safe backup suffix from the current time. */
|
|
162
|
+
export function backupSuffix(now = new Date()): string {
|
|
163
|
+
return now.toISOString().replace(/[:.]/g, '-')
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ─── File I/O ───────────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
/** Load the raw config object (empty when the file is missing). */
|
|
169
|
+
function readConfigFile(configPath: string): Record<string, unknown> {
|
|
170
|
+
if (!existsSync(configPath)) return {}
|
|
171
|
+
const content = readFileSync(configPath, 'utf-8')
|
|
172
|
+
const parsed = yaml.load(content)
|
|
173
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
174
|
+
// A config that exists but is not a YAML mapping must NOT be silently
|
|
175
|
+
// treated as empty: writing over it would destroy user data. Callers
|
|
176
|
+
// surface this as an error and refuse to write.
|
|
177
|
+
throw new Error('existing iterate.config.yaml is not a valid YAML mapping')
|
|
178
|
+
}
|
|
179
|
+
return parsed as Record<string, unknown>
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Apply the triage entries: backup, merge, write, rollback on failure. */
|
|
183
|
+
function applyEntries(
|
|
184
|
+
projectRoot: string,
|
|
185
|
+
incoming: KnownIntentional[],
|
|
186
|
+
): {
|
|
187
|
+
ok: true
|
|
188
|
+
added: number
|
|
189
|
+
skipped: number
|
|
190
|
+
count: number
|
|
191
|
+
configPath: string
|
|
192
|
+
backupPath: string | null
|
|
193
|
+
} | {
|
|
194
|
+
ok: false
|
|
195
|
+
error: string
|
|
196
|
+
} {
|
|
197
|
+
const configPath = join(projectRoot, CONFIG_FILE)
|
|
198
|
+
let config: Record<string, unknown>
|
|
199
|
+
try {
|
|
200
|
+
config = readConfigFile(configPath)
|
|
201
|
+
} catch (err) {
|
|
202
|
+
// The file exists but is malformed — refuse to overwrite user data.
|
|
203
|
+
return { ok: false, error: `Failed to read config: ${String(err)}` }
|
|
204
|
+
}
|
|
205
|
+
const existing = readKnownIntentional(config)
|
|
206
|
+
const { merged, added, skipped } = mergeKnownIntentional(existing, incoming)
|
|
207
|
+
const nextConfig = buildConfigWithKnownIntentional(config, merged)
|
|
208
|
+
|
|
209
|
+
const hadFile = existsSync(configPath)
|
|
210
|
+
const backupPath = hadFile ? `${configPath}.bak-${backupSuffix()}` : null
|
|
211
|
+
|
|
212
|
+
if (backupPath) {
|
|
213
|
+
try {
|
|
214
|
+
copyFileSync(configPath, backupPath)
|
|
215
|
+
} catch (err) {
|
|
216
|
+
return {
|
|
217
|
+
ok: false,
|
|
218
|
+
error: `Failed to create backup: ${String(err)}`,
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const yamlText = yaml.dump(nextConfig, { noRefs: true })
|
|
224
|
+
try {
|
|
225
|
+
writeFileSync(configPath, yamlText, 'utf-8')
|
|
226
|
+
} catch (err) {
|
|
227
|
+
// Rollback: restore the backup (or delete the file we just created).
|
|
228
|
+
try {
|
|
229
|
+
if (backupPath) copyFileSync(backupPath, configPath)
|
|
230
|
+
else if (existsSync(configPath)) writeFileSync(configPath, '', 'utf-8')
|
|
231
|
+
} catch {
|
|
232
|
+
// Rollback failure is reported, not swallowed silently.
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
ok: false,
|
|
236
|
+
error: `Failed to write config: ${String(err)}`,
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return { ok: true, added, skipped, count: merged.length, configPath, backupPath }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Register the `iterate_triage` tool.
|
|
245
|
+
*
|
|
246
|
+
* Completes the findings-triage closed loop: the client triage panel marks
|
|
247
|
+
* findings as "known intentional" (a), and this tool writes those entries
|
|
248
|
+
* into `iterate.config.yaml` under `personalization.known_intentional` so the
|
|
249
|
+
* next review round filters them out (review.ts filterKnownIntentional).
|
|
250
|
+
*
|
|
251
|
+
* Operations:
|
|
252
|
+
* - `apply`: merge validated entries into the config (dedupe by
|
|
253
|
+
* file|dimension|line), with an automatic timestamped backup and
|
|
254
|
+
* rollback if the write fails.
|
|
255
|
+
* - `list`: read back the current known_intentional entries.
|
|
256
|
+
*/
|
|
257
|
+
export function registerTriageTool(ctx: { tools: { register: (def: ReturnType<typeof defineTool>) => void } }): void {
|
|
258
|
+
ctx.tools.register(
|
|
259
|
+
defineTool({
|
|
260
|
+
name: 'iterate_triage',
|
|
261
|
+
description:
|
|
262
|
+
'Manage `personalization.known_intentional` entries in iterate.config.yaml. ' +
|
|
263
|
+
'Use `apply` to write back triage verdicts (entries where the reviewer said "known intentional") so ' +
|
|
264
|
+
'future review rounds filter them out. Entries are deduped by file|dimension|line and the config is ' +
|
|
265
|
+
'backed up before writing. Use `list` to read the current entries. ' +
|
|
266
|
+
'The client browser cannot write files, so this tool is the write-back channel for the triage panel.',
|
|
267
|
+
parameters: {
|
|
268
|
+
operation: {
|
|
269
|
+
type: 'string',
|
|
270
|
+
required: true,
|
|
271
|
+
description: '"apply" to merge entries into the config, "list" to read them back.',
|
|
272
|
+
enum: ['apply', 'list'],
|
|
273
|
+
},
|
|
274
|
+
entries: {
|
|
275
|
+
type: 'json',
|
|
276
|
+
description:
|
|
277
|
+
'For `apply`: array of known-intentional entries, e.g. ' +
|
|
278
|
+
'[{"file":"src/a.ts","line":42,"dimension":"security","reason":"..."}]. ' +
|
|
279
|
+
'Each entry needs non-empty string file/dimension/reason; line is an optional positive integer ' +
|
|
280
|
+
'(omitted = whole file).',
|
|
281
|
+
},
|
|
282
|
+
path: {
|
|
283
|
+
type: 'string',
|
|
284
|
+
description: 'Project root directory (default: current working directory).',
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
|
|
288
|
+
output: {
|
|
289
|
+
schema: {
|
|
290
|
+
type: 'object',
|
|
291
|
+
additionalProperties: false,
|
|
292
|
+
properties: {
|
|
293
|
+
operation: { type: 'string', required: true },
|
|
294
|
+
added: { type: 'integer' },
|
|
295
|
+
skipped: { type: 'integer' },
|
|
296
|
+
count: { type: 'integer' },
|
|
297
|
+
path: { type: 'string' },
|
|
298
|
+
backupPath: { type: 'string' },
|
|
299
|
+
entries: { type: 'json' },
|
|
300
|
+
errors: { type: 'array', items: { type: 'string' } },
|
|
301
|
+
error: { type: 'string' },
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
render: (_args, value) => [
|
|
305
|
+
{ type: 'text', text: JSON.stringify(value, null, 2) },
|
|
306
|
+
],
|
|
307
|
+
},
|
|
308
|
+
|
|
309
|
+
async execute(args) {
|
|
310
|
+
const resolved = resolveProjectRoot(args.path)
|
|
311
|
+
if (!resolved.ok) {
|
|
312
|
+
return { operation: args.operation, error: resolved.reason }
|
|
313
|
+
}
|
|
314
|
+
const projectRoot = resolved.root
|
|
315
|
+
const configPath = join(projectRoot, CONFIG_FILE)
|
|
316
|
+
|
|
317
|
+
if (args.operation === 'list') {
|
|
318
|
+
let config: Record<string, unknown>
|
|
319
|
+
try {
|
|
320
|
+
config = readConfigFile(configPath)
|
|
321
|
+
} catch (err) {
|
|
322
|
+
return { operation: 'list', error: `Failed to read config: ${String(err)}` }
|
|
323
|
+
}
|
|
324
|
+
const entries = readKnownIntentional(config)
|
|
325
|
+
return {
|
|
326
|
+
operation: 'list',
|
|
327
|
+
count: entries.length,
|
|
328
|
+
path: configPath,
|
|
329
|
+
entries: entries as unknown as JsonValue,
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (args.operation === 'apply') {
|
|
334
|
+
const validation = validateTriageEntries(args.entries)
|
|
335
|
+
if (validation.length > 0) {
|
|
336
|
+
return { operation: 'apply', errors: validation, error: 'Invalid entries.' }
|
|
337
|
+
}
|
|
338
|
+
const incoming = (args.entries as unknown[]).map((e) => {
|
|
339
|
+
const raw = e as Record<string, unknown>
|
|
340
|
+
return {
|
|
341
|
+
file: String(raw.file),
|
|
342
|
+
...(normalizeEntryLine(raw.line) !== undefined
|
|
343
|
+
? { line: normalizeEntryLine(raw.line) as number }
|
|
344
|
+
: {}),
|
|
345
|
+
dimension: String(raw.dimension),
|
|
346
|
+
reason: String(raw.reason),
|
|
347
|
+
} as KnownIntentional
|
|
348
|
+
})
|
|
349
|
+
const result = applyEntries(projectRoot, incoming)
|
|
350
|
+
if (!result.ok) {
|
|
351
|
+
return { operation: 'apply', error: result.error }
|
|
352
|
+
}
|
|
353
|
+
return {
|
|
354
|
+
operation: 'apply',
|
|
355
|
+
added: result.added,
|
|
356
|
+
skipped: result.skipped,
|
|
357
|
+
count: result.count,
|
|
358
|
+
path: result.configPath,
|
|
359
|
+
backupPath: result.backupPath ?? undefined,
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return {
|
|
364
|
+
operation: args.operation,
|
|
365
|
+
error: 'Unknown operation. Use "apply" or "list".',
|
|
366
|
+
}
|
|
367
|
+
},
|
|
368
|
+
}),
|
|
369
|
+
)
|
|
370
|
+
}
|
package/src/tools/validate.ts
CHANGED
|
@@ -1,9 +1,29 @@
|
|
|
1
1
|
import { exec } from 'node:child_process'
|
|
2
2
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
loadEffectiveConfig,
|
|
5
|
+
isCommandAllowed,
|
|
6
|
+
flattenCommands,
|
|
7
|
+
resolveProjectRoot,
|
|
8
|
+
} from '../config-loader.ts'
|
|
4
9
|
import type { ValidationResult } from '../types.ts'
|
|
5
10
|
|
|
6
11
|
const DEFAULT_TIMEOUT_MS = 120_000
|
|
12
|
+
/** Hard ceiling on a single validation command's runtime, so a model cannot
|
|
13
|
+
* pin the tool open indefinitely via an unbounded `timeout` argument. */
|
|
14
|
+
const MAX_TIMEOUT_MS = 600_000
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Clamp a caller-supplied timeout (ms) to a sane range.
|
|
18
|
+
* Non-finite / non-positive values fall back to the default; any value above
|
|
19
|
+
* the ceiling is capped. Pure function, unit-tested.
|
|
20
|
+
*/
|
|
21
|
+
export function clampTimeout(ms: number | undefined): number {
|
|
22
|
+
if (typeof ms !== 'number' || !Number.isFinite(ms) || ms <= 0) {
|
|
23
|
+
return DEFAULT_TIMEOUT_MS
|
|
24
|
+
}
|
|
25
|
+
return Math.min(ms, MAX_TIMEOUT_MS)
|
|
26
|
+
}
|
|
7
27
|
|
|
8
28
|
/**
|
|
9
29
|
* Run a single shell command with timeout and return structured results.
|
|
@@ -107,10 +127,23 @@ export function registerValidateTool(ctx: { tools: { register: (def: ReturnType<
|
|
|
107
127
|
},
|
|
108
128
|
|
|
109
129
|
async execute(args) {
|
|
110
|
-
const
|
|
130
|
+
const resolved = resolveProjectRoot(args.path)
|
|
131
|
+
if (!resolved.ok) {
|
|
132
|
+
return {
|
|
133
|
+
allowed: false,
|
|
134
|
+
command: args.command,
|
|
135
|
+
exitCode: -1,
|
|
136
|
+
stdout: '',
|
|
137
|
+
stderr: '',
|
|
138
|
+
timedOut: false,
|
|
139
|
+
durationMs: 0,
|
|
140
|
+
rejectReason: resolved.reason,
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const projectRoot = resolved.root
|
|
111
144
|
// Effective config = defaults merged with project overrides. Never null.
|
|
112
145
|
const { config, source } = loadEffectiveConfig(projectRoot)
|
|
113
|
-
const timeout = args.timeout
|
|
146
|
+
const timeout = clampTimeout(args.timeout)
|
|
114
147
|
|
|
115
148
|
// Only commands predefined in validation.commands may run — the
|
|
116
149
|
// user trusts exactly these, and nothing else. This replaces the
|