iterate-plugin 3.5.4 → 3.5.6

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.
@@ -81,6 +81,7 @@ import {
81
81
  latestPhase,
82
82
  SEVERITY_LABEL,
83
83
  SEVERITY_COLOR,
84
+ stoppedReasonLabel,
84
85
  } from '../../lib/parse.js'
85
86
 
86
87
  // ─── Module contract ─────────────────────────────────────────────────────────
@@ -737,17 +738,6 @@ function severityLabel(severity: string | undefined): string {
737
738
  return SEVERITY_LABEL[coerceSeverity(severity)]
738
739
  }
739
740
 
740
- /** Human label for WHY a finished run stopped (transcript manifest). */
741
- function stoppedReasonLabel(reason: string | null | undefined): string {
742
- switch (reason) {
743
- case 'converged': return '已结束 · 已收敛(无新发现)'
744
- case 'max_rounds_reached': return '已结束 · 达到轮数上限'
745
- case 'aborted_by_validation': return '已结束 · 验证失败后回滚停止'
746
- case 'aborted_by_config': return '已结束 · 验证命令不在白名单(配置需修复)'
747
- default: return reason ? `已结束 · ${reason}` : '已结束'
748
- }
749
- }
750
-
751
741
  /** Badge text for the run's live/finished state, annotated with the stop reason. */
752
742
  function runStatusText(live: boolean, manifest: ObsManifest | null | undefined): string {
753
743
  if (live) return '运行中'
@@ -785,6 +775,7 @@ let slotsSvc: SlotsService | undefined = undefined
785
775
  let themeSvc: ThemeService | undefined = undefined
786
776
  let storage: ReturnType<typeof createStorage> | null = null
787
777
  let themeDisposer: (() => void) | null = null
778
+ let themeListener: (() => void) | null = null
788
779
  let themeEnabled = true
789
780
  const roundPulseListeners: Array<(payload: { round: number; converged: boolean }) => void> = []
790
781
 
@@ -814,6 +805,33 @@ function clearThemeSkin(): void {
814
805
  }
815
806
  }
816
807
 
808
+ /**
809
+ * Register the theme/change hook exactly once per client lifecycle.
810
+ * The listener is tracked so a re-`apply()` (hot reload / re-mount) never
811
+ * stacks a second copy — every new registration first drops the previous
812
+ * one, and `ctx.effect` pairs it off when the client unmounts.
813
+ */
814
+ function registerThemeListener(ctx: ClientContext): void {
815
+ if (typeof ctx.on !== 'function') return
816
+ if (themeListener) {
817
+ try { themeListener() } catch { /* noop */ }
818
+ themeListener = null
819
+ }
820
+ const off = ctx.on('theme/change', () => {
821
+ if (themeEnabled) applyThemeSkin()
822
+ })
823
+ // `ctx.on` may return void (no unsubscribe support) — only track a callback.
824
+ themeListener = typeof off === 'function' ? off : null
825
+ if (typeof ctx.effect === 'function') {
826
+ ctx.effect(() => {
827
+ if (themeListener) {
828
+ try { themeListener() } catch { /* noop */ }
829
+ themeListener = null
830
+ }
831
+ })
832
+ }
833
+ }
834
+
817
835
  /** Persist + apply the theme preference. */
