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.
@@ -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 18 tools (14 original + 3 v3.0 quality command center
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 18 tools to do real work (read config, run validation, log decisions,
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/ — 18 tool implementations (14 original + 3 v3.0 + 1 v3.9)
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 lines = decodedLines(filePath)
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,