iterate-plugin 3.10.0 → 3.12.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 +14 -13
- package/README.zh-CN.md +12 -11
- package/dist/index.js +8 -5
- package/dist/report-render.js +33 -2
- package/dist/tools/preflight.js +239 -0
- package/dist/tools/report.js +20 -8
- package/lib/client.js +359 -9
- package/lib/parse.js +148 -2
- package/package.json +22 -20
- package/src/client/i18n.ts +43 -1
- package/src/client/index.ts +216 -4
- package/src/index.ts +8 -5
- package/src/report-render.ts +69 -3
- package/src/tools/preflight.ts +289 -0
- package/src/tools/report.ts +19 -8
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/tools/preflight.ts — `iterate_preflight` tool.
|
|
3
|
+
*
|
|
4
|
+
* Answers one question BEFORE a run starts: "is this project ready to iterate
|
|
5
|
+
* safely, and what must be fixed first?" It reads only local state (config,
|
|
6
|
+
* git, persisted artifacts, checkpoint) and returns a structured checklist the
|
|
7
|
+
* observatory renders as a preflight panel (F11).
|
|
8
|
+
*
|
|
9
|
+
* Why it exists: the most expensive failure mode is starting a run that cannot
|
|
10
|
+
* prove anything — an empty `validation.commands` means the loop has no way to
|
|
11
|
+
* tell a fix from a regression, and a stale FAIL certificate or a leftover
|
|
12
|
+
* interrupted checkpoint makes the first round lie about its starting state.
|
|
13
|
+
* Preflight surfaces those conditions before the model spends a round
|
|
14
|
+
* discovering them mid-flight.
|
|
15
|
+
*
|
|
16
|
+
* Read-only: never mutates config, files, or artifacts.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
20
|
+
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
21
|
+
import { resolveProjectRootForExec, loadEffectiveConfig, flattenCommands } from '../config-loader.ts'
|
|
22
|
+
import { runGit } from '../git-scope.ts'
|
|
23
|
+
import { readQualityGate } from './quality-store.ts'
|
|
24
|
+
import { readCheckpoint, computeStatus } from './checkpoint.ts'
|
|
25
|
+
import { readDecisionEntries } from './decision-log.ts'
|
|
26
|
+
import { readRegistry } from './fix.ts'
|
|
27
|
+
import { resultHeadline } from './present.ts'
|
|
28
|
+
|
|
29
|
+
/** A single check's outcome. `fail` blocks a clean run; `warn` is advisory. */
|
|
30
|
+
export type PreflightStatus = 'pass' | 'warn' | 'fail'
|
|
31
|
+
|
|
32
|
+
/** One readiness check. `detail` is structured facts (no prose) so the client
|
|
33
|
+
* can localize the message and offer the right fix action. */
|
|
34
|
+
export interface PreflightCheck {
|
|
35
|
+
/** Stable id the client maps to a localized label + fix action. */
|
|
36
|
+
id: string
|
|
37
|
+
status: PreflightStatus
|
|
38
|
+
detail: Record<string, unknown>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The full preflight report. */
|
|
42
|
+
export interface PreflightReport {
|
|
43
|
+
ok: boolean
|
|
44
|
+
/** True when no check is `fail` (warnings do not block). */
|
|
45
|
+
ready: boolean
|
|
46
|
+
/** Count of `fail` checks. */
|
|
47
|
+
blocking: number
|
|
48
|
+
/** Count of `warn` checks. */
|
|
49
|
+
warnings: number
|
|
50
|
+
checks: PreflightCheck[]
|
|
51
|
+
error?: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Facts gathered from disk/git before the pure decision runs. */
|
|
55
|
+
export interface PreflightInput {
|
|
56
|
+
/** Number of configured validation commands (flattened across modules). */
|
|
57
|
+
validationCommandCount: number
|
|
58
|
+
/** Whether the project root is inside a git work tree. */
|
|
59
|
+
isGitRepo: boolean
|
|
60
|
+
/** Uncommitted/untracked file count, or null when git could not answer. */
|
|
61
|
+
changedFiles: number | null
|
|
62
|
+
/** Persisted quality-gate overall status, or null when none is stored. */
|
|
63
|
+
gateOverallStatus: 'pass' | 'fail' | 'pending' | null
|
|
64
|
+
/** True when a leftover checkpoint is genuinely interrupted (nothing newer). */
|
|
65
|
+
interrupted: boolean
|
|
66
|
+
/** How many times the leftover checkpoint has already been resumed. */
|
|
67
|
+
resumeCount: number
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Build the readiness checklist from already-gathered facts (pure).
|
|
72
|
+
*
|
|
73
|
+
* The five checks and why each exists:
|
|
74
|
+
* 1. `validation` — empty command set ⇒ the loop cannot prove a
|
|
75
|
+
* fix; the product is fail-closed here, so this
|
|
76
|
+
* is a BLOCKING `fail`, not a warning.
|
|
77
|
+
* 2. `git` — changed-only scope + `iterate_diff` need a
|
|
78
|
+
* repository; full-scope review still works, so
|
|
79
|
+
* absent is a `warn`.
|
|
80
|
+
* 3. `dirty-tree` — a run over uncommitted work mixes the user's
|
|
81
|
+
* WIP with the plugin's fixes; advisory `warn`.
|
|
82
|
+
* Unknown (null) is treated as pass so a git
|
|
83
|
+
* hiccup never fabricates a warning.
|
|
84
|
+
* 4. `stale-gate` — a persisted FAIL certificate makes the first
|
|
85
|
+
* round read stale; `warn` + clear before a
|
|
86
|
+
* fresh run.
|
|
87
|
+
* 5. `interrupted-checkpoint`— a leftover checkpoint from a run that never
|
|
88
|
+
* progressed; `warn` + resume or clear.
|
|
89
|
+
*/
|
|
90
|
+
export function buildPreflightReport(input: PreflightInput): PreflightReport {
|
|
91
|
+
const checks: PreflightCheck[] = [
|
|
92
|
+
{
|
|
93
|
+
id: 'validation',
|
|
94
|
+
status: input.validationCommandCount > 0 ? 'pass' : 'fail',
|
|
95
|
+
detail: { commandCount: input.validationCommandCount },
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
id: 'git',
|
|
99
|
+
status: input.isGitRepo ? 'pass' : 'warn',
|
|
100
|
+
detail: { isRepo: input.isGitRepo },
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: 'dirty-tree',
|
|
104
|
+
status: input.changedFiles !== null && input.changedFiles > 0 ? 'warn' : 'pass',
|
|
105
|
+
detail: { changedFiles: input.changedFiles },
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
id: 'stale-gate',
|
|
109
|
+
status: input.gateOverallStatus === 'fail' ? 'warn' : 'pass',
|
|
110
|
+
detail: { overallStatus: input.gateOverallStatus },
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
id: 'interrupted-checkpoint',
|
|
114
|
+
status: input.interrupted ? 'warn' : 'pass',
|
|
115
|
+
detail: { interrupted: input.interrupted, resumeCount: input.resumeCount },
|
|
116
|
+
},
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
const blocking = checks.filter((c) => c.status === 'fail').length
|
|
120
|
+
const warnings = checks.filter((c) => c.status === 'warn').length
|
|
121
|
+
return { ok: true, ready: blocking === 0, blocking, warnings, checks }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Human-readable (English, for the model-facing render) description per check. */
|
|
125
|
+
function describeCheck(id: string, detail: Record<string, unknown>): string {
|
|
126
|
+
switch (id) {
|
|
127
|
+
case 'validation': {
|
|
128
|
+
const n = Number(detail.commandCount ?? 0)
|
|
129
|
+
return n > 0
|
|
130
|
+
? `${n} validation command(s) configured`
|
|
131
|
+
: 'no validation.commands configured — the run cannot prove fixes (blocking)'
|
|
132
|
+
}
|
|
133
|
+
case 'git':
|
|
134
|
+
return detail.isRepo === true
|
|
135
|
+
? 'git repository detected'
|
|
136
|
+
: 'not a git repository — changed-only scope and iterate_diff need one'
|
|
137
|
+
case 'dirty-tree': {
|
|
138
|
+
const n = detail.changedFiles
|
|
139
|
+
if (n === null || n === undefined) return 'uncommitted changes: unknown'
|
|
140
|
+
const num = Number(n)
|
|
141
|
+
return num > 0
|
|
142
|
+
? `${num} uncommitted/untracked file(s) — commit or stash before a run`
|
|
143
|
+
: 'working tree clean'
|
|
144
|
+
}
|
|
145
|
+
case 'stale-gate': {
|
|
146
|
+
const s = detail.overallStatus
|
|
147
|
+
if (s === 'fail') return 'a stale FAIL quality gate is persisted — clear it before a fresh run'
|
|
148
|
+
return s ? `quality gate: ${String(s)}` : 'no persisted quality gate'
|
|
149
|
+
}
|
|
150
|
+
case 'interrupted-checkpoint': {
|
|
151
|
+
if (detail.interrupted === true) {
|
|
152
|
+
const rc = Number(detail.resumeCount ?? 0)
|
|
153
|
+
return `an interrupted checkpoint is waiting (resumed ${rc}x) — resume or clear it`
|
|
154
|
+
}
|
|
155
|
+
return 'no interrupted checkpoint'
|
|
156
|
+
}
|
|
157
|
+
default:
|
|
158
|
+
return ''
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Register the `iterate_preflight` tool.
|
|
164
|
+
*/
|
|
165
|
+
export function registerPreflightTool(ctx: { tools: { register: (def: ReturnType<typeof defineTool>) => void } }): void {
|
|
166
|
+
ctx.tools.register(
|
|
167
|
+
defineTool({
|
|
168
|
+
name: 'iterate_preflight',
|
|
169
|
+
// Pure read of on-disk state + git → safe to join a parallel group.
|
|
170
|
+
isConcurrencySafe: () => true,
|
|
171
|
+
presentResult: (_args, result) => {
|
|
172
|
+
const title = resultHeadline(result)
|
|
173
|
+
return title ? { card: 'generic', title } : undefined
|
|
174
|
+
},
|
|
175
|
+
description:
|
|
176
|
+
'Read-only readiness check to run BEFORE starting an iterate run. Returns a checklist: whether ' +
|
|
177
|
+
'validation.commands is configured (an empty set means the loop cannot prove fixes — blocking), ' +
|
|
178
|
+
'whether the project is a git repository, how many files are uncommitted, whether a stale FAIL ' +
|
|
179
|
+
'quality gate is persisted, and whether an interrupted checkpoint is waiting. Use it before a fresh ' +
|
|
180
|
+
'run so you fix the environment first instead of discovering it mid-round.',
|
|
181
|
+
parameters: {
|
|
182
|
+
path: { type: 'string', description: 'Project root directory (default: current working directory).' },
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
output: {
|
|
186
|
+
schema: {
|
|
187
|
+
type: 'object',
|
|
188
|
+
additionalProperties: false,
|
|
189
|
+
properties: {
|
|
190
|
+
ok: { type: 'boolean', required: true },
|
|
191
|
+
ready: { type: 'boolean' },
|
|
192
|
+
blocking: { type: 'integer' },
|
|
193
|
+
warnings: { type: 'integer' },
|
|
194
|
+
checks: { type: 'json', description: 'Per-check outcomes: { id, status: pass|warn|fail, detail }.' },
|
|
195
|
+
error: { type: 'string' },
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
render: (_args, value) => {
|
|
199
|
+
if (!value.ok) return [{ type: 'text', text: `preflight failed: ${value.error}` }]
|
|
200
|
+
const checks = Array.isArray(value.checks) ? value.checks : []
|
|
201
|
+
const warnings = Number(value.warnings ?? 0)
|
|
202
|
+
const noun = warnings === 1 ? 'warning' : 'warnings'
|
|
203
|
+
const head = value.ready
|
|
204
|
+
? `Preflight: READY (${Number(value.blocking ?? 0)} blocking, ${warnings} ${noun})`
|
|
205
|
+
: `Preflight: NOT READY (${Number(value.blocking ?? 0)} blocking, ${warnings} ${noun})`
|
|
206
|
+
const lines = checks.map((c) => {
|
|
207
|
+
const check = c as { id?: unknown; status?: unknown; detail?: Record<string, unknown> }
|
|
208
|
+
const id = String(check.id ?? '?')
|
|
209
|
+
const status = String(check.status ?? '?').toUpperCase()
|
|
210
|
+
return ` ${status} ${id} — ${describeCheck(id, check.detail ?? {})}`
|
|
211
|
+
})
|
|
212
|
+
return [{ type: 'text', text: [head, ...lines].join('\n') }]
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
async execute(args, exec) {
|
|
217
|
+
const resolved = resolveProjectRootForExec(exec, args.path)
|
|
218
|
+
if (!resolved.ok) {
|
|
219
|
+
return { ok: false, ready: false, blocking: 0, warnings: 0, checks: [] as unknown as JsonValue, error: resolved.reason }
|
|
220
|
+
}
|
|
221
|
+
const projectRoot = resolved.root
|
|
222
|
+
|
|
223
|
+
// 1. validation.commands — the load-bearing check.
|
|
224
|
+
let validationCommandCount = 0
|
|
225
|
+
try {
|
|
226
|
+
const effective = loadEffectiveConfig(projectRoot)
|
|
227
|
+
validationCommandCount = flattenCommands(effective.config.validation?.commands).length
|
|
228
|
+
} catch {
|
|
229
|
+
validationCommandCount = 0
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// 2/3. git repo + dirty tree (best-effort; any failure degrades to unknown).
|
|
233
|
+
let isGitRepo = false
|
|
234
|
+
let changedFiles: number | null = null
|
|
235
|
+
try {
|
|
236
|
+
const inside = await runGit(['rev-parse', '--is-inside-work-tree'], projectRoot, { signal: exec.signal })
|
|
237
|
+
isGitRepo = inside.ok && inside.stdout.trim() === 'true'
|
|
238
|
+
if (isGitRepo) {
|
|
239
|
+
const status = await runGit(['status', '--porcelain', '-z'], projectRoot, { signal: exec.signal })
|
|
240
|
+
if (status.ok) {
|
|
241
|
+
// `-z` NUL-terminates each entry; a rename emits two NUL-separated
|
|
242
|
+
// paths for one entry, but counting non-empty tokens is still a
|
|
243
|
+
// faithful "there is uncommitted work" signal (>=1 when dirty).
|
|
244
|
+
changedFiles = status.stdout.split('\u0000').filter((s) => s.length > 0).length
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
} catch {
|
|
248
|
+
isGitRepo = false
|
|
249
|
+
changedFiles = null
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// 4. stale quality gate.
|
|
253
|
+
let gateOverallStatus: 'pass' | 'fail' | 'pending' | null = null
|
|
254
|
+
try {
|
|
255
|
+
gateOverallStatus = readQualityGate(projectRoot).overallStatus ?? null
|
|
256
|
+
} catch {
|
|
257
|
+
gateOverallStatus = null
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// 5. interrupted checkpoint (reuse the exact status logic so preflight
|
|
261
|
+
// and iterate_status can never disagree about "interrupted").
|
|
262
|
+
let interrupted = false
|
|
263
|
+
let resumeCount = 0
|
|
264
|
+
try {
|
|
265
|
+
const status = computeStatus({
|
|
266
|
+
checkpoint: readCheckpoint(projectRoot),
|
|
267
|
+
decisionEntries: readDecisionEntries(projectRoot),
|
|
268
|
+
fixRegistry: readRegistry(projectRoot),
|
|
269
|
+
})
|
|
270
|
+
interrupted = status.interrupted
|
|
271
|
+
resumeCount = status.resumeCount
|
|
272
|
+
} catch {
|
|
273
|
+
interrupted = false
|
|
274
|
+
resumeCount = 0
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const report = buildPreflightReport({
|
|
278
|
+
validationCommandCount,
|
|
279
|
+
isGitRepo,
|
|
280
|
+
changedFiles,
|
|
281
|
+
gateOverallStatus,
|
|
282
|
+
interrupted,
|
|
283
|
+
resumeCount,
|
|
284
|
+
})
|
|
285
|
+
return { ...report, checks: report.checks as unknown as JsonValue }
|
|
286
|
+
},
|
|
287
|
+
}),
|
|
288
|
+
)
|
|
289
|
+
}
|
package/src/tools/report.ts
CHANGED
|
@@ -31,7 +31,7 @@ import { readQualityGate } from './quality-store.ts'
|
|
|
31
31
|
import { readExperienceBank } from './experience-store.ts'
|
|
32
32
|
import { readDefenseEvents } from './defense-store.ts'
|
|
33
33
|
import { resultHeadline } from './present.ts'
|
|
34
|
-
import { renderRunReportMarkdown, renderRunReportSarif, REPORT_FORMATS } from '../report-render.ts'
|
|
34
|
+
import { renderRunReportMarkdown, renderRunReportSarif, renderRunReportJson, REPORT_FORMATS } from '../report-render.ts'
|
|
35
35
|
|
|
36
36
|
/** Formats accepted by the tool (kept in sync with the shared renderer). */
|
|
37
37
|
const FORMATS: ReadonlySet<string> = new Set(REPORT_FORMATS)
|
|
@@ -73,19 +73,21 @@ export function registerReportTool(ctx: { tools: { register: (def: ReturnType<ty
|
|
|
73
73
|
'quality-gate table, findings table, fixes table, per-round validations, defense events, and the ' +
|
|
74
74
|
'experience bank. `format:"sarif"` renders a SARIF 2.1.0 log for CI / GitHub Code Scanning ' +
|
|
75
75
|
'(one rule per review dimension; finding severity maps to error/warning/note; findings carry ' +
|
|
76
|
-
'file + line locations and the suggested fix). ' +
|
|
76
|
+
'file + line locations and the suggested fix). `format:"json"` renders the COMPLETE normalized run ' +
|
|
77
|
+
'model as machine-readable JSON (findings, fixes, validations, convergence, quality gate, ' +
|
|
78
|
+
'experience bank, defense events) — the lossless format for CI dashboards and PR bots. ' +
|
|
77
79
|
'Set `write:true` to also persist the report under `.iterate/report.md` / `.iterate/report.sarif` ' +
|
|
78
|
-
'and get the path back. `lang` selects the report language (zh | en). ' +
|
|
80
|
+
'/ `.iterate/report.json` and get the path back. `lang` selects the report language (zh | en). ' +
|
|
79
81
|
'Read-only apart from the optional report file; never touches source files.',
|
|
80
82
|
parameters: {
|
|
81
83
|
format: {
|
|
82
84
|
type: 'string',
|
|
83
|
-
description: 'Report format: "markdown" (default) or "
|
|
84
|
-
enum: ['markdown', 'sarif'],
|
|
85
|
+
description: 'Report format: "markdown" (default), "sarif", or "json".',
|
|
86
|
+
enum: ['markdown', 'sarif', 'json'],
|
|
85
87
|
},
|
|
86
88
|
write: {
|
|
87
89
|
type: 'boolean',
|
|
88
|
-
description: 'When true, persist the report to `.iterate/report.md` / `.iterate/report.sarif` and return its path. Default: false.',
|
|
90
|
+
description: 'When true, persist the report to `.iterate/report.md` / `.iterate/report.sarif` / `.iterate/report.json` and return its path. Default: false.',
|
|
89
91
|
},
|
|
90
92
|
lang: {
|
|
91
93
|
type: 'string',
|
|
@@ -106,10 +108,12 @@ export function registerReportTool(ctx: { tools: { register: (def: ReturnType<ty
|
|
|
106
108
|
ok: { type: 'boolean', required: true },
|
|
107
109
|
kind: { type: 'string' },
|
|
108
110
|
format: { type: 'string' },
|
|
109
|
-
/** The rendered report text (Markdown, or pretty-printed SARIF
|
|
111
|
+
/** The rendered report text (Markdown, or pretty-printed SARIF/JSON). */
|
|
110
112
|
content: { type: 'string' },
|
|
111
113
|
/** The SARIF log object, when format is "sarif". */
|
|
112
114
|
sarif: { type: 'json' },
|
|
115
|
+
/** The full normalized run model, when format is "json". */
|
|
116
|
+
json: { type: 'json' },
|
|
113
117
|
/** Whether a source transcript manifest was found on disk. */
|
|
114
118
|
source: { type: 'string' },
|
|
115
119
|
written: { type: 'boolean' },
|
|
@@ -161,6 +165,7 @@ export function registerReportTool(ctx: { tools: { register: (def: ReturnType<ty
|
|
|
161
165
|
const extra = { qualityGate, experienceBank, defenseEvents }
|
|
162
166
|
let content: string
|
|
163
167
|
let sarif: JsonValue | undefined
|
|
168
|
+
let json: JsonValue | undefined
|
|
164
169
|
let findings = 0
|
|
165
170
|
try {
|
|
166
171
|
if (format === 'sarif') {
|
|
@@ -170,6 +175,11 @@ export function registerReportTool(ctx: { tools: { register: (def: ReturnType<ty
|
|
|
170
175
|
sarif = doc as unknown as JsonValue
|
|
171
176
|
content = JSON.stringify(doc, null, 2)
|
|
172
177
|
findings = Array.isArray(doc?.runs?.[0]?.results) ? (doc.runs[0].results as unknown[]).length : 0
|
|
178
|
+
} else if (format === 'json') {
|
|
179
|
+
const doc = renderRunReportJson(manifest, extra)
|
|
180
|
+
json = doc as unknown as JsonValue
|
|
181
|
+
content = JSON.stringify(doc, null, 2)
|
|
182
|
+
findings = doc.findings.length
|
|
173
183
|
} else {
|
|
174
184
|
content = renderRunReportMarkdown(manifest, extra, { lang }) as unknown as string
|
|
175
185
|
findings = countMarkdownFindings(content)
|
|
@@ -189,7 +199,7 @@ export function registerReportTool(ctx: { tools: { register: (def: ReturnType<ty
|
|
|
189
199
|
try {
|
|
190
200
|
const dir = iterateDir(projectRoot)
|
|
191
201
|
mkdirSync(dir, { recursive: true })
|
|
192
|
-
reportPath = `${dir}/report.${format === 'sarif' ? 'sarif' : 'md'}`
|
|
202
|
+
reportPath = `${dir}/report.${format === 'sarif' ? 'sarif' : format === 'json' ? 'json' : 'md'}`
|
|
193
203
|
writeFileSync(reportPath, content, 'utf-8')
|
|
194
204
|
written = true
|
|
195
205
|
} catch (err) {
|
|
@@ -208,6 +218,7 @@ export function registerReportTool(ctx: { tools: { register: (def: ReturnType<ty
|
|
|
208
218
|
format,
|
|
209
219
|
content,
|
|
210
220
|
...(sarif !== undefined ? { sarif } : {}),
|
|
221
|
+
...(json !== undefined ? { json } : {}),
|
|
211
222
|
source,
|
|
212
223
|
written,
|
|
213
224
|
...(reportPath ? { reportPath } : {}),
|