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/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
- if (lang === 'en') return en[reason] ?? (reason ? `Done \u00b7 ${reason}` : 'Done')
2285
- return zh[reason] ?? (reason ? `已结束 · ${reason}` : '已结束')
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 时迭代不被保护:每轮 validate 拿到空结果会被当作',
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
- if (f.status === 'fixed') row.fixed += 1
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.7.0",
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-skill.git",
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-skill/tree/main/harness/iterate-plugin",
11
+ "homepage": "https://github.com/jingzhao-l/iterate-plugin#readme",
13
12
  "bugs": {
14
- "url": "https://github.com/jingzhao-l/iterate-skill/issues"
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/dsh-agent": "0.2.1-alpha.2",
94
- "@deepseek-ai/dsh-jobs": "0.2.1-alpha.2",
95
- "@deepseek-ai/dsh-session": "0.2.1-alpha.2",
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
  }
@@ -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 -)',
@@ -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 the triage panel. Everything else keeps
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 = doc.querySelector('[data-iterate="triage"]')
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
- const DEFAULT_WAIT_MS = 5000
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
- * Returns a release function: always callable, idempotent, never throws.
87
- * A no-op (but callable) release is returned when the lock directory cannot be
88
- * created or the wait times out — callers then proceed unlocked, which is the
89
- * historical best-effort behavior, rather than failing the operation.
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 acquireProjectLock(
108
+ export function acquireProjectLockChecked(
92
109
  projectRoot: string,
93
110
  name: string,
94
111
  options: LockOptions = {},
95
- ): () => void {
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: stamp the holder pid, then release once on return.
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
- export function makeLockRelease(lockPath: string): () => void {
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 { existsSync, statSync } from 'node:fs'
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
- if (existsSync(candidate) && statSync(candidate).isFile()) out.push(rel)
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 17 tools (14 original + 3 v3.0 quality command center tools)
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 17 tools to do real work (read config, run validation, log decisions,
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/ — 17 tool implementations (14 original + 3 v3.0)
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 {