iterate-plugin 3.7.0 → 3.9.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 +43 -8
- package/README.zh-CN.md +26 -5
- package/dist/file-lock.js +75 -14
- package/dist/git-scope.js +22 -2
- package/dist/index.js +9 -4
- package/dist/report-render.js +452 -0
- package/dist/session-record.js +246 -0
- package/dist/skill-prompt.js +4 -3
- package/dist/tools/checkpoint.js +24 -17
- package/dist/tools/config.js +42 -33
- package/dist/tools/decision-log.js +13 -93
- package/dist/tools/report.js +239 -0
- package/dist/tools/transcript.js +260 -53
- package/dist/tools/triage.js +52 -45
- package/dist/transcript.js +2 -2
- package/lib/client.js +486 -22
- package/lib/parse.js +29 -6
- package/package.json +38 -13
- package/src/client/i18n.ts +6 -0
- package/src/client/index.ts +66 -4
- package/src/file-lock.ts +84 -14
- package/src/git-scope.ts +21 -2
- package/src/index.ts +9 -4
- package/src/report-render.ts +578 -0
- package/src/session-record.ts +333 -0
- package/src/skill-prompt.ts +4 -3
- package/src/tools/checkpoint.ts +23 -16
- package/src/tools/config.ts +40 -31
- package/src/tools/decision-log.ts +12 -80
- package/src/tools/report.ts +244 -0
- package/src/tools/transcript.ts +271 -52
- package/src/tools/triage.ts +47 -40
- package/src/transcript.ts +2 -2
- package/src/types.ts +1 -1
package/lib/parse.js
CHANGED
|
@@ -2281,8 +2281,17 @@ export function stoppedReasonLabel(reason, lang = 'zh') {
|
|
|
2281
2281
|
schema_invalid: 'Done \u00b7 repeated schema validation failures in review output',
|
|
2282
2282
|
no_usable_reviewer_output: 'Done \u00b7 no usable review output',
|
|
2283
2283
|
}
|
|
2284
|
-
|
|
2285
|
-
|
|
2284
|
+
// Object.hasOwn, NOT `zh[reason]`: a model/hand-edited `stoppedReason` of
|
|
2285
|
+
// `__proto__` / `constructor` / `toString` would otherwise resolve through
|
|
2286
|
+
// the PROTOTYPE CHAIN to Object.prototype or a function (never a string) and
|
|
2287
|
+
// crash React ("Objects are not valid as a React child") when the badge
|
|
2288
|
+
// renders it. An own-key hit is the only way to return a table value; any
|
|
2289
|
+
// other value degrades to a plain text label.
|
|
2290
|
+
const table = lang === 'en' ? en : zh
|
|
2291
|
+
if (typeof reason === 'string' && Object.hasOwn(table, reason)) return table[reason]
|
|
2292
|
+
const text = typeof reason === 'string' && reason !== '' ? reason : ''
|
|
2293
|
+
if (lang === 'en') return text ? `Done \u00b7 ${text}` : 'Done'
|
|
2294
|
+
return text ? `已结束 · ${text}` : '已结束'
|
|
2286
2295
|
}
|
|
2287
2296
|
|
|
2288
2297
|
// ─── Completion notification ────────────────────────────────────────────────
|
|
@@ -2391,8 +2400,9 @@ export function buildConfigEditGuide() {
|
|
|
2391
2400
|
'---------------------',
|
|
2392
2401
|
'配置文件:项目根目录 iterate.config.yaml。',
|
|
2393
2402
|
'',
|
|
2394
|
-
'⚠ 未配置 validation.commands
|
|
2395
|
-
'
|
|
2403
|
+
'⚠ 未配置 validation.commands 时迭代不被保护:该轮会直接中止(fail-closed),',
|
|
2404
|
+
' 空验证结果/验证未执行绝不当作“验证通过”,运行以 converged_unverified /',
|
|
2405
|
+
' aborted_by_config 收尾而不是 plain converged。第一次启动迭代前,',
|
|
2396
2406
|
' 请先给 validation.commands 配上至少一条真实命令(如 npm test)。',
|
|
2397
2407
|
'',
|
|
2398
2408
|
'可编辑字段:',
|
|
@@ -2789,14 +2799,19 @@ function pickExportExtras(extra) {
|
|
|
2789
2799
|
* One row per round: findings raised, findings fixed, severity mix and the
|
|
2790
2800
|
* validation command outcomes recorded on the manifest.
|
|
2791
2801
|
*
|
|
2802
|
+
* `fixed` counts SUCCESSFUL fixes applied in that round, taken from the
|
|
2803
|
+
* transcript's `fixes` (each `{ round, success }`) — the review `findings`
|
|
2804
|
+
* schema carries no `status`, so reading `f.status` here always yielded 0.
|
|
2805
|
+
*
|
|
2792
2806
|
* Cross-*session* comparison is intentionally out of scope — that is what the
|
|
2793
2807
|
* #4 pull instruction is for (see `buildDiskSnapshotInstruction`).
|
|
2794
2808
|
*
|
|
2795
2809
|
* @param {unknown} report
|
|
2796
2810
|
* @param {unknown} validations
|
|
2811
|
+
* @param {unknown} [fixes] transcript fixes (`{round, success}[]`) for the fixed counts
|
|
2797
2812
|
* @returns {Array<{round: number, findings: number, fixed: number, severities: Record<string, number>, validations: Array<{command: string, exitCode: number|null, allowed: boolean}>}>}
|
|
2798
2813
|
*/
|
|
2799
|
-
export function buildRoundComparison(report, validations) {
|
|
2814
|
+
export function buildRoundComparison(report, validations, fixes) {
|
|
2800
2815
|
const rounds = new Map()
|
|
2801
2816
|
const rowsFor = (round) => {
|
|
2802
2817
|
const n = Number(round)
|
|
@@ -2814,7 +2829,15 @@ export function buildRoundComparison(report, validations) {
|
|
|
2814
2829
|
row.findings += 1
|
|
2815
2830
|
const sev = typeof f.severity === 'string' && f.severity ? f.severity : 'unknown'
|
|
2816
2831
|
row.severities[sev] = (row.severities[sev] || 0) + 1
|
|
2817
|
-
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
// Fixed counts come from applied fixes (real data), not a finding `status`
|
|
2835
|
+
// the findings schema never produces.
|
|
2836
|
+
const fixList = Array.isArray(fixes) ? fixes : []
|
|
2837
|
+
for (const fx of fixList) {
|
|
2838
|
+
if (!fx || typeof fx !== 'object') continue
|
|
2839
|
+
if ((/** @type {Record<string, unknown>} */ (fx)).success !== true) continue
|
|
2840
|
+
rowsFor((/** @type {Record<string, unknown>} */ (fx)).round).fixed += 1
|
|
2818
2841
|
}
|
|
2819
2842
|
|
|
2820
2843
|
const vals = Array.isArray(validations) ? validations : []
|
package/package.json
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iterate-plugin",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.9.0",
|
|
4
4
|
"description": "dsh plugin that turns the iterate skill into an autonomous closed-loop harness with quality command center and experience bank (v3.3). Features: plan -> parallel review xN -> atomic fixes -> validate -> loop -> auto-stop, plus dry-run pure-review mode, quality gate compute/persist, writable experience bank, defense events stream (record + bilingual labels), and native command buttons — with a live quality command center (F8/F9/F10 render real session data), §8 assign-fix instruction, and task_mode indicator wired end to end.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
|
-
"url": "https://github.com/jingzhao-l/iterate-
|
|
10
|
-
"directory": "harness/iterate-plugin"
|
|
9
|
+
"url": "https://github.com/jingzhao-l/iterate-plugin.git"
|
|
11
10
|
},
|
|
12
|
-
"homepage": "https://github.com/jingzhao-l/iterate-
|
|
11
|
+
"homepage": "https://github.com/jingzhao-l/iterate-plugin#readme",
|
|
13
12
|
"bugs": {
|
|
14
|
-
"url": "https://github.com/jingzhao-l/iterate-
|
|
13
|
+
"url": "https://github.com/jingzhao-l/iterate-plugin/issues"
|
|
15
14
|
},
|
|
16
15
|
"keywords": [
|
|
17
16
|
"dsh-plugin",
|
|
@@ -47,7 +46,12 @@
|
|
|
47
46
|
"0.2.0-rc.1": "compatible",
|
|
48
47
|
"0.2.0-rc.2": "compatible",
|
|
49
48
|
"0.2.1-alpha.1": "compatible",
|
|
50
|
-
"0.2.1-alpha.2": "compatible"
|
|
49
|
+
"0.2.1-alpha.2": "compatible",
|
|
50
|
+
"0.2.1-alpha.3": "compatible",
|
|
51
|
+
"0.2.1-alpha.4": "compatible",
|
|
52
|
+
"0.2.1-alpha.5": "compatible",
|
|
53
|
+
"0.2.1-rc.1": "compatible",
|
|
54
|
+
"0.2.1-rc.2": "compatible"
|
|
51
55
|
}
|
|
52
56
|
},
|
|
53
57
|
"client": {
|
|
@@ -84,15 +88,35 @@
|
|
|
84
88
|
"test:validate": "tsx --test test/validate.test.ts"
|
|
85
89
|
},
|
|
86
90
|
"dependencies": {
|
|
87
|
-
"@deepseek-ai/cordis": "4.0.5-alpha.1",
|
|
88
|
-
"@deepseek-ai/dsh-tools": "0.2.1-alpha.2",
|
|
89
|
-
"@deepseek-ai/dsh-util-values": "0.2.1-alpha.2",
|
|
90
91
|
"js-yaml": "4.3.2"
|
|
91
92
|
},
|
|
93
|
+
"peerDependencies": {
|
|
94
|
+
"@deepseek-ai/cordis": "^4.0.5-alpha.2",
|
|
95
|
+
"@deepseek-ai/dsh-session": "0.1.1-rc.1 || 0.1.2-alpha.4 || 0.1.2-alpha.5 || 0.1.2-rc.1 || 0.1.5-alpha.1 || 0.1.5-alpha.2 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.6-alpha.1 || 0.1.6-alpha.2 || 0.1.7-alpha.1 || 0.1.7-alpha.2 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4 || 0.2.1-alpha.5 || 0.2.1-rc.1 || 0.2.1-rc.2",
|
|
96
|
+
"@deepseek-ai/dsh-tools": "0.1.1-rc.1 || 0.1.2-alpha.4 || 0.1.2-alpha.5 || 0.1.2-rc.1 || 0.1.5-alpha.1 || 0.1.5-alpha.2 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.6-alpha.1 || 0.1.6-alpha.2 || 0.1.7-alpha.1 || 0.1.7-alpha.2 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4 || 0.2.1-alpha.5 || 0.2.1-rc.1 || 0.2.1-rc.2",
|
|
97
|
+
"@deepseek-ai/dsh-util-values": "0.1.2-alpha.5 || 0.1.2-rc.1 || 0.1.5-alpha.1 || 0.1.5-alpha.2 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.6-alpha.1 || 0.1.6-alpha.2 || 0.1.7-alpha.1 || 0.1.7-alpha.2 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4 || 0.2.1-alpha.5 || 0.2.1-rc.1 || 0.2.1-rc.2"
|
|
98
|
+
},
|
|
99
|
+
"peerDependenciesMeta": {
|
|
100
|
+
"@deepseek-ai/cordis": {
|
|
101
|
+
"optional": true
|
|
102
|
+
},
|
|
103
|
+
"@deepseek-ai/dsh-session": {
|
|
104
|
+
"optional": true
|
|
105
|
+
},
|
|
106
|
+
"@deepseek-ai/dsh-tools": {
|
|
107
|
+
"optional": true
|
|
108
|
+
},
|
|
109
|
+
"@deepseek-ai/dsh-util-values": {
|
|
110
|
+
"optional": true
|
|
111
|
+
}
|
|
112
|
+
},
|
|
92
113
|
"devDependencies": {
|
|
93
|
-
"@deepseek-ai/
|
|
94
|
-
"@deepseek-ai/dsh-
|
|
95
|
-
"@deepseek-ai/dsh-
|
|
114
|
+
"@deepseek-ai/cordis": "4.0.5",
|
|
115
|
+
"@deepseek-ai/dsh-agent": "0.2.1-rc.2",
|
|
116
|
+
"@deepseek-ai/dsh-jobs": "0.2.1-rc.2",
|
|
117
|
+
"@deepseek-ai/dsh-session": "0.2.1-rc.2",
|
|
118
|
+
"@deepseek-ai/dsh-tools": "0.2.1-rc.2",
|
|
119
|
+
"@deepseek-ai/dsh-util-values": "0.2.1-rc.2",
|
|
96
120
|
"@types/js-yaml": "4.0.9",
|
|
97
121
|
"@types/node": "22.15.0",
|
|
98
122
|
"@types/react": "19.2.2",
|
|
@@ -104,6 +128,7 @@
|
|
|
104
128
|
"typescript": "5.9.3"
|
|
105
129
|
},
|
|
106
130
|
"engines": {
|
|
107
|
-
"node": ">=20"
|
|
131
|
+
"node": ">=20",
|
|
132
|
+
"dsh": ">=0.1.1-rc.1"
|
|
108
133
|
}
|
|
109
134
|
}
|
package/src/client/i18n.ts
CHANGED
|
@@ -253,6 +253,12 @@ export const EN: Record<string, string> = {
|
|
|
253
253
|
'Export every observatory panel as JSON (downloads first, falls back to copying)',
|
|
254
254
|
已导出: 'Exported',
|
|
255
255
|
'导出 JSON': 'Export JSON',
|
|
256
|
+
'导出报告': 'Export report',
|
|
257
|
+
'导出可粘贴进 PR 的 Markdown 运行报告(优先下载,失败则复制)':
|
|
258
|
+
'Export a PR-pasteable Markdown run report (download first, copy on failure)',
|
|
259
|
+
'导出 SARIF': 'Export SARIF',
|
|
260
|
+
'导出 SARIF 2.1.0(供 CI / GitHub Code Scanning 消费)':
|
|
261
|
+
'Export SARIF 2.1.0 for CI / GitHub Code Scanning',
|
|
256
262
|
'观测台页签(快捷键 1-9 / 0 / -)': 'Observatory tabs (keys 1-9 / 0 / -)',
|
|
257
263
|
'{a0}(按 {a1} 切换)': '{a0} (press {a1})',
|
|
258
264
|
'{a0}(按 - 切换)': '{a0} (press -)',
|
package/src/client/index.ts
CHANGED
|
@@ -108,6 +108,13 @@ import {
|
|
|
108
108
|
configValueByPath, // #8b: current value of the selected dotted key
|
|
109
109
|
DISK_SNAPSHOT_SOURCES, // #4/#3: read-side tools listed by the pull instruction
|
|
110
110
|
} from '../../lib/parse.js'
|
|
111
|
+
// v3.9: report export — shared with the `iterate_report` tool so the button
|
|
112
|
+
// and the tool can never produce different reports.
|
|
113
|
+
import {
|
|
114
|
+
renderRunReportMarkdown, // Markdown run report (PR-pasteable)
|
|
115
|
+
renderRunReportSarif, // SARIF 2.1.0 log (CI / Code Scanning)
|
|
116
|
+
runReportFilename, // deterministic download filename
|
|
117
|
+
} from '../report-render.ts'
|
|
111
118
|
import {
|
|
112
119
|
configureLangStorage,
|
|
113
120
|
detectLang,
|
|
@@ -1054,9 +1061,22 @@ function ConvergenceDashboard(props: SlotProps) {
|
|
|
1054
1061
|
}, [session])
|
|
1055
1062
|
const report = latestReport(session)
|
|
1056
1063
|
|
|
1064
|
+
// Mount-time suppression: opening a session that ALREADY has a report mounts
|
|
1065
|
+
// this component with that report, and an effect keyed on the report hash
|
|
1066
|
+
// fires immediately — popping a stale "Round X 完成" bubble for work that did
|
|
1067
|
+
// not just complete. Track the first key this instance sees and skip it; only
|
|
1068
|
+
// a subsequent round progression pulses.
|
|
1069
|
+
const pulseSeenRef = React.useRef<string | null>(null)
|
|
1057
1070
|
React.useEffect(() => {
|
|
1058
1071
|
if (!report) return
|
|
1059
1072
|
const cur = getCurrentRound(report)
|
|
1073
|
+
const key = hashReport(report) + ':' + String(cur)
|
|
1074
|
+
if (pulseSeenRef.current === null) {
|
|
1075
|
+
pulseSeenRef.current = key
|
|
1076
|
+
return
|
|
1077
|
+
}
|
|
1078
|
+
if (pulseSeenRef.current === key) return
|
|
1079
|
+
pulseSeenRef.current = key
|
|
1060
1080
|
const conv = report.convergence
|
|
1061
1081
|
emitRoundPulse(cur, conv?.converged === true)
|
|
1062
1082
|
setPulseKey((k) => k + 1)
|
|
@@ -1406,6 +1426,11 @@ function TriagePanel(props: SlotProps) {
|
|
|
1406
1426
|
}
|
|
1407
1427
|
|
|
1408
1428
|
// ── Keyboard shortcuts (y / n / a on the selected finding, ↑/↓ to move) ──
|
|
1429
|
+
// Multi-instance scoping: every turn that has a report mounts its OWN triage
|
|
1430
|
+
// panel. Keying off `document.querySelector('[data-iterate="triage"]')` would
|
|
1431
|
+
// return only the FIRST one, so y/n/a/↑/↓ silently dead whenever focus sits
|
|
1432
|
+
// in a later panel. A per-component ref points at THIS instance's root.
|
|
1433
|
+
const rootRef = React.useRef<HTMLDivElement | null>(null)
|
|
1409
1434
|
React.useEffect(() => {
|
|
1410
1435
|
const doc = typeof document !== 'undefined' ? document : null
|
|
1411
1436
|
if (!doc) return
|
|
@@ -1417,10 +1442,10 @@ function TriagePanel(props: SlotProps) {
|
|
|
1417
1442
|
const t = ev.target as HTMLElement | null
|
|
1418
1443
|
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT')) return
|
|
1419
1444
|
if (t && typeof t.isContentEditable === 'boolean' && t.isContentEditable) return
|
|
1420
|
-
// Only act when focus is INSIDE
|
|
1445
|
+
// Only act when focus is INSIDE THIS panel. Everything else keeps
|
|
1421
1446
|
// standard behavior (↑/↓ page scroll, typing in the composer), so we do
|
|
1422
1447
|
// not hijack document-level keys just because a report is mounted.
|
|
1423
|
-
const rootEl =
|
|
1448
|
+
const rootEl = rootRef.current
|
|
1424
1449
|
if (!rootEl) return
|
|
1425
1450
|
const activeEl = doc.activeElement as HTMLElement | null
|
|
1426
1451
|
if (!activeEl) return
|
|
@@ -1584,7 +1609,7 @@ function TriagePanel(props: SlotProps) {
|
|
|
1584
1609
|
)
|
|
1585
1610
|
})
|
|
1586
1611
|
|
|
1587
|
-
return React.createElement('div', { 'data-iterate-root': '', 'data-iterate': 'triage', className: 'iterate-triage' },
|
|
1612
|
+
return React.createElement('div', { ref: rootRef, 'data-iterate-root': '', 'data-iterate': 'triage', className: 'iterate-triage' },
|
|
1588
1613
|
React.createElement('div', { className: 'iterate-triage-head' },
|
|
1589
1614
|
React.createElement('span', { role: 'heading', 'aria-level': 3 }, tf(`Iterate · Findings 分诊 ({a0}/{a1})`, { a0: filtered.length, a1: findings.length })),
|
|
1590
1615
|
React.createElement('span', { className: 'iterate-triage-hint' }, t('y=修复 · n=跳过 · a=已知有意 · ↑/↓ 选择')),
|
|
@@ -2390,6 +2415,30 @@ function ObservatoryPanel(props: SlotProps) {
|
|
|
2390
2415
|
})
|
|
2391
2416
|
}
|
|
2392
2417
|
|
|
2418
|
+
/** v3.9: export the run as a portable report — Markdown (PR-pasteable) or
|
|
2419
|
+
* SARIF 2.1.0 (CI / GitHub Code Scanning). Rendered from the SAME shared
|
|
2420
|
+
* data model as the `iterate_report` tool, so the button and the tool can
|
|
2421
|
+
* never disagree. Download-first with a copy fallback, like `exportObservatory`. */
|
|
2422
|
+
const exportReport = (format: 'markdown' | 'sarif') => {
|
|
2423
|
+
if (!manifest) return
|
|
2424
|
+
const extra = { qualityGate, experienceBank, defenseEvents }
|
|
2425
|
+
const content = format === 'sarif'
|
|
2426
|
+
? JSON.stringify(renderRunReportSarif(manifest, extra), null, 2)
|
|
2427
|
+
: renderRunReportMarkdown(manifest, extra, { lang: getLang() })
|
|
2428
|
+
const filename = runReportFilename(format)
|
|
2429
|
+
const key = `report-${format}`
|
|
2430
|
+
const flash = () => {
|
|
2431
|
+
setCopiedKey(key)
|
|
2432
|
+
if (copyTimer.current) clearTimeout(copyTimer.current)
|
|
2433
|
+
copyTimer.current = setTimeout(() => setCopiedKey((cur) => (cur === key ? null : cur)), 1600)
|
|
2434
|
+
}
|
|
2435
|
+
if (downloadTextFile(filename, content)) { flash(); return }
|
|
2436
|
+
copyText(content).then((ok) => {
|
|
2437
|
+
if (!ok) { setCopyFailText(content); return }
|
|
2438
|
+
flash()
|
|
2439
|
+
})
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2393
2442
|
/** Force every thread open (or closed) and drop per-thread state. */
|
|
2394
2443
|
const setAllThreads = (mode: 'all' | 'none') => {
|
|
2395
2444
|
setThreadMode(mode)
|
|
@@ -2525,7 +2574,7 @@ function ObservatoryPanel(props: SlotProps) {
|
|
|
2525
2574
|
// client has no filesystem access, so `iterate_history` aggregates have to
|
|
2526
2575
|
// be echoed back into the session first).
|
|
2527
2576
|
const report = latestReport(session)
|
|
2528
|
-
const comparison = buildRoundComparison(report, manifest.validations || [])
|
|
2577
|
+
const comparison = buildRoundComparison(report, manifest.validations || [], manifest.fixes || [])
|
|
2529
2578
|
const comparisonTable = comparison.length === 0
|
|
2530
2579
|
? null
|
|
2531
2580
|
: React.createElement('table', { className: 'iterate-obs-table' },
|
|
@@ -3431,6 +3480,19 @@ function ObservatoryPanel(props: SlotProps) {
|
|
|
3431
3480
|
onClick: exportObservatory,
|
|
3432
3481
|
title: t('将观测台全部数据导出为 JSON(优先下载,失败则复制)'),
|
|
3433
3482
|
}, copiedKey === 'export' ? t('已导出') : t('导出 JSON')),
|
|
3483
|
+
// v3.9: portable report export — Markdown for a PR body, SARIF for
|
|
3484
|
+
// CI / GitHub Code Scanning. Rendered from the same data model as
|
|
3485
|
+
// the `iterate_report` tool.
|
|
3486
|
+
React.createElement('button', {
|
|
3487
|
+
className: 'iterate-btn', 'data-copied': copiedKey === 'report-markdown' ? '' : undefined,
|
|
3488
|
+
onClick: () => exportReport('markdown'),
|
|
3489
|
+
title: t('导出可粘贴进 PR 的 Markdown 运行报告(优先下载,失败则复制)'),
|
|
3490
|
+
}, copiedKey === 'report-markdown' ? t('已导出') : t('导出报告')),
|
|
3491
|
+
React.createElement('button', {
|
|
3492
|
+
className: 'iterate-btn', 'data-copied': copiedKey === 'report-sarif' ? '' : undefined,
|
|
3493
|
+
onClick: () => exportReport('sarif'),
|
|
3494
|
+
title: t('导出 SARIF 2.1.0(供 CI / GitHub Code Scanning 消费)'),
|
|
3495
|
+
}, copiedKey === 'report-sarif' ? t('已导出') : t('导出 SARIF')),
|
|
3434
3496
|
// Gap #4: `.iterate/` artifacts are unreachable from the browser;
|
|
3435
3497
|
// this copies the one instruction that makes the model echo them
|
|
3436
3498
|
// into the session stream (which is what every panel scans).
|
package/src/file-lock.ts
CHANGED
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
import {
|
|
31
31
|
closeSync,
|
|
32
32
|
existsSync,
|
|
33
|
+
fstatSync,
|
|
33
34
|
mkdirSync,
|
|
34
35
|
openSync,
|
|
35
36
|
readFileSync,
|
|
@@ -40,7 +41,14 @@ import {
|
|
|
40
41
|
import { join } from 'node:path'
|
|
41
42
|
|
|
42
43
|
const LOCK_DIR = '.iterate'
|
|
43
|
-
|
|
44
|
+
// Bounded wait before degrading to "proceed unlocked". `acquireProjectLock`
|
|
45
|
+
// spins on `Atomics.wait`, which BLOCKS the single plugin thread for the whole
|
|
46
|
+
// budget — so a long default wedges unrelated tool calls and the UI heartbeat
|
|
47
|
+
// whenever a sibling process holds the lock. Healthy holders release in
|
|
48
|
+
// microseconds, so 1000ms is very generous; anything slower is treated as
|
|
49
|
+
// contention and degrades to the historical unlocked behavior rather than
|
|
50
|
+
// stalling the event loop for five seconds.
|
|
51
|
+
const DEFAULT_WAIT_MS = 1000
|
|
44
52
|
const DEFAULT_STALE_MS = 10000
|
|
45
53
|
const POLL_MS = 25
|
|
46
54
|
|
|
@@ -80,19 +88,28 @@ function lockFileName(name: string): string {
|
|
|
80
88
|
return `.${name}.lock`
|
|
81
89
|
}
|
|
82
90
|
|
|
91
|
+
/** Outcome of a checked acquisition: whether the lock was actually TAKEN. */
|
|
92
|
+
export interface AcquireLockResult {
|
|
93
|
+
acquired: boolean
|
|
94
|
+
release: () => void
|
|
95
|
+
}
|
|
96
|
+
|
|
83
97
|
/**
|
|
84
|
-
* Acquire an advisory lock named `name` inside `<projectRoot>/.iterate
|
|
98
|
+
* Acquire an advisory lock named `name` inside `<projectRoot>/.iterate/`,
|
|
99
|
+
* reporting whether it was actually taken.
|
|
85
100
|
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* created or the wait times out — callers then proceed
|
|
89
|
-
* historical best-effort behavior, rather than failing
|
|
101
|
+
* The returned release function is always callable, idempotent, and never
|
|
102
|
+
* throws. `acquired:false` (with a no-op release) is returned when the lock
|
|
103
|
+
* directory cannot be created or the wait times out — callers then proceed
|
|
104
|
+
* unlocked, which is the historical best-effort behavior, rather than failing
|
|
105
|
+
* the operation. Callers that MUST hold the mutex (e.g. a log rewrite that
|
|
106
|
+
* renames the file) branch on `acquired` and refuse to run unlocked.
|
|
90
107
|
*/
|
|
91
|
-
export function
|
|
108
|
+
export function acquireProjectLockChecked(
|
|
92
109
|
projectRoot: string,
|
|
93
110
|
name: string,
|
|
94
111
|
options: LockOptions = {},
|
|
95
|
-
):
|
|
112
|
+
): AcquireLockResult {
|
|
96
113
|
const waitMs = Number.isFinite(options.waitMs) && (options.waitMs ?? 0) >= 0
|
|
97
114
|
? (options.waitMs as number)
|
|
98
115
|
: DEFAULT_WAIT_MS
|
|
@@ -105,7 +122,7 @@ export function acquireProjectLock(
|
|
|
105
122
|
try {
|
|
106
123
|
mkdirSync(dir, { recursive: true })
|
|
107
124
|
} catch {
|
|
108
|
-
return () => {}
|
|
125
|
+
return { acquired: false, release: () => {} }
|
|
109
126
|
}
|
|
110
127
|
}
|
|
111
128
|
const lockPath = join(dir, lockFileName(name))
|
|
@@ -115,7 +132,7 @@ export function acquireProjectLock(
|
|
|
115
132
|
try {
|
|
116
133
|
fd = openSync(lockPath, 'wx')
|
|
117
134
|
} catch (err) {
|
|
118
|
-
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') return () => {}
|
|
135
|
+
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') return { acquired: false, release: () => {} }
|
|
119
136
|
// Lock exists — steal it when the owner is gone or the lock is ancient.
|
|
120
137
|
let stale = false
|
|
121
138
|
try {
|
|
@@ -136,11 +153,20 @@ export function acquireProjectLock(
|
|
|
136
153
|
}
|
|
137
154
|
continue
|
|
138
155
|
}
|
|
139
|
-
if (Date.now() - start > waitMs) return () => {}
|
|
156
|
+
if (Date.now() - start > waitMs) return { acquired: false, release: () => {} }
|
|
140
157
|
sleepSync(POLL_MS)
|
|
141
158
|
continue
|
|
142
159
|
}
|
|
143
|
-
// Owned the lock:
|
|
160
|
+
// Owned the lock: capture the created inode (for the ownership-checked
|
|
161
|
+
// release), stamp the holder pid, then release once on return.
|
|
162
|
+
let ino = 0
|
|
163
|
+
try {
|
|
164
|
+
ino = fstatSync(fd).ino
|
|
165
|
+
} catch {
|
|
166
|
+
// inode unavailable on this filesystem — the release falls back to the
|
|
167
|
+
// pid-stamp check only.
|
|
168
|
+
ino = 0
|
|
169
|
+
}
|
|
144
170
|
try {
|
|
145
171
|
writeSync(fd, String(process.pid))
|
|
146
172
|
} catch {
|
|
@@ -151,10 +177,26 @@ export function acquireProjectLock(
|
|
|
151
177
|
} catch {
|
|
152
178
|
// best-effort
|
|
153
179
|
}
|
|
154
|
-
return makeLockRelease(lockPath)
|
|
180
|
+
return { acquired: true, release: makeLockRelease(lockPath, ino) }
|
|
155
181
|
}
|
|
156
182
|
}
|
|
157
183
|
|
|
184
|
+
/**
|
|
185
|
+
* Acquire an advisory lock named `name` inside `<projectRoot>/.iterate/`.
|
|
186
|
+
*
|
|
187
|
+
* Returns a release function: always callable, idempotent, never throws.
|
|
188
|
+
* A no-op (but callable) release is returned when the lock directory cannot be
|
|
189
|
+
* created or the wait times out — callers then proceed unlocked, which is the
|
|
190
|
+
* historical best-effort behavior, rather than failing the operation.
|
|
191
|
+
*/
|
|
192
|
+
export function acquireProjectLock(
|
|
193
|
+
projectRoot: string,
|
|
194
|
+
name: string,
|
|
195
|
+
options: LockOptions = {},
|
|
196
|
+
): () => void {
|
|
197
|
+
return acquireProjectLockChecked(projectRoot, name, options).release
|
|
198
|
+
}
|
|
199
|
+
|
|
158
200
|
/**
|
|
159
201
|
* Build the release function for a lock file this process just acquired.
|
|
160
202
|
*
|
|
@@ -168,7 +210,25 @@ export function acquireProjectLock(
|
|
|
168
210
|
*
|
|
169
211
|
* Always callable, idempotent, and never throws.
|
|
170
212
|
*/
|
|
171
|
-
|
|
213
|
+
/**
|
|
214
|
+
* Build the release function for a lock file this process just acquired.
|
|
215
|
+
*
|
|
216
|
+
* Advisory locks here are STOLEN when the recorded owner dies or the mtime
|
|
217
|
+
* goes ancient: the steal unlinks our file and creates a new one stamped with
|
|
218
|
+
* the STEALER's pid. An unguarded release would then unlink the new holder's
|
|
219
|
+
* lock — turning our release into a double-release that admits a third process
|
|
220
|
+
* into a "held" critical section. So the release re-reads the stamp and only
|
|
221
|
+
* unlinks a file that is still ours: same pid (or unstamped) AND, when we know
|
|
222
|
+
* the inode we created, the SAME inode still at the path. The inode check closes
|
|
223
|
+
* the read-then-unlink window where the stamp check alone could race a steal
|
|
224
|
+
* (our pid is alive, but the file was already replaced between the two calls).
|
|
225
|
+
*
|
|
226
|
+
* Always callable, idempotent, and never throws.
|
|
227
|
+
*
|
|
228
|
+
* @param lockPath - the lock file path.
|
|
229
|
+
* @param ino - the inode captured at acquisition (0 = unknown; skip the check).
|
|
230
|
+
*/
|
|
231
|
+
export function makeLockRelease(lockPath: string, ino?: number): () => void {
|
|
172
232
|
let released = false
|
|
173
233
|
return () => {
|
|
174
234
|
if (released) return
|
|
@@ -183,6 +243,16 @@ export function makeLockRelease(lockPath: string): () => void {
|
|
|
183
243
|
// A stamp naming another pid means the lock was stolen and reassigned:
|
|
184
244
|
// it is no longer ours to delete.
|
|
185
245
|
if (raw !== '' && Number(raw) !== process.pid) return
|
|
246
|
+
// The path must still refer to the file WE created. A stealer unlinked our
|
|
247
|
+
// file and created a fresh one; that replacement carries a different inode,
|
|
248
|
+
// so unlinking by path would delete the NEW holder's lock.
|
|
249
|
+
if (typeof ino === 'number' && ino !== 0) {
|
|
250
|
+
try {
|
|
251
|
+
if (statSync(lockPath).ino !== ino) return
|
|
252
|
+
} catch {
|
|
253
|
+
return // vanished mid-release — nothing of ours left
|
|
254
|
+
}
|
|
255
|
+
}
|
|
186
256
|
try {
|
|
187
257
|
unlinkSync(lockPath)
|
|
188
258
|
} catch {
|
package/src/git-scope.ts
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { execFile } from 'node:child_process'
|
|
25
|
-
import {
|
|
25
|
+
import { lstatSync, realpathSync } from 'node:fs'
|
|
26
26
|
import { join, sep } from 'node:path'
|
|
27
27
|
|
|
28
28
|
/** A resolved changed-only scope result. */
|
|
@@ -113,6 +113,15 @@ export function parseChangedFiles(stdout: string): string[] {
|
|
|
113
113
|
* Keep only entries that resolve to an existing regular file under `root`.
|
|
114
114
|
* Traversal-safe: rejects absolute paths and any relative path that would
|
|
115
115
|
* escape `root` via `..` (resolved against the root before stat).
|
|
116
|
+
*
|
|
117
|
+
* Symlink handling: `statSync` (and `existsSync`) FOLLOW symlinks, so a git
|
|
118
|
+
* entry that is a link to a file OUTSIDE the project would pass a path-only
|
|
119
|
+
* containment check and enter the changed-only scope. Such a file is then
|
|
120
|
+
* rejected by the evidence gate (realpath out of bounds → `file_not_found`),
|
|
121
|
+
* so every finding on it would fail closed as `EVIDENCE_VIOLATION` forever —
|
|
122
|
+
* a permanent false positive for repos with dotfile or shared-config links.
|
|
123
|
+
* `lstatSync` distinguishes the link itself; a link whose REAL target escapes
|
|
124
|
+
* `root` is dropped here instead.
|
|
116
125
|
*/
|
|
117
126
|
export function filterExistingFiles(root: string, files: string[]): string[] {
|
|
118
127
|
const out: string[] = []
|
|
@@ -122,7 +131,17 @@ export function filterExistingFiles(root: string, files: string[]): string[] {
|
|
|
122
131
|
const candidate = join(root, rel)
|
|
123
132
|
if (!candidate.startsWith(rootPrefix) && candidate !== root) continue
|
|
124
133
|
try {
|
|
125
|
-
|
|
134
|
+
const st = lstatSync(candidate)
|
|
135
|
+
if (st.isFile()) {
|
|
136
|
+
out.push(rel)
|
|
137
|
+
continue
|
|
138
|
+
}
|
|
139
|
+
if (!st.isSymbolicLink()) continue
|
|
140
|
+
// A link whose real target stays inside the project is a legitimate,
|
|
141
|
+
// reviewable file; one that escapes is excluded from scope (fail-closed
|
|
142
|
+
// at the source instead of as a permanent evidence violation).
|
|
143
|
+
const real = realpathSync(candidate)
|
|
144
|
+
if (real === root || real.startsWith(rootPrefix)) out.push(rel)
|
|
126
145
|
} catch {
|
|
127
146
|
// Unreadable entry (e.g. a broken symlink) is not a valid review target.
|
|
128
147
|
continue
|
package/src/index.ts
CHANGED
|
@@ -2,16 +2,18 @@
|
|
|
2
2
|
* iterate-plugin — dsh plugin for the iterate autonomous closed-loop workflow
|
|
3
3
|
*
|
|
4
4
|
* Architecture:
|
|
5
|
-
* - The plugin registers
|
|
5
|
+
* - The plugin registers 18 tools (14 original + 3 v3.0 quality command center
|
|
6
|
+
* tools + 1 v3.9 report export)
|
|
6
7
|
* Original: config, validate, decision-log, context, review, triage, fix, diff,
|
|
7
8
|
* rollback, checkpoint, status, history, prune, transcript
|
|
8
9
|
* v3.0: experience, quality_gate, defense_events
|
|
10
|
+
* v3.9: report
|
|
9
11
|
* - The plugin injects a system prompt section teaching the iterate workflow pattern
|
|
10
12
|
* - The model (prompted by the skill) writes a workflow script using dsh's `workflow` tool
|
|
11
13
|
* - The workflow script uses `agent()` / `parallel()` / `phase()` / `log()` to orchestrate
|
|
12
|
-
* - Subagents use the
|
|
14
|
+
* - Subagents use the 18 tools to do real work (read config, run validation, log decisions,
|
|
13
15
|
* review, triage, apply/rollback/fixing, checkpoint, status, history, prune, transcript,
|
|
14
|
-
* query experience bank, check quality gates, query defense events)
|
|
16
|
+
* query experience bank, check quality gates, query defense events, export a report)
|
|
15
17
|
* - A `tools/pre-execute` hook gates destructive iterate calls behind human approval
|
|
16
18
|
* (F8 observatory approval policy: ask / deny / allow).
|
|
17
19
|
*
|
|
@@ -23,7 +25,7 @@
|
|
|
23
25
|
*
|
|
24
26
|
* Key files:
|
|
25
27
|
* - src/index.ts — Plugin entry: register tools + inject skill prompt
|
|
26
|
-
* - src/tools/ —
|
|
28
|
+
* - src/tools/ — 18 tool implementations (14 original + 3 v3.0 + 1 v3.9)
|
|
27
29
|
* - src/config-loader.ts — YAML config loading
|
|
28
30
|
* - src/types.ts — Shared types
|
|
29
31
|
*/
|
|
@@ -43,6 +45,7 @@ import { registerTranscriptTool } from './tools/transcript.ts'
|
|
|
43
45
|
import { registerExperienceBankTool } from './tools/experience-bank.ts'
|
|
44
46
|
import { registerQualityGateTool } from './tools/quality-gate.ts'
|
|
45
47
|
import { registerDefenseEventsTool } from './tools/defense-events.ts'
|
|
48
|
+
import { registerReportTool } from './tools/report.ts'
|
|
46
49
|
import { registerSessionHooks } from './session-hooks.ts'
|
|
47
50
|
import { registerLiveCapture } from './live.ts'
|
|
48
51
|
import { installWorkingDirectoryContext } from './working-directory.ts'
|
|
@@ -92,6 +95,8 @@ export function apply(ctx: Context): void {
|
|
|
92
95
|
['iterate_experience', () => registerExperienceBankTool(ctx)],
|
|
93
96
|
['iterate_quality_gate', () => registerQualityGateTool(ctx)],
|
|
94
97
|
['iterate_defense_events', () => registerDefenseEventsTool(ctx)],
|
|
98
|
+
// v3.9: PR-ready / CI-ready report export
|
|
99
|
+
['iterate_report', () => registerReportTool(ctx)],
|
|
95
100
|
]
|
|
96
101
|
for (const [toolName, register] of toolRegistrations) {
|
|
97
102
|
try {
|