818
836
  function setThemeEnabled(enabled: boolean): void {
819
837
  themeEnabled = enabled
@@ -2826,15 +2844,20 @@ export function apply(ctx: ClientContext): void {
2826
2844
  themeEnabled = savedTheme === null ? true : savedTheme === '1'
2827
2845
  if (themeEnabled) applyThemeSkin()
2828
2846
 
2829
- // 3. Inject styles (independent of slots / React availability).
2847
+ // 3. Inject styles (independent of slots / React availability). Guarded so a
2848
+ // re-`apply()` never appends a duplicate <style>: the tag is keyed by a
2849
+ // `dataset.iterateLock` marker instead of relying on the CSS content.
2830
2850
  if (typeof document !== 'undefined' && document.createElement && document.head) {
2831
- const style = document.createElement('style')
2832
- style.dataset.plugin = PLUGIN_TAG
2833
- style.dataset.pluginCss = 'iterate-main'
2834
- style.textContent = ITERATE_CSS
2835
- document.head.appendChild(style)
2836
- if (typeof ctx.effect === 'function') {
2837
- ctx.effect(() => { try { style.remove() } catch { /* noop */ } })
2851
+ if (!document.head.querySelector('style[data-iterate-lock="1"]')) {
2852
+ const style = document.createElement('style')
2853
+ style.dataset.iterateLock = '1'
2854
+ style.dataset.plugin = PLUGIN_TAG
2855
+ style.dataset.pluginCss = 'iterate-main'
2856
+ style.textContent = ITERATE_CSS
2857
+ document.head.appendChild(style)
2858
+ if (typeof ctx.effect === 'function') {
2859
+ ctx.effect(() => { try { style.remove() } catch { /* noop */ } })
2860
+ }
2838
2861
  }
2839
2862
  }
2840
2863
 
@@ -2889,9 +2912,7 @@ export function apply(ctx: ClientContext): void {
2889
2912
  }
2890
2913
 
2891
2914
  // 6: keep theme toggle state consistent across theme/change events.
2892
- if (typeof ctx.on === 'function') {
2893
- ctx.on('theme/change', () => {
2894
- if (themeEnabled) applyThemeSkin()
2895
- })
2896
- }
2915
+ // Registered through registerThemeListener so repeated apply() and client
2916
+ // unmounts never leak a second listener.
2917
+ registerThemeListener(ctx)
2897
2918
  }
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from 'node:fs'
1
+ import { existsSync, readFileSync, realpathSync } from 'node:fs'
2
2
  import { homedir } from 'node:os'
3
3
  import { join, resolve, sep } from 'node:path'
4
4
  import yaml from 'js-yaml'
@@ -12,7 +12,10 @@ export function loadConfig(projectRoot: string): IterateConfig | null {
12
12
  try {
13
13
  const content = readFileSync(join(projectRoot, 'iterate.config.yaml'), 'utf-8')
14
14
  const parsed = yaml.load(content) as Record<string, unknown>
15
- if (!parsed || typeof parsed !== 'object') return null
15
+ // A YAML sequence root (e.g. a list of findings or a `- foo` file) must
16
+ // not masquerade as a config object — treating it as one would merge its
17
+ // indices into the config (config bomb). Reject it, not coerce it.
18
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
16
19
  return parsed as unknown as IterateConfig
17
20
  } catch {
18
21
  return null
@@ -158,7 +161,7 @@ export function flattenCommands(
158
161
  */
159
162
  export function validateConfig(config: unknown): string[] {
160
163
  const errors: string[] = []
161
- if (!config || typeof config !== 'object') {
164
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
162
165
  errors.push('root')
163
166
  return errors
164
167
  }
@@ -206,13 +209,33 @@ export type ProjectRootResult = { ok: true; root: string } | { ok: false; reason
206
209
  * the DSH runtime injects per-session env into tool sub-processes).
207
210
  */
208
211
  export function resolveProjectRoot(input?: string, sessionCwd?: string): ProjectRootResult {
209
- const raw = (input ?? '').trim()
212
+ // A non-string input (number, array, …) from a hostile tool invocation must
213
+ // be treated as "no explicit path" instead of crashing on `.trim()`.
214
+ const raw = (typeof input === 'string' ? input : '').trim()
210
215
  // A NUL byte can never name a real path and makes `resolve()` (and every
211
216
  // downstream fs call) throw — treat it as unsafe input, not a throw path.
212
217
  if (raw.includes('\0')) {
213
218
  return { ok: false, reason: 'Refusing project root containing NUL bytes.' }
214
219
  }
215
- const root = raw ? resolve(raw) : resolve(effectiveCwd(sessionCwd))
220
+ const rootLexical = raw ? resolve(raw) : resolve(effectiveCwd(sessionCwd))
221
+ // Collapse symlinks (documented contract: "collapsing `..` and symlinks") —
222
+ // a path through a symlinked directory must resolve to its REAL location so
223
+ // two aliases of the same project share one `.iterate/` state, and tools can
224
+ // never read outside the resolved root via a link. Only when the path
225
+ // exists: a not-yet-created target (dirs created through the tools) keeps
226
+ // the lexical path so the caller can still write into it. The filesystem
227
+ // root check runs against the NORMALIZED path, so a symlink to `/` is
228
+ // refused too.
229
+ let root = rootLexical
230
+ try {
231
+ if (existsSync(rootLexical)) {
232
+ const real = realpathSync(rootLexical)
233
+ if (real) root = real
234
+ }
235
+ } catch {
236
+ // realpath can throw on a broken link or a permission fence — keep the
237
+ // lexical path (existing behavior) rather than failing the resolution.
238
+ }
216
239
  if (!root || root === sep) {
217
240
  return { ok: false, reason: 'Refusing filesystem root as project root.' }
218
241
  }
@@ -10,8 +10,8 @@
10
10
  * config, always back up before writing, roll back on failure.
11
11
  */
12
12
 
13
- import { copyFileSync, existsSync, readFileSync, rmSync } from 'node:fs'
14
- import { join } from 'node:path'
13
+ import { copyFileSync, existsSync, readFileSync, readdirSync, rmSync } from 'node:fs'
14
+ import { basename, dirname, join } from 'node:path'
15
15
  import yaml from 'js-yaml'
16
16
  import { writeTextAtomic } from './atomic-fs.ts'
17
17
 
@@ -23,6 +23,49 @@ export function configBackupSuffix(now = new Date()): string {
23
23
  return now.toISOString().replace(/[:.]/g, '-')
24
24
  }
25
25
 
26
+ /** Upper bound on configurable iteration rounds (config bomb guard). */
27
+ export const MAX_MAX_ROUNDS = 100
28
+
29
+ /** Upper bound on `atomic.max_lines` (a single fix never needs more). */
30
+ export const MAX_ATOMIC_MAX_LINES = 10_000
31
+
32
+ /** Upper bound on `atomic.max_adjacent_methods`. */
33
+ export const MAX_MAX_ADJACENT_METHODS = 200
34
+
35
+ /** Upper bound on `reviewer.scope_chunk_size` (files per reviewer task batch). */
36
+ export const MAX_SCOPE_CHUNK_SIZE = 1000
37
+
38
+ /** Keep at most this many timestamped config backups (older ones are removed). */
39
+ export const MAX_CONFIG_BACKUPS = 5
40
+
41
+ /**
42
+ * Bound the timestamped config backups: after a fresh one is written, delete
43
+ * every older `config.bak-*` file beyond the newest `keep`. Best-effort — a
44
+ * filesystem failure here must never fail the write that just succeeded.
45
+ * @returns the absolute paths of the backups that were removed.
46
+ */
47
+ export function pruneOldConfigBackups(
48
+ configPath: string,
49
+ keep = MAX_CONFIG_BACKUPS,
50
+ ): string[] {
51
+ const removed: string[] = []
52
+ try {
53
+ const dir = dirname(configPath)
54
+ const prefix = `${basename(configPath)}.bak-`
55
+ const matches = existsSync(dir)
56
+ ? readdirSync(dir).filter((f) => f.startsWith(prefix)).sort()
57
+ : []
58
+ const doomed = matches.slice(0, Math.max(0, matches.length - keep))
59
+ for (const f of doomed) {
60
+ rmSync(join(dir, f), { force: true })
61
+ removed.push(join(dir, f))
62
+ }
63
+ } catch {
64
+ // Best-effort cleanup — never surface a cleanup failure.
65
+ }
66
+ return removed
67
+ }
68
+
26
69
  /**
27
70
  * Validate a partial config update.
28
71
  * Returns an array of error strings (empty when the update is valid).
@@ -45,8 +88,40 @@ export function validateConfigUpdates(updates: Record<string, unknown>): string[
45
88
  }
46
89
  }
47
90
  if ('max_rounds' in updates) {
48
- if (typeof updates.max_rounds !== 'number' || !Number.isInteger(updates.max_rounds) || updates.max_rounds < 1) {
49
- errors.push('updates.max_rounds must be a positive integer')
91
+ if (
92
+ typeof updates.max_rounds !== 'number' || !Number.isInteger(updates.max_rounds) ||
93
+ updates.max_rounds < 1 || updates.max_rounds > MAX_MAX_ROUNDS
94
+ ) {
95
+ errors.push(`updates.max_rounds must be an integer between 1 and ${MAX_MAX_ROUNDS}`)
96
+ }
97
+ }
98
+ if ('reasoning_effort' in updates) {
99
+ if (
100
+ updates.reasoning_effort !== undefined &&
101
+ updates.reasoning_effort !== 'low' &&
102
+ updates.reasoning_effort !== 'medium' &&
103
+ updates.reasoning_effort !== 'high'
104
+ ) {
105
+ errors.push('updates.reasoning_effort must be "low", "medium", or "high"')
106
+ }
107
+ }
108
+ if ('reviewer' in updates) {
109
+ const rv = updates.reviewer as Record<string, unknown> | undefined
110
+ if (!rv || typeof rv !== 'object') {
111
+ errors.push('updates.reviewer must be an object')
112
+ } else {
113
+ for (const boolKey of ['output_schema_validation', 'evidence_validation', 'coverage_validation'] as const) {
114
+ if (rv[boolKey] !== undefined && typeof rv[boolKey] !== 'boolean') {
115
+ errors.push(`updates.reviewer.${boolKey} must be a boolean`)
116
+ }
117
+ }
118
+ if (
119
+ rv.scope_chunk_size !== undefined &&
120
+ (typeof rv.scope_chunk_size !== 'number' || !Number.isInteger(rv.scope_chunk_size) ||
121
+ rv.scope_chunk_size < 1 || rv.scope_chunk_size > MAX_SCOPE_CHUNK_SIZE)
122
+ ) {
123
+ errors.push(`updates.reviewer.scope_chunk_size must be an integer between 1 and ${MAX_SCOPE_CHUNK_SIZE}`)
124
+ }
50
125
  }
51
126
  }
52
127
  if ('review' in updates) {
@@ -62,11 +137,11 @@ export function validateConfigUpdates(updates: Record<string, unknown>): string[
62
137
  if (!a || typeof a !== 'object') {
63
138
  errors.push('updates.atomic must be an object')
64
139
  } else {
65
- if (a.max_lines !== undefined && (typeof a.max_lines !== 'number' || !Number.isInteger(a.max_lines) || a.max_lines < 1)) {
66
- errors.push('updates.atomic.max_lines must be a positive integer')
140
+ if (a.max_lines !== undefined && (typeof a.max_lines !== 'number' || !Number.isInteger(a.max_lines) || a.max_lines < 1 || a.max_lines > MAX_ATOMIC_MAX_LINES)) {
141
+ errors.push(`updates.atomic.max_lines must be an integer between 1 and ${MAX_ATOMIC_MAX_LINES}`)
67
142
  }
68
- if (a.max_adjacent_methods !== undefined && (typeof a.max_adjacent_methods !== 'number' || a.max_adjacent_methods < 0)) {
69
- errors.push('updates.atomic.max_adjacent_methods must be a non-negative number')
143
+ if (a.max_adjacent_methods !== undefined && (typeof a.max_adjacent_methods !== 'number' || a.max_adjacent_methods < 0 || a.max_adjacent_methods > MAX_MAX_ADJACENT_METHODS)) {
144
+ errors.push(`updates.atomic.max_adjacent_methods must be a number between 0 and ${MAX_MAX_ADJACENT_METHODS}`)
70
145
  }
71
146
  }
72
147
  }
@@ -168,7 +243,10 @@ export function readRawConfig(configPath: string): Record<string, unknown> {
168
243
  } catch {
169
244
  throw new Error('existing iterate.config.yaml is not a valid YAML mapping')
170
245
  }
171
- if (!parsed || typeof parsed !== 'object') {
246
+ // A YAML sequence root (array) must not masquerade as a config object —
247
+ // `typeof [] === 'object'`, so the presence check alone would accept a
248
+ // config file that is actually a list. Writing over it would destroy data.
249
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
172
250
  throw new Error('existing iterate.config.yaml is not a valid YAML mapping')
173
251
  }
174
252
  return parsed as Record<string, unknown>
@@ -207,5 +285,9 @@ export function writeConfigFile(
207
285
  return { ok: false, error: `failed to write config: ${String(err)}${rollbackError}` }
208
286
  }
209
287
 
288
+ // Success: bound the accumulation of timestamped backups so a long-lived
289
+ // project never collects an unbounded pile of config snapshots.
290
+ if (backupPath) pruneOldConfigBackups(configPath)
291
+
210
292
  return { ok: true, backupPath }
211
293
  }
package/src/evidence.ts CHANGED
@@ -115,7 +115,11 @@ export function verifyLineBounds(
115
115
  if (line === undefined || line === null || line === WHOLE_FILE_LINE) {
116
116
  return { inBounds: true, lineTotal }
117
117
  }
118
- if (line < 1) return { inBounds: false, lineTotal }
118
+ // Only a positive INTEGER is a valid line anchor. A fractional line (e.g.
119
+ // 3.7 from a bypassed schema) or a negative value is out of bounds — a
120
+ // fractional line "within" the file would otherwise be accepted and poison
121
+ // the evidence trail with a location nothing can jump to.
122
+ if (line < 1 || !Number.isInteger(line)) return { inBounds: false, lineTotal }
119
123
  return { inBounds: line <= lineTotal, lineTotal }
120
124
  }
121
125
 
@@ -125,8 +129,36 @@ export function verifyFinding(
125
129
  input: Locatable,
126
130
  opts: { readSet?: Set<string> } = {},
127
131
  ): FindingEvidence {
128
- const relFile = input.file ?? ''
132
+ // A null element / non-object finding (hostile or model-authored JSON) must
133
+ // be reported as poisoned evidence, never crash `path.resolve` downstream.
134
+ if (!input || typeof input !== 'object') {
135
+ return {
136
+ file: '',
137
+ line: null,
138
+ lineTotal: null,
139
+ resolvedPath: null,
140
+ verified: false,
141
+ error: 'file_not_found',
142
+ }
143
+ }
144
+ // A non-string `file` (number, object, …) would make `resolve` throw
145
+ // ERR_INVALID_ARG_TYPE — coerce to "" so it fails closed as not-found.
146
+ const relFile = typeof input.file === 'string' ? input.file : ''
129
147
  const line = typeof input.line === 'number' ? input.line : null
148
+
149
+ // "" resolves to the project ROOT (resolve(root, '') === root), which then
150
+ // trips the not-a-regular-file branch as `line_out_of_range` — but there is
151
+ // no file reference at all. Report it as `file_not_found` upfront.
152
+ if (relFile === '') {
153
+ return {
154
+ file: '',
155
+ line,
156
+ lineTotal: null,
157
+ resolvedPath: null,
158
+ verified: false,
159
+ error: 'file_not_found',
160
+ }
161
+ }
130
162
  const resolved = resolveWithin(root, relFile)
131
163
 
132
164
  if (resolved === null || !existsSync(resolved)) {
package/src/git-scope.ts CHANGED
@@ -90,7 +90,10 @@ function decodeQuotedPath(content: string): string {
90
90
 
91
91
  export function parseChangedFiles(stdout: string): string[] {
92
92
  if (stdout.includes('\0')) {
93
- return stdout.split('\0').map((s) => s.trim()).filter((s) => s.length > 0)
93
+ // NUL mode: git emits exact names with no surrounding whitespace, so any
94
+ // trim would CORRUPT a filename that legitimately starts/ends with spaces
95
+ // (git supports such names). Split and drop only the trailing empty entry.
96
+ return stdout.split('\0').filter((s) => s.length > 0)
94
97
  }
95
98
  return stdout
96
99
  .split('\n')
@@ -152,12 +155,19 @@ export function decideScope(changedFiles: string[]): {
152
155
  export function runGit(
153
156
  args: string[],
154
157
  cwd: string,
158
+ opts: { signal?: AbortSignal } = {},
155
159
  ): Promise<{ ok: boolean; stdout: string; stderr: string; exitCode: number }> {
156
160
  return new Promise((resolve) => {
157
161
  execFile(
158
162
  'git',
159
163
  args,
160
- { cwd, timeout: 30_000, maxBuffer: 10 * 1024 * 1024, env: { ...process.env, PAGER: 'cat' } },
164
+ {
165
+ cwd,
166
+ timeout: 30_000,
167
+ maxBuffer: 10 * 1024 * 1024,
168
+ env: { ...process.env, PAGER: 'cat' },
169
+ signal: opts.signal,
170
+ },
161
171
  (error, stdout, stderr) => {
162
172
  const exitCode = error ? (typeof error.code === 'number' ? error.code : 1) : 0
163
173
  resolve({ ok: exitCode === 0, stdout: stdout ?? '', stderr: stderr ?? '', exitCode })
@@ -175,6 +185,7 @@ export function runGit(
175
185
  export async function resolveChangedFiles(
176
186
  root: string,
177
187
  targetBranch: string,
188
+ opts: { signal?: AbortSignal } = {},
178
189
  ): Promise<GitScopeResult> {
179
190
  // Option-injection guard: a branch name starting with '-' would be parsed by
180
191
  // git as an option (e.g. --output=...), not a ref. Reject it outright.
@@ -188,7 +199,7 @@ export async function resolveChangedFiles(
188
199
  }
189
200
  // -z: NUL-delimited names — machine-safe for any filename (spaces, quotes,
190
201
  // non-ASCII), and never confused with option-like content.
191
- const { ok, stdout, stderr } = await runGit(['diff', '--name-only', '-z', targetBranch, '--'], root)
202
+ const { ok, stdout, stderr } = await runGit(['diff', '--name-only', '-z', targetBranch, '--'], root, opts)
192
203
  if (!ok) {
193
204
  const reason = stderr.trim() || `git diff --name-only -z ${targetBranch} failed`
194
205
  return { scope: 'full', changedFiles: [], fallbackToFull: true, error: reason }
package/src/jobs.ts CHANGED
@@ -39,6 +39,20 @@ interface JobsLike {
39
39
  }): string
40
40
  }
41
41
 
42
+ /**
43
+ * Read `ctx.jobs` defensively: the dsh plugin context can be a Proxy whose
44
+ * `jobs` getter throws (or is missing) when no job registry is present. A
45
+ * Proxy throw on property access must degrade to "no jobs", never crash the
46
+ * tool execution we are about to wrap.
47
+ */
48
+ function safeJobs(ctx: unknown): JobsLike | undefined {
49
+ try {
50
+ return (ctx as { jobs?: JobsLike } | undefined)?.jobs
51
+ } catch {
52
+ return undefined
53
+ }
54
+ }
55
+
42
56
  /**
43
57
  * Run `fn` wrapped in a dsh background job, settling it completed/failed
44
58
  * with the execution's outcome. When the host exposes no job registry (or
@@ -57,7 +71,7 @@ export async function runWithJob<T>(
57
71
  label: string,
58
72
  fn: () => Promise<T> | T,
59
73
  ): Promise<{ result: T; jobId: string | null }> {
60
- const jobs = (ctx as { jobs?: JobsLike } | undefined)?.jobs
74
+ const jobs = safeJobs(ctx)
61
75
  if (!jobs || typeof jobs.start !== 'function') {
62
76
  return { result: await fn(), jobId: null }
63
77
  }
package/src/live.ts CHANGED
@@ -68,6 +68,47 @@ export function liveFilePath(projectRoot: string): string {
68
68
  return join(projectRoot, '.iterate', 'transcript-live.ndjson')
69
69
  }
70
70
 
71
+ /**
72
+ * Module-level in-process queue serializing live-feed writes. The trim path
73
+ * (read → rewrite → append) is a read-modify-write: two concurrent tool
74
+ * results could interleave and one's rewrite would silently drop the other's
75
+ * freshly-appended line. Chaining every append onto one tail makes the whole
76
+ * read+rewrite+append atomic within the process (the only realistic mode for
77
+ * the plugin); cross-process racing remains a theoretical hazard, but the
78
+ * cap-trim makes a lost line a cosmetic issue, not a correctness one.
79
+ */
80
+ let liveWriteQueue: Promise<void> = Promise.resolve()
81
+
82
+ function enqueueLiveWrite(task: () => Promise<void>): Promise<void> {
83
+ const next = liveWriteQueue.then(task, task)
84
+ liveWriteQueue = next.catch(() => {})
85
+ return next
86
+ }
87
+
88
+ /** Append one activity record to the project's live feed (byte-capped). */
89
+ export function appendLive(projectRoot: string, entry: LiveActivityEntry): Promise<void> {
90
+ return enqueueLiveWrite(async () => {
91
+ const file = liveFilePath(projectRoot)
92
+ const line = JSON.stringify(entry) + '\n'
93
+ await mkdir(join(projectRoot, '.iterate'), { recursive: true })
94
+ // Amortized O(1): only read+rewrite when the file has grown past the cap.
95
+ try {
96
+ const st = await stat(file).catch(() => null)
97
+ if (st && st.size > LIVE_MAX_BYTES) {
98
+ const raw = await readFile(file, 'utf-8')
99
+ const lines = raw.split('\n').filter(Boolean)
100
+ const tail = lines.slice(-LIVE_MAX_ENTRIES)
101
+ // Atomic rewrite (unique temp + rename) so a crash mid-trim never
102
+ // truncates the live feed; the temp file is prunable by iterate_prune.
103
+ await writeTextAtomicAsync(file, tail.join('\n') + '\n')
104
+ }
105
+ await appendFile(file, line, 'utf-8')
106
+ } catch {
107
+ // Fire-and-forget: never let live capture break a tool call.
108
+ }
109
+ })
110
+ }
111
+
71
112
  /** Resolve the project root a tool execution belongs to, if any. */
72
113
  function projectRootOf(exec: ToolExecution): string | null {
73
114
  const cwd = exec.agent?.session?.header?.cwd
@@ -130,28 +171,6 @@ export function classifyTool(
130
171
  return { ts: new Date().toISOString(), type, tool: name, target }
131
172
  }
132
173
 
133
- /** Append one activity record to the project's live feed (byte-capped). */
134
- export async function appendLive(projectRoot: string, entry: LiveActivityEntry): Promise<void> {
135
- const file = liveFilePath(projectRoot)
136
- const line = JSON.stringify(entry) + '\n'
137
- await mkdir(join(projectRoot, '.iterate'), { recursive: true })
138
- // Amortized O(1): only read+rewrite when the file has grown past the cap.
139
- try {
140
- const st = await stat(file).catch(() => null)
141
- if (st && st.size > LIVE_MAX_BYTES) {
142
- const raw = await readFile(file, 'utf-8')
143
- const lines = raw.split('\n').filter(Boolean)
144
- const tail = lines.slice(-LIVE_MAX_ENTRIES)
145
- // Atomic rewrite (unique temp + rename) so a crash mid-trim never
146
- // truncates the live feed; the temp file is prunable by iterate_prune.
147
- await writeTextAtomicAsync(file, tail.join('\n') + '\n')
148
- }
149
- await appendFile(file, line, 'utf-8')
150
- } catch {
151
- // Fire-and-forget: never let live capture break a tool call.
152
- }
153
- }
154
-
155
174
  /** Read the live feed (newest first), capped at the last LIVE_MAX_ENTRIES. */
156
175
  export async function readLive(projectRoot: string): Promise<LiveActivityEntry[]> {
157
176
  const file = liveFilePath(projectRoot)
@@ -184,7 +184,7 @@ export function metaReviewReport(report: ReviewReport): MetaReviewResult {
184
184
  `byDimension sums to ${dimSum}, but totalFindings is ${total}.`,
185
185
  )
186
186
  }
187
- const invalidDim = findings.find((f) => !dimensions.includes(f?.dimension))
187
+ const invalidDim = findings.find((f) => !!f && !dimensions.includes(f?.dimension))
188
188
  if (invalidDim) {
189
189
  add(
190
190
  'DIMENSION_UNKNOWN',
@@ -233,7 +233,11 @@ export function metaReviewReport(report: ReviewReport): MetaReviewResult {
233
233
  : null
234
234
  const lastRoundNew =
235
235
  lastRecordedRound !== null && lastRecordedRound > 0
236
- ? Number(findingsByRound[lastRecordedRound - 1] ?? 0)
236
+ // Over-cap rounds are FOLDED by aggregateRounds into the final slot, so a
237
+ // reported round number may exceed the array length — read the SAME
238
+ // clamped index buildReviewReport/computeConvergence use, or a real
239
+ // over-cap round would be mis-read as 0 new (flag mismatch).
240
+ ? Number(findingsByRound[Math.min(lastRecordedRound, findingsByRound.length) - 1] ?? 0)
237
241
  : null
238
242
  const expectedConverged = lastRoundNew === 0
239
243
  if (report.convergence?.converged !== expectedConverged) {
@@ -364,7 +368,7 @@ export function buildFinalReviewReport(
364
368
  // surfaced it (best-effort; report rounds carry it).
365
369
  for (const r of report.rounds ?? []) {
366
370
  const matched = (r.findings ?? []).some(
367
- (fnd) => fnd.file === violation.file && fnd.line === violation.line,
371
+ (fnd) => !!fnd && fnd?.file === violation.file && fnd?.line === violation.line,
368
372
  )
369
373
  if (matched) {
370
374
  roundHint = ` (round ${r.round})`
@@ -72,8 +72,12 @@ function normalizePath(path: string): string {
72
72
  for (const part of cleaned.split(SEP)) {
73
73
  if (part === '' || part === '.') continue
74
74
  if (part === '..') {
75
- if (parts.length > 0) parts.pop()
76
- else parts.push(part) // no root segment to pop — keep the leading '..'
75
+ // Mirrors Python `os.path.normpath`: a leading `..` is preserved and a
76
+ // second `..` is NEVER collapsed into a bare `/` away — otherwise a
77
+ // traversal like `../../evil` folds into the single segment `evil` and
78
+ // the escape becomes undetectable downstream.
79
+ if (parts.length > 0 && parts[parts.length - 1] !== '..') parts.pop()
80
+ else parts.push(part)
77
81
  continue
78
82
  }
79
83
  parts.push(part)