iterate-plugin 2.3.6 → 2.3.7

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 CHANGED
@@ -1,5 +1,7 @@
1
1
  # iterate-plugin for DeepSeek Harness (dsh)
2
2
 
3
+ > **开发与评审在 [iterate-skill 主仓库](https://github.com/jingzhao-l/iterate-skill) 完成**:插件代码由主仓库统一维护,通过 `git subtree` 同步到本仓库;**版本发版与 npm 发布在本仓库(插件仓库)进行**,作为 dsh 生态的正式发布位。欢迎 **star / fork 主仓库** 并在 [主仓库 Issues](https://github.com/jingzhao-l/iterate-skill/issues) 反馈问题。
4
+
3
5
  `iterate-plugin` 是 [iterate](https://github.com/iterate-skill/iterate-skill) 技能的 [DeepSeek Harness (dsh)](https://github.com/deepseek-ai/deepseek-harness) 插件,提供**自治闭环代码迭代**和**dry-run 纯多轮审查**能力。
4
6
 
5
7
  ## 特性
@@ -134,8 +136,8 @@ npm test
134
136
  ```
135
137
 
136
138
  所有测试通过:
137
- - 31 个单元测试全绿
138
- - 覆盖去重、过滤、排序、多轮收敛、meta-review 审计
139
+ - 63 个单元测试全绿
140
+ - 覆盖去重、过滤、排序、多轮收敛、meta-review 审计、路径安全、超时钳制
139
141
  - 类型检查通过
140
142
 
141
143
  ## License
package/package.json CHANGED
@@ -1,9 +1,18 @@
1
1
  {
2
2
  "name": "iterate-plugin",
3
- "version": "2.3.6",
3
+ "version": "2.3.7",
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",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/jingzhao-l/iterate-skill.git",
10
+ "directory": "harness/iterate-plugin"
11
+ },
12
+ "homepage": "https://github.com/jingzhao-l/iterate-skill#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/jingzhao-l/iterate-skill/issues"
15
+ },
7
16
  "keywords": [
8
17
  "dsh-plugin",
9
18
  "deepseek-harness",
@@ -34,10 +43,6 @@
34
43
  "scripts": {
35
44
  "typecheck": "tsc --noEmit",
36
45
  "test": "tsx --test test/*.test.ts",
37
- "test:script": "tsx --test test/script.test.ts",
38
- "test:runtime": "tsx --test test/runtime.test.ts",
39
- "test:loader": "tsx --test test/loader.test.ts",
40
- "test:decisions": "tsx --test test/decisions.test.ts",
41
46
  "test:validate": "tsx --test test/validate.test.ts"
42
47
  },
43
48
  "dependencies": {
@@ -1,5 +1,5 @@
1
1
  import { readFileSync } from 'node:fs'
2
- import { join } from 'node:path'
2
+ import { join, resolve, sep } from 'node:path'
3
3
  import yaml from 'js-yaml'
4
4
  import type { IterateConfig } from './types.ts'
5
5
 
@@ -159,4 +159,30 @@ export function validateConfig(config: unknown): string[] {
159
159
  if (!v.commands || typeof v.commands !== 'object') errors.push('validation.commands')
160
160
  }
161
161
  return errors
162
+ }
163
+
164
+ /** Result of resolving/validating a caller-supplied project root. */
165
+ export type ProjectRootResult = { ok: true; root: string } | { ok: false; reason: string }
166
+
167
+ /**
168
+ * Resolve a caller-supplied project root to a safe absolute path.
169
+ *
170
+ * Every tool accepts a model-controlled `path` argument. Before it is used in
171
+ * any file read/write or as a command `cwd`, it must be sanitized:
172
+ * - an empty/missing `path` falls back to the current working directory;
173
+ * - the path is resolved to an absolute path (collapsing `..` and symlinks);
174
+ * - the filesystem root (`/`) is refused — it would let a prompt point tools
175
+ * at arbitrary system directories (path-traversal escape).
176
+ *
177
+ * Returns `{ ok: true, root }` on success, or `{ ok: false, reason }` when the
178
+ * path is unsafe; callers must short-circuit on the failure and return a
179
+ * structured error instead of proceeding.
180
+ */
181
+ export function resolveProjectRoot(input?: string): ProjectRootResult {
182
+ const raw = (input ?? '').trim()
183
+ const root = raw ? resolve(raw) : resolve(process.cwd())
184
+ if (!root || root === sep) {
185
+ return { ok: false, reason: 'Refusing filesystem root as project root.' }
186
+ }
187
+ return { ok: true, root }
162
188
  }
package/src/review.ts CHANGED
@@ -36,7 +36,12 @@ export const SEVERITY_RANK: Record<ReviewFinding['severity'], number> = {
36
36
  /** Sort findings by severity (most severe first), then by file path. */
37
37
  export function sortFindings(findings: ReviewFinding[]): ReviewFinding[] {
38
38
  return [...findings].sort((a, b) => {
39
- const bySeverity = SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]
39
+ // Guard against an out-of-spec severity string (e.g. from a model that
40
+ // bypassed the schema): treat it as the least severe so NaN never enters
41
+ // the comparator and ordering stays deterministic.
42
+ const rankA = SEVERITY_RANK[a.severity] ?? SEVERITY_RANK.low
43
+ const rankB = SEVERITY_RANK[b.severity] ?? SEVERITY_RANK.low
44
+ const bySeverity = rankA - rankB
40
45
  if (bySeverity !== 0) return bySeverity
41
46
  const byFile = a.file.localeCompare(b.file)
42
47
  if (byFile !== 0) return byFile
@@ -288,6 +293,8 @@ export function reviewerTaskPrompt(input: {
288
293
  mode: 'normal' | 'dry-run'
289
294
  alreadyKnown?: ReviewFinding[]
290
295
  outputLanguage: string
296
+ /** Atomic fix threshold from config.atomic. */
297
+ maxLines: number
291
298
  }): string {
292
299
  const parts: string[] = []
293
300
  parts.push(
@@ -313,7 +320,7 @@ export function reviewerTaskPrompt(input: {
313
320
  `Each finding: dimension (must be "${input.dimension}"), file (relative path), ` +
314
321
  'line (optional integer), severity (critical/high/medium/low), summary (one line), ' +
315
322
  'failure_scenario (how/when it fails, specific evidence), suggested_fix (the concrete fix), ' +
316
- 'is_atomic (true if the fix is <= {atomic.max_lines} lines within a SINGLE file/function, else false).',
323
+ `is_atomic (true if the fix is <= ${input.maxLines} lines within a SINGLE file/function, else false).`,
317
324
  `Write summaries and details in ${input.outputLanguage}.`,
318
325
  )
319
326
  return parts.join('\n')
@@ -351,6 +358,7 @@ export function buildReviewPlan(input: {
351
358
  mode: input.mode,
352
359
  alreadyKnown: [],
353
360
  outputLanguage: language,
361
+ maxLines: input.config.atomic.max_lines,
354
362
  }),
355
363
  findingsSchema: findingsSchema(),
356
364
  })),
@@ -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 known = [] // cumulative deduped findings across rounds
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
- known.push(...thisRound.findings) // rough accumulation; final dedupe is deterministic in aggregate
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
- if (agg && agg.report && agg.report.convergence.findingsByRound[r-1] === 0) {
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
- await parallel(atomic.map(f => () => agent(
166
- 'Fix this finding with the smallest possible change (single file, single function, <=20 lines). ' +
167
- JSON.stringify(f) + '. Verify the edit locally before finishing.',
168
- { label: 'fix:' + f.file + ':' + (f.line || 0), phase: 'fix' }
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
- architectural.push(...remaining)
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, ≤20 lines change). Architectural = everything else.
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
+ `
@@ -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 projectRoot = args.path ?? process.cwd()
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.
@@ -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 projectRoot = args.path ?? process.cwd()
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'
@@ -116,7 +117,11 @@ export function registerDecisionLogTool(ctx: { tools: { register: (def: ReturnTy
116
117
  },
117
118
 
118
119
  async execute(args) {
119
- const projectRoot = args.path ?? process.cwd()
120
+ const resolved = resolveProjectRoot(args.path)
121
+ if (!resolved.ok) {
122
+ return { operation: args.operation, error: resolved.reason }
123
+ }
124
+ const projectRoot = resolved.root
120
125
 
121
126
  if (args.operation === 'read') {
122
127
  const entries = readEntries(projectRoot)
@@ -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 projectRoot = args.path ?? process.cwd()
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)
@@ -1,9 +1,29 @@
1
1
  import { exec } from 'node:child_process'
2
2
  import { defineTool } from '@deepseek-ai/dsh-tools'
3
- import { loadEffectiveConfig, isCommandAllowed, flattenCommands } from '../config-loader.ts'
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 projectRoot = args.path ?? process.cwd()
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 ?? DEFAULT_TIMEOUT_MS
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