iterate-plugin 3.15.0 → 3.17.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/README.zh-CN.md +3 -1
- package/dist/index.js +5 -4
- package/dist/tools/decision-log.js +21 -2
- package/lib/client.js +443 -7
- package/lib/parse.js +192 -0
- package/package.json +1 -1
- package/src/client/i18n.ts +29 -2
- package/src/client/index.ts +235 -21
- package/src/client/run-compare.ts +239 -0
- package/src/index.ts +5 -4
- package/src/tools/decision-log.ts +20 -2
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/client/run-compare.ts — cross-session run comparison (the observatory F2 tab).
|
|
3
|
+
*
|
|
4
|
+
* The F2 convergence tab compares ROUNDS *within* one session, but comparing
|
|
5
|
+
* two ITERATIONS of the same project (before / after a refactor) used to be a
|
|
6
|
+
* manual chore: export JSON for each run, open both files, line up the columns.
|
|
7
|
+
* This module turns that into a first-class interaction. The current run is
|
|
8
|
+
* reduced to a compact, serializable summary and saved to localStorage as the
|
|
9
|
+
* comparison baseline; any later run can then be compared against it side by
|
|
10
|
+
* side, with a per-metric delta and a directional "better / worse" tint.
|
|
11
|
+
*
|
|
12
|
+
* Design constraints (mirrors `i18n.ts` / `session-cache.ts`):
|
|
13
|
+
* - **Pure / dependency-free**: no React, no DOM, no Chinese literals (the
|
|
14
|
+
* bilingual labels live next to `t()` in index.ts, which is the layer the
|
|
15
|
+
* i18n dictionary-contract test scans). The summary builder and the
|
|
16
|
+
* compare reducer are unit-testable on their own.
|
|
17
|
+
* - **Versioned**: the stored payload carries a `version` so a future schema
|
|
18
|
+
* change can migrate or reject stale baselines instead of mis-reading them.
|
|
19
|
+
* - **Never fabricated**: every field is derived from real manifest fields
|
|
20
|
+
* with defensive optional chaining; a run with no findings simply reads 0.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Current schema version of the persisted baseline. */
|
|
24
|
+
export const RUN_BASELINE_VERSION = 1
|
|
25
|
+
|
|
26
|
+
/** localStorage key holding the single comparison baseline (per browser). */
|
|
27
|
+
export const RUN_BASELINE_STORAGE_KEY = 'iterate.run-baseline'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A compact, serializable summary of one run — everything the side-by-side
|
|
31
|
+
* comparison needs, and nothing the client cannot already see in the manifest.
|
|
32
|
+
*/
|
|
33
|
+
export interface RunBaseline {
|
|
34
|
+
version: 1
|
|
35
|
+
/** ISO timestamp of when this baseline was saved (NOT the run end time). */
|
|
36
|
+
savedAt: string
|
|
37
|
+
/** Project root the run was anchored to (informational). */
|
|
38
|
+
project?: string
|
|
39
|
+
/** 'normal' | 'dry-run' — the mode that produced the run. */
|
|
40
|
+
mode?: string
|
|
41
|
+
/** Number of review rounds captured. */
|
|
42
|
+
rounds: number
|
|
43
|
+
/** Total findings raised across the run. */
|
|
44
|
+
findings: number
|
|
45
|
+
/** critical + high findings (the "must fix" surface). */
|
|
46
|
+
criticalHigh: number
|
|
47
|
+
/** Fixes that succeeded. */
|
|
48
|
+
fixed: number
|
|
49
|
+
/** Fixes that failed (rolled back / validation-rejected). */
|
|
50
|
+
failedFixes: number
|
|
51
|
+
/** Fraction of validation command runs that passed, 0-100, or null if none ran. */
|
|
52
|
+
validationPassRate: number | null
|
|
53
|
+
/** Number of validation command runs. */
|
|
54
|
+
validationTotal: number
|
|
55
|
+
/** 'pass' | 'fail' | null — the quality gate verdict, when it was computed. */
|
|
56
|
+
qualityGateStatus: string | null
|
|
57
|
+
/** Quality gate overall score (0-100) when computed. */
|
|
58
|
+
qualityGateScore: number | null
|
|
59
|
+
/** Why the run ended: converged / max_rounds_reached / aborted_by_validation. */
|
|
60
|
+
stoppedReason: string | null
|
|
61
|
+
/** Findings per round (the convergence series), for a side-by-side trend. */
|
|
62
|
+
convergence: number[]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The manifest-shaped inputs the summary builder reads (defensively). */
|
|
66
|
+
export interface RunBaselineInput {
|
|
67
|
+
project?: string
|
|
68
|
+
mode?: string
|
|
69
|
+
convergence?: number[]
|
|
70
|
+
findings?: Array<{ severity?: string }>
|
|
71
|
+
fixes?: Array<{ success?: boolean }>
|
|
72
|
+
validations?: Array<{ allowed?: boolean }>
|
|
73
|
+
qualityGateStatus?: string | null
|
|
74
|
+
qualityGateScore?: number | null
|
|
75
|
+
stoppedReason?: string | null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Reduce a run manifest (plus the session-scanned quality gate) to a compact,
|
|
80
|
+
* comparison-ready summary. Pure: `savedAt` is injected by the caller so this
|
|
81
|
+
* stays deterministic and testable.
|
|
82
|
+
*/
|
|
83
|
+
export function buildRunBaseline(input: RunBaselineInput, savedAt: string): RunBaseline {
|
|
84
|
+
const convergence = Array.isArray(input.convergence) ? input.convergence : []
|
|
85
|
+
// Only object entries count — a null / primitive in a malformed manifest
|
|
86
|
+
// must never be read as a finding / fix / validation (and skew the rate).
|
|
87
|
+
const findings = (Array.isArray(input.findings) ? input.findings : []).filter(
|
|
88
|
+
(f): f is { severity?: string } => typeof f === 'object' && f !== null,
|
|
89
|
+
)
|
|
90
|
+
const fixes = (Array.isArray(input.fixes) ? input.fixes : []).filter(
|
|
91
|
+
(f): f is { success?: boolean } => typeof f === 'object' && f !== null,
|
|
92
|
+
)
|
|
93
|
+
const validations = (Array.isArray(input.validations) ? input.validations : []).filter(
|
|
94
|
+
(v): v is { allowed?: boolean } => typeof v === 'object' && v !== null,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
let criticalHigh = 0
|
|
98
|
+
for (const f of findings) {
|
|
99
|
+
const sev = String((f && f.severity) || '').toLowerCase()
|
|
100
|
+
if (sev === 'critical' || sev === 'high') criticalHigh += 1
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let fixed = 0
|
|
104
|
+
let failedFixes = 0
|
|
105
|
+
for (const x of fixes) {
|
|
106
|
+
if (x && x.success === true) fixed += 1
|
|
107
|
+
else if (x && x.success === false) failedFixes += 1
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let passed = 0
|
|
111
|
+
for (const v of validations) {
|
|
112
|
+
if (v && v.allowed === true) passed += 1
|
|
113
|
+
}
|
|
114
|
+
const validationTotal = validations.length
|
|
115
|
+
const validationPassRate = validationTotal > 0 ? Math.round((passed / validationTotal) * 100) : null
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
version: RUN_BASELINE_VERSION,
|
|
119
|
+
savedAt,
|
|
120
|
+
...(input.project ? { project: input.project } : {}),
|
|
121
|
+
...(input.mode ? { mode: input.mode } : {}),
|
|
122
|
+
rounds: convergence.length,
|
|
123
|
+
findings: findings.length,
|
|
124
|
+
criticalHigh,
|
|
125
|
+
fixed,
|
|
126
|
+
failedFixes,
|
|
127
|
+
validationPassRate,
|
|
128
|
+
validationTotal,
|
|
129
|
+
qualityGateStatus: input.qualityGateStatus || null,
|
|
130
|
+
qualityGateScore: typeof input.qualityGateScore === 'number' ? input.qualityGateScore : null,
|
|
131
|
+
stoppedReason: input.stoppedReason || null,
|
|
132
|
+
convergence,
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Parse a persisted baseline JSON string. Returns `null` for anything that is
|
|
138
|
+
* not a well-formed, version-matched baseline — a stale/corrupt payload must
|
|
139
|
+
* never be mis-read as a real run, and never throws.
|
|
140
|
+
*/
|
|
141
|
+
export function parseRunBaseline(raw: string | null): RunBaseline | null {
|
|
142
|
+
if (!raw) return null
|
|
143
|
+
let parsed: unknown
|
|
144
|
+
try {
|
|
145
|
+
parsed = JSON.parse(raw)
|
|
146
|
+
} catch {
|
|
147
|
+
return null
|
|
148
|
+
}
|
|
149
|
+
if (typeof parsed !== 'object' || parsed === null) return null
|
|
150
|
+
const b = parsed as Record<string, unknown>
|
|
151
|
+
if (b.version !== RUN_BASELINE_VERSION) return null
|
|
152
|
+
if (typeof b.savedAt !== 'string' || b.savedAt.length === 0) return null
|
|
153
|
+
if (typeof b.findings !== 'number' || !Number.isFinite(b.findings)) return null
|
|
154
|
+
// convergence must be a number array (defensive; a non-array becomes []).
|
|
155
|
+
const convergence = Array.isArray(b.convergence)
|
|
156
|
+
? b.convergence.filter((n): n is number => typeof n === 'number' && Number.isFinite(n))
|
|
157
|
+
: []
|
|
158
|
+
return {
|
|
159
|
+
version: RUN_BASELINE_VERSION,
|
|
160
|
+
savedAt: b.savedAt,
|
|
161
|
+
...(typeof b.project === 'string' && b.project ? { project: b.project } : {}),
|
|
162
|
+
...(typeof b.mode === 'string' && b.mode ? { mode: b.mode } : {}),
|
|
163
|
+
rounds: typeof b.rounds === 'number' && Number.isFinite(b.rounds) ? b.rounds : convergence.length,
|
|
164
|
+
findings: b.findings,
|
|
165
|
+
criticalHigh: typeof b.criticalHigh === 'number' && Number.isFinite(b.criticalHigh) ? b.criticalHigh : 0,
|
|
166
|
+
fixed: typeof b.fixed === 'number' && Number.isFinite(b.fixed) ? b.fixed : 0,
|
|
167
|
+
failedFixes: typeof b.failedFixes === 'number' && Number.isFinite(b.failedFixes) ? b.failedFixes : 0,
|
|
168
|
+
validationPassRate:
|
|
169
|
+
typeof b.validationPassRate === 'number' && Number.isFinite(b.validationPassRate) ? b.validationPassRate : null,
|
|
170
|
+
validationTotal:
|
|
171
|
+
typeof b.validationTotal === 'number' && Number.isFinite(b.validationTotal) ? b.validationTotal : 0,
|
|
172
|
+
qualityGateStatus: typeof b.qualityGateStatus === 'string' && b.qualityGateStatus ? b.qualityGateStatus : null,
|
|
173
|
+
qualityGateScore:
|
|
174
|
+
typeof b.qualityGateScore === 'number' && Number.isFinite(b.qualityGateScore) ? b.qualityGateScore : null,
|
|
175
|
+
stoppedReason: typeof b.stoppedReason === 'string' && b.stoppedReason ? b.stoppedReason : null,
|
|
176
|
+
convergence,
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* One row of the side-by-side comparison.
|
|
182
|
+
*
|
|
183
|
+
* `higherIsBetter` tells the renderer how to tint the delta:
|
|
184
|
+
* - `true` → a larger current value is an improvement (fixed, pass rate, score)
|
|
185
|
+
* - `false` → a smaller current value is an improvement (findings, failed fixes)
|
|
186
|
+
* - `null` → neutral (rounds — fewer is usually faster but not always better)
|
|
187
|
+
*/
|
|
188
|
+
export interface RunCompareMetric {
|
|
189
|
+
key: 'rounds' | 'findings' | 'criticalHigh' | 'fixed' | 'failedFixes' | 'validationPassRate' | 'qualityGateScore'
|
|
190
|
+
baseline: number | null
|
|
191
|
+
current: number | null
|
|
192
|
+
higherIsBetter: boolean | null
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Compare a saved baseline against the current run. Both sides come from
|
|
197
|
+
* `buildRunBaseline`, so `null` means "that side had no such measurement"
|
|
198
|
+
* (e.g. a dry-run has no fixes; a run that never validated has no pass rate).
|
|
199
|
+
*/
|
|
200
|
+
export function compareRuns(baseline: RunBaseline, current: RunBaseline): RunCompareMetric[] {
|
|
201
|
+
const num = (v: number | null | undefined): number | null =>
|
|
202
|
+
typeof v === 'number' && Number.isFinite(v) ? v : null
|
|
203
|
+
const rows: RunCompareMetric[] = [
|
|
204
|
+
{ key: 'rounds', baseline: num(baseline.rounds), current: num(current.rounds), higherIsBetter: null },
|
|
205
|
+
{ key: 'findings', baseline: num(baseline.findings), current: num(current.findings), higherIsBetter: false },
|
|
206
|
+
{ key: 'criticalHigh', baseline: num(baseline.criticalHigh), current: num(current.criticalHigh), higherIsBetter: false },
|
|
207
|
+
{ key: 'fixed', baseline: num(baseline.fixed), current: num(current.fixed), higherIsBetter: true },
|
|
208
|
+
{ key: 'failedFixes', baseline: num(baseline.failedFixes), current: num(current.failedFixes), higherIsBetter: false },
|
|
209
|
+
{ key: 'validationPassRate', baseline: num(baseline.validationPassRate), current: num(current.validationPassRate), higherIsBetter: true },
|
|
210
|
+
{ key: 'qualityGateScore', baseline: num(baseline.qualityGateScore), current: num(current.qualityGateScore), higherIsBetter: true },
|
|
211
|
+
]
|
|
212
|
+
return rows
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Render the side-by-side comparison as a Markdown table that can be pasted
|
|
217
|
+
* into a PR / issue / CI comment. `labels` is resolved by the caller from the
|
|
218
|
+
* current UI language (this module stays dependency-free and Chinese-free).
|
|
219
|
+
* A `null` cell renders as `—` (that side had no such measurement).
|
|
220
|
+
*
|
|
221
|
+
* @param rows - the rows from {@link compareRuns}
|
|
222
|
+
* @param labels - metric key → localized column name
|
|
223
|
+
*/
|
|
224
|
+
export function buildRunCompareMarkdown(rows: RunCompareMetric[], labels: Record<string, string>): string {
|
|
225
|
+
const cell = (v: number | null): string => (v === null ? '—' : String(v))
|
|
226
|
+
const header = ['指标', '基准', '当前', '变化'].map((h) => labels[h] ?? h)
|
|
227
|
+
const lines = [
|
|
228
|
+
`| ${header.join(' | ')} |`,
|
|
229
|
+
`| ${header.map(() => '---').join(' | ')} |`,
|
|
230
|
+
]
|
|
231
|
+
for (const row of rows) {
|
|
232
|
+
const delta =
|
|
233
|
+
row.baseline === null || row.current === null ? '—' : String(row.current - row.baseline)
|
|
234
|
+
lines.push(
|
|
235
|
+
`| ${labels[row.key] ?? row.key} | ${cell(row.baseline)} | ${cell(row.current)} | ${delta} |`,
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
return lines.join('\n')
|
|
239
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2,16 +2,17 @@
|
|
|
2
2
|
* iterate-plugin — dsh plugin for the iterate autonomous closed-loop workflow
|
|
3
3
|
*
|
|
4
4
|
* Architecture:
|
|
5
|
-
* - The plugin registers
|
|
6
|
-
* tools + 1 v3.9 report export)
|
|
5
|
+
* - The plugin registers 19 tools (14 original + 3 v3.0 quality command center
|
|
6
|
+
* tools + 1 v3.9 report export + 1 v3.12 pre-run preflight)
|
|
7
7
|
* Original: config, validate, decision-log, context, review, triage, fix, diff,
|
|
8
8
|
* rollback, checkpoint, status, history, prune, transcript
|
|
9
9
|
* v3.0: experience, quality_gate, defense_events
|
|
10
10
|
* v3.9: report
|
|
11
|
+
* v3.12: preflight
|
|
11
12
|
* - The plugin injects a system prompt section teaching the iterate workflow pattern
|
|
12
13
|
* - The model (prompted by the skill) writes a workflow script using dsh's `workflow` tool
|
|
13
14
|
* - The workflow script uses `agent()` / `parallel()` / `phase()` / `log()` to orchestrate
|
|
14
|
-
* - Subagents use the
|
|
15
|
+
* - Subagents use the 19 tools to do real work (read config, run validation, log decisions,
|
|
15
16
|
* review, triage, apply/rollback/fixing, checkpoint, status, history, prune, transcript,
|
|
16
17
|
* query experience bank, check quality gates, query defense events, export a report)
|
|
17
18
|
* - A `tools/pre-execute` hook gates destructive iterate calls behind human approval
|
|
@@ -25,7 +26,7 @@
|
|
|
25
26
|
*
|
|
26
27
|
* Key files:
|
|
27
28
|
* - src/index.ts — Plugin entry: register tools + inject skill prompt
|
|
28
|
-
* - src/tools/ —
|
|
29
|
+
* - src/tools/ — 19 tool implementations (14 original + 3 v3.0 + 1 v3.9 + 1 v3.12)
|
|
29
30
|
* - src/config-loader.ts — YAML config loading
|
|
30
31
|
* - src/types.ts — Shared types
|
|
31
32
|
*/
|
|
@@ -323,6 +323,25 @@ function decodedLines(filePath: string): Array<{ raw: string; decoded: unknown }
|
|
|
323
323
|
return out
|
|
324
324
|
}
|
|
325
325
|
|
|
326
|
+
/**
|
|
327
|
+
* Rebuild the exact ledger text the kernel verifies, dropping only the artifact a
|
|
328
|
+
* trailing newline leaves. Unlike `decodedLines` this keeps interior blank lines:
|
|
329
|
+
* `kernelVerifyLedgerText` splits on "\n" and `JSON.parse`s each line, so an interior
|
|
330
|
+
* blank line is a corrupt entry it must report — renumbering the lines that follow it
|
|
331
|
+
* (by dropping it here) would mask a real break as a healthy ledger.
|
|
332
|
+
*/
|
|
333
|
+
function ledgerTextForVerify(filePath: string): string {
|
|
334
|
+
let text: string
|
|
335
|
+
try {
|
|
336
|
+
text = readFileSync(filePath, 'utf-8')
|
|
337
|
+
} catch {
|
|
338
|
+
return ''
|
|
339
|
+
}
|
|
340
|
+
const lines = text.endsWith('\n') ? text.slice(0, -1) : text
|
|
341
|
+
if (lines.length === 0) return ''
|
|
342
|
+
return `${lines}\n`
|
|
343
|
+
}
|
|
344
|
+
|
|
326
345
|
/** The 1-based line number of the first legacy record AFTER the chain began, else `null`. */
|
|
327
346
|
export function contestedLedgerLine(filePath: string): number | null {
|
|
328
347
|
const kinds = decodedLines(filePath).map((l) => classifyDecisionLine(l.decoded))
|
|
@@ -798,8 +817,7 @@ export function verifyDecisionLedger(projectRoot: string): {
|
|
|
798
817
|
} {
|
|
799
818
|
const filePath = decisionLogPath(projectRoot)
|
|
800
819
|
try {
|
|
801
|
-
const
|
|
802
|
-
const body = `${lines.map((l) => l.raw).join('\n')}${lines.length > 0 ? '\n' : ''}`
|
|
820
|
+
const body = ledgerTextForVerify(filePath)
|
|
803
821
|
const verified = kernelVerifyLedgerText(body)
|
|
804
822
|
return {
|
|
805
823
|
ok: verified.ok,
|