iterate-plugin 3.5.4 → 3.5.5

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/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',
@@ -364,7 +364,7 @@ export function buildFinalReviewReport(
364
364
  // surfaced it (best-effort; report rounds carry it).
365
365
  for (const r of report.rounds ?? []) {
366
366
  const matched = (r.findings ?? []).some(
367
- (fnd) => fnd.file === violation.file && fnd.line === violation.line,
367
+ (fnd) => !!fnd && fnd?.file === violation.file && fnd?.line === violation.line,
368
368
  )
369
369
  if (matched) {
370
370
  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)
package/src/review.ts CHANGED
@@ -39,17 +39,18 @@ export const SEVERITY_RANK: Record<ReviewFinding['severity'], number> = {
39
39
  export function sortFindings(findings: ReviewFinding[]): ReviewFinding[] {
40
40
  return [...findings].sort((a, b) => {
41
41
  // Guard against an out-of-spec severity string (e.g. from a model that
42
- // bypassed the schema): treat it as the least severe so NaN never enters
43
- // the comparator and ordering stays deterministic.
44
- const rankA = SEVERITY_RANK[a.severity] ?? SEVERITY_RANK.low
45
- const rankB = SEVERITY_RANK[b.severity] ?? SEVERITY_RANK.low
42
+ // bypassed the schema) AND against a null list element (model-authored
43
+ // JSON): treat both as the least severe so NaN never enters the
44
+ // comparator and ordering stays deterministic.
45
+ const rankA = SEVERITY_RANK[a?.severity] ?? SEVERITY_RANK.low
46
+ const rankB = SEVERITY_RANK[b?.severity] ?? SEVERITY_RANK.low
46
47
  const bySeverity = rankA - rankB
47
48
  if (bySeverity !== 0) return bySeverity
48
49
  // Defensive coercion: `file`/`line` can be wrong-typed when schema
49
50
  // validation is disabled — String()/Number() keep the comparator total.
50
- const byFile = String(a.file ?? '').localeCompare(String(b.file ?? ''))
51
+ const byFile = String(a?.file ?? '').localeCompare(String(b?.file ?? ''))
51
52
  if (byFile !== 0) return byFile
52
- return (Number(a.line) || 0) - (Number(b.line) || 0)
53
+ return (Number(a?.line) || 0) - (Number(b?.line) || 0)
53
54
  })
54
55
  }
55
56
 
@@ -357,10 +357,10 @@ for (let r = startRound; r <= maxRounds; r++) {
357
357
  const byFile = {}
358
358
  atomic.forEach(f => { (byFile[f.file] = byFile[f.file] || []).push(f) })
359
359
  const fixRes = await parallel(Object.keys(byFile).map(file => () => agent(
360
- 'Apply the fixes for ' + file + ' using iterate_fix. For EACH finding in this list, ' +
360
+ 'Apply the fixes for ' + JSON.stringify(file) + ' using iterate_fix. For EACH finding in this list, ' +
361
361
  'read the current file, compute the edited full content (change <= ' + atomicMaxLines + ' lines), and call ' +
362
- 'iterate_fix({ file: "' + file + '", content: <full new file content>, finding: <that finding>, round: ' + r + ' }). ' +
363
- 'Apply the findings IN ORDER. After all fixes, call iterate_diff({ file: "' + file + '" }) to verify the accumulated diff and ' +
362
+ 'iterate_fix({ file: ' + JSON.stringify(file) + ', content: <full new file content>, finding: <that finding>, round: ' + r + ' }). ' +
363
+ 'Apply the findings IN ORDER. After all fixes, call iterate_diff({ file: ' + JSON.stringify(file) + ' }) to verify the accumulated diff and ' +
364
364
  'read its line statistics (lines added/removed). ' +
365
365
  'Findings: ' + JSON.stringify(byFile[file]) + '. Return the array of {id, ok, error, file, linesAdded, linesRemoved} per iterate_fix call ' +
366
366
  '(id/ok required; put the file-wide line stats from iterate_diff on each record, or on the last record and 0 elsewhere).',
@@ -1,4 +1,14 @@
1
- import { appendFileSync, readFileSync, mkdirSync, existsSync } from 'node:fs'
1
+ import {
2
+ appendFileSync,
3
+ readFileSync,
4
+ mkdirSync,
5
+ existsSync,
6
+ openSync,
7
+ writeSync,
8
+ closeSync,
9
+ unlinkSync,
10
+ statSync,
11
+ } from 'node:fs'
2
12
  import { join } from 'node:path'
3
13
  import { defineTool } from '@deepseek-ai/dsh-tools'
4
14
  import type { JsonValue } from '@deepseek-ai/dsh-util-values'
@@ -8,6 +18,109 @@ import type { DecisionLogEntry } from '../types.ts'
8
18
  const LOG_DIR = '.iterate'
9
19
  const LOG_FILE = 'decision-log.jsonl'
10
20
 
21
+ // ─── Cross-process log mutex ────────────────────────────────────────────────
22
+ //
23
+ // The prune rewrite (temp + atomic rename) can silently discard an audit line
24
+ // from a CONCURRENT appender whose fd was opened against the pre-rename inode:
25
+ // the appender writes to the unlinked inode, the post-rewrite re-read sees a
26
+ // "stable" file, and the fresh entry is lost. In-process safety is guaranteed
27
+ // by synchronous I/O, but a second plugin process appending to the same
28
+ // project realises the race. The log is serialized with a tiny advisory lock
29
+ // file (exclusive create, pid stamped, stale-stealable) so `append` and the
30
+ // prune rewrite mutually exclude across processes. Best-effort: contention
31
+ // timeouts degrade to "proceed unlocked" (the bounded retry loop in prune.ts
32
+ // stays as defense-in-depth), never to a crash.
33
+
34
+ const LOCK_FILE = '.decision-log.lock'
35
+ const LOCK_WAIT_MS = 5000
36
+ const LOCK_STALE_MS = 10000
37
+ const LOCK_POLL_MS = 25
38
+
39
+ /** True when a pid refers to a live process (or we lack permission to tell). */
40
+ function processAlive(pid: number): boolean {
41
+ if (!Number.isInteger(pid) || pid <= 0) return false
42
+ try {
43
+ process.kill(pid, 0)
44
+ return true
45
+ } catch (err) {
46
+ return (err as NodeJS.ErrnoException).code === 'EPERM'
47
+ }
48
+ }
49
+
50
+ /** Synchronous busy-wait sleep (Atomics.wait is a reliable sleep in Node). */
51
+ function sleepSync(ms: number): void {
52
+ const sab = new Int32Array(new SharedArrayBuffer(4))
53
+ Atomics.wait(sab, 0, 0, ms)
54
+ }
55
+
56
+ /**
57
+ * Acquire the decision-log lock for `projectRoot`.
58
+ * Returns a release function (always callable; a no-op when the lock could
59
+ * not be taken — never wedge an append behind a vanished holder).
60
+ */
61
+ export function acquireLogLock(projectRoot: string): () => void {
62
+ const dir = join(projectRoot, LOG_DIR)
63
+ if (!existsSync(dir)) {
64
+ try {
65
+ mkdirSync(dir, { recursive: true })
66
+ } catch {
67
+ return () => {}
68
+ }
69
+ }
70
+ const lockPath = join(dir, LOCK_FILE)
71
+ const start = Date.now()
72
+ for (;;) {
73
+ let fd: number | null = null
74
+ try {
75
+ fd = openSync(lockPath, 'wx')
76
+ } catch (err) {
77
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') return () => {}
78
+ // Lock exists — steal it when the owner is gone or the lock is ancient.
79
+ let stale = false
80
+ try {
81
+ const st = statSync(lockPath)
82
+ if (Date.now() - st.mtimeMs > LOCK_STALE_MS) stale = true
83
+ else {
84
+ const pid = Number(readFileSync(lockPath, 'utf-8'))
85
+ if (!processAlive(pid)) stale = true
86
+ }
87
+ } catch {
88
+ stale = true // unreadable/vanishing lock → retry as stale
89
+ }
90
+ if (stale) {
91
+ try { unlinkSync(lockPath) } catch { /* another holder stole it */ }
92
+ continue
93
+ }
94
+ if (Date.now() - start > LOCK_WAIT_MS) return () => {}
95
+ sleepSync(LOCK_POLL_MS)
96
+ continue
97
+ }
98
+ // Owned the lock: stamp the holder pid, then release once on return.
99
+ try { writeSync(fd, String(process.pid)) } catch { /* pid stamp is advisory */ }
100
+ try { closeSync(fd) } catch { /* best-effort */ }
101
+ let released = false
102
+ return () => {
103
+ if (released) return
104
+ released = true
105
+ try { unlinkSync(lockPath) } catch { /* already gone */ }
106
+ }
107
+ }
108
+ }
109
+
110
+ // ─── Entry-count cache (avoid an O(n) full re-read per append) ─────────────
111
+
112
+ const entryCountCache = new Map<string, { size: number; count: number }>()
113
+
114
+ /** Line-count the log file from scratch (entries = non-empty JSON lines). */
115
+ function recountEntries(filePath: string): number {
116
+ try {
117
+ const content = readFileSync(filePath, 'utf-8')
118
+ return content.split('\n').filter((l) => l.trim().length > 0).length
119
+ } catch {
120
+ return 1
121
+ }
122
+ }
123
+
11
124
  /** All valid DecisionLogEntry `type` values (must stay in sync with Types). */
12
125
  const VALID_ENTRY_TYPES = new Set<DecisionLogEntry['type']>([
13
126
  'round_start',
@@ -57,23 +170,30 @@ function logPath(projectRoot: string): string {
57
170
  * miss without failing the mutation they already performed.
58
171
  */
59
172
  export function appendDecisionEntry(projectRoot: string, entry: DecisionLogEntry): { count: number; path: string; error?: string } {
60
- let filePath: string
61
- try {
62
- filePath = logPath(projectRoot)
63
- const line = JSON.stringify(entry) + '\n'
64
- appendFileSync(filePath, line, 'utf-8')
65
- } catch (err) {
66
- return { count: 0, path: join(projectRoot, LOG_DIR, LOG_FILE), error: `failed to append decision log: ${String(err)}` }
67
- }
68
- // Count entries
69
- let count = 0
173
+ // Serialize with the cross-process log lock so an append can never interleave
174
+ // with a prune rewrite (which renames the file under us).
175
+ const release = acquireLogLock(projectRoot)
70
176
  try {
71
- const content = readFileSync(filePath, 'utf-8')
72
- count = content.split('\n').filter((l) => l.trim().length > 0).length
73
- } catch {
74
- count = 1
177
+ const filePath = logPath(projectRoot)
178
+ try {
179
+ const line = JSON.stringify(entry) + '\n'
180
+ const prevSize = existsSync(filePath) ? statSync(filePath).size : 0
181
+ appendFileSync(filePath, line, 'utf-8')
182
+ // Count entries via the size/count cache: when nothing else changed the
183
+ // file since our last append (same byte size), count is just +1 — no full
184
+ // re-read. Falls back to a full re-count whenever the cache is stale.
185
+ const cached = entryCountCache.get(filePath)
186
+ const count = cached && cached.size === prevSize
187
+ ? cached.count + 1
188
+ : recountEntries(filePath)
189
+ entryCountCache.set(filePath, { size: existsSync(filePath) ? statSync(filePath).size : prevSize, count })
190
+ return { count, path: filePath }
191
+ } catch (err) {
192
+ return { count: 0, path: join(projectRoot, LOG_DIR, LOG_FILE), error: `failed to append decision log: ${String(err)}` }
193
+ }
194
+ } finally {
195
+ release()
75
196
  }
76
- return { count, path: filePath }
77
197
  }
78
198
 
79
199
  /**
package/src/tools/fix.ts CHANGED
@@ -366,6 +366,7 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
366
366
  linesRemoved: { type: 'integer' },
367
367
  diffSummary: { type: 'string' },
368
368
  backupPath: { type: 'string' },
369
+ warning: { type: 'string' },
369
370
  error: { type: 'string' },
370
371
  },
371
372
  },
@@ -456,6 +457,17 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
456
457
  return { ok: false, error: `finding already fixed this run (id: ${id})`, id }
457
458
  }
458
459
 
460
+ // No-op guard: content-identical "fixes" (e.g. a fixer that re-sent the
461
+ // file unchanged) must never burn a backup, a write, or a registry/success
462
+ // record. Placed after the registry check so a re-sent fix of an id that
463
+ // was ALREADY fixed is still reported as "already fixed this run".
464
+ if (added === 0 && removed === 0) {
465
+ return {
466
+ ok: false,
467
+ error: `no changes: the supplied content for ${file} is identical to the current content — apply a real edit`,
468
+ }
469
+ }
470
+
459
471
  const target = resolveProjectFile(projectRoot, file)
460
472
  if (!target.ok) return { ok: false, error: target.reason }
461
473
 
@@ -528,13 +540,16 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
528
540
  return { ok: false, error: `failed to write fix registry: ${String(err)} (file restored from backup)` }
529
541
  }
530
542
 
531
- appendDecisionEntry(projectRoot, {
543
+ const logRes = appendDecisionEntry(projectRoot, {
532
544
  timestamp,
533
545
  round: args.round,
534
546
  type: 'atomic_fix',
535
547
  data: { id, file, finding: finding.summary, linesAdded: added, linesRemoved: removed },
536
548
  })
537
549
 
550
+ // The fix itself succeeded, but a decision-log write failure would leave
551
+ // the audit trail incomplete — surface it (warning, not fatal) so the
552
+ // model/UI knows the record was not persisted.
538
553
  return {
539
554
  ok: true,
540
555
  id,
@@ -544,6 +559,7 @@ export function registerFixTool(ctx: { tools: { register: (def: ReturnType<typeo
544
559
  linesRemoved: removed,
545
560
  diffSummary: record.diffSummary,
546
561
  backupPath,
562
+ ...(logRes.error ? { warning: logRes.error } : {}),
547
563
  }
548
564
  })
549
565
  return result
@@ -710,6 +726,7 @@ export function registerRollbackTool(ctx: { tools: { register: (def: ReturnType<
710
726
  ok: { type: 'boolean', required: true },
711
727
  id: { type: 'string' },
712
728
  file: { type: 'string' },
729
+ warning: { type: 'string' },
713
730
  error: { type: 'string' },
714
731
  },
715
732
  },
@@ -749,7 +766,7 @@ export function registerRollbackTool(ctx: { tools: { register: (def: ReturnType<
749
766
  return { ok: false, error: `failed to update fix registry: ${String(err)}` }
750
767
  }
751
768
 
752
- appendDecisionEntry(projectRoot, {
769
+ const logRes = appendDecisionEntry(projectRoot, {
753
770
  timestamp: new Date().toISOString(),
754
771
  round: record.round,
755
772
  type: 'revert',
@@ -762,7 +779,12 @@ export function registerRollbackTool(ctx: { tools: { register: (def: ReturnType<
762
779
  // rollback flow.
763
780
  await markFixRolledBackInTranscript(projectRoot, id)
764
781
 
765
- return { ok: true, id, file: record.finding.file }
782
+ return {
783
+ ok: true,
784
+ id,
785
+ file: record.finding.file,
786
+ ...(logRes.error ? { warning: logRes.error } : {}),
787
+ }
766
788
  },
767
789
  }),
768
790
  )
@@ -30,7 +30,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
30
30
  import type { JsonValue } from '@deepseek-ai/dsh-util-values'
31
31
  import { resolveProjectRootForExec } from '../config-loader.ts'
32
32
  import { writeTextAtomic, writeJsonAtomic } from '../atomic-fs.ts'
33
- import { readDecisionEntries, appendDecisionEntry } from './decision-log.ts'
33
+ import { readDecisionEntries, appendDecisionEntry, acquireLogLock } from './decision-log.ts'
34
34
  import { readRegistry, removeRecord, recomputeRoundCounts } from './fix.ts'
35
35
  import { readExperienceBank, writeExperienceBank } from './experience-store.ts'
36
36
  import { readDefenseEvents, writeDefenseEvents } from './defense-store.ts'
@@ -264,23 +264,32 @@ export function inspectPrune(
264
264
  */
265
265
  function rewriteDecisionLogKeepingRecent(projectRoot: string, cutoff: string): { deleted: number; error?: string } {
266
266
  const logPath = join(iterateDir(projectRoot), 'decision-log.jsonl')
267
- for (let attempt = 0; attempt < MAX_LOG_REWRITE_RETRIES; attempt++) {
268
- try {
269
- const entries = readDecisionEntries(projectRoot)
270
- const kept = entries.filter((e) => e.timestamp >= cutoff)
271
- const deleted = entries.length - kept.length
272
- if (deleted === 0) return { deleted: 0 }
273
- writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n')
274
- // A concurrent appender may have landed new lines after our read. If the
275
- // log grew during the write window, re-read and prune again instead of
276
- // accepting a lost audit trail.
277
- const after = readDecisionEntries(projectRoot)
278
- if (after.length <= kept.length) return { deleted }
279
- } catch (err) {
280
- return { deleted: 0, error: `failed to rewrite decision log: ${String(err)}` }
267
+ // Take the cross-process log lock for the ENTIRE loop: with the mutex held,
268
+ // no concurrent appender can slip a fresh line into the read-before-rewrite
269
+ // window, so the first attempt is nearly always the only one. (The retry
270
+ // loop stays as defense-in-depth for the lock-unavailable path.)
271
+ const release = acquireLogLock(projectRoot)
272
+ try {
273
+ for (let attempt = 0; attempt < MAX_LOG_REWRITE_RETRIES; attempt++) {
274
+ try {
275
+ const entries = readDecisionEntries(projectRoot)
276
+ const kept = entries.filter((e) => e.timestamp >= cutoff)
277
+ const deleted = entries.length - kept.length
278
+ if (deleted === 0) return { deleted: 0 }
279
+ writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n')
280
+ // A concurrent appender (cross-process, lock-unavailable path) may have
281
+ // landed new lines after our read. If the log grew during the write
282
+ // window, re-read and prune again instead of accepting a lost audit trail.
283
+ const after = readDecisionEntries(projectRoot)
284
+ if (after.length <= kept.length) return { deleted }
285
+ } catch (err) {
286
+ return { deleted: 0, error: `failed to rewrite decision log: ${String(err)}` }
287
+ }
281
288
  }
289
+ return { deleted: 0, error: `failed to rewrite decision log after ${MAX_LOG_REWRITE_RETRIES} attempts` }
290
+ } finally {
291
+ release()
282
292
  }
283
- return { deleted: 0, error: `failed to rewrite decision log after ${MAX_LOG_REWRITE_RETRIES} attempts` }
284
293
  }
285
294
 
286
295
  /**
@@ -507,8 +516,10 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
507
516
 
508
517
  const result = executePrune(projectRoot, retainDays, report)
509
518
 
510
- // Log the prune to the decision log.
511
- appendDecisionEntry(projectRoot, {
519
+ // Log the prune to the decision log. The mutations already happened, so
520
+ // a failed log append is reported via `errors` — a silent audit miss
521
+ // (F2) must never pretend every deletion was recorded.
522
+ const logRes = appendDecisionEntry(projectRoot, {
512
523
  timestamp: new Date().toISOString(),
513
524
  round: 0,
514
525
  type: 'decision',
@@ -523,6 +534,7 @@ export function registerPruneTool(ctx: { tools: { register: (def: ReturnType<typ
523
534
  deletedDefenseEvents: result.deletedDefenseEvents,
524
535
  },
525
536
  })
537
+ if (logRes.error) result.errors.push(logRes.error)
526
538
 
527
539
  return {
528
540
  ok: true,
@@ -208,8 +208,9 @@ function readConfigFile(configPath: string): Record<string, unknown> {
208
208
  if (!existsSync(configPath)) return {}
209
209
  const content = readFileSync(configPath, 'utf-8')
210
210
  const parsed = yaml.load(content)
211
- if (!parsed || typeof parsed !== 'object') {
212
- // A config that exists but is not a YAML mapping must NOT be silently
211
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
212
+ // A config that exists but is NOT a YAML mapping (including a YAML list,
213
+ // which `typeof [] === 'object'` alone would accept) must NOT be silently
213
214
  // treated as empty: writing over it would destroy user data. Callers
214
215
  // surface this as an error and refuse to write.
215
216
  throw new Error('existing iterate.config.yaml is not a valid YAML mapping')