iterate-plugin 2.9.4 → 2.11.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.
@@ -18,7 +18,7 @@
18
18
  * - Each deletion is logged to the decision log (when not dry-run).
19
19
  */
20
20
 
21
- import { existsSync, readdirSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
21
+ import { existsSync, readdirSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
22
22
  import { join } from 'node:path'
23
23
  import { defineTool } from '@deepseek-ai/dsh-tools'
24
24
  import type { JsonValue } from '@deepseek-ai/dsh-session'
@@ -134,17 +134,17 @@ export function executePrune(
134
134
  errors: [] as string[],
135
135
  }
136
136
 
137
- // 1. Rewrite the decision log, keeping only recent entries.
137
+ // 1. Rewrite the decision log, keeping only recent entries. Atomic
138
+ // (temp + rename) so a crash mid-write can never truncate the log.
138
139
  try {
139
140
  const entries = readDecisionEntries(projectRoot)
140
141
  const kept = entries.filter((e) => e.timestamp >= cutoff)
141
142
  result.deletedLogEntries = entries.length - kept.length
142
143
  if (result.deletedLogEntries > 0) {
143
- writeFileSync(
144
- join(iterateDir(projectRoot), 'decision-log.jsonl'),
145
- kept.map((e) => JSON.stringify(e)).join('\n') + '\n',
146
- 'utf-8',
147
- )
144
+ const logPath = join(iterateDir(projectRoot), 'decision-log.jsonl')
145
+ const tmpPath = `${logPath}.tmp-${Date.now()}`
146
+ writeFileSync(tmpPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n', 'utf-8')
147
+ renameSync(tmpPath, logPath)
148
148
  }
149
149
  } catch (err) {
150
150
  result.errors.push(`failed to rewrite decision log: ${String(err)}`)
@@ -175,10 +175,13 @@ export function executePrune(
175
175
  if (report.emptyRounds.length > 0) {
176
176
  try {
177
177
  let registry = readRegistry(projectRoot)
178
- for (const round of report.emptyRounds) {
179
- for (const rec of [...registry.rounds.find((r) => r.round === round)?.records ?? []]) {
180
- registry = removeRecord(registry, rec.id)
181
- }
178
+ const emptyRoundNos = new Set(report.emptyRounds)
179
+ // Drop whole empty rounds (records.length === 0) instead of only
180
+ // removing their records — an empty round has no records to remove, so
181
+ // the old loop was a no-op that still reported trimmedEmptyRounds.
182
+ registry = {
183
+ ...registry,
184
+ rounds: registry.rounds.filter((r) => !emptyRoundNos.has(r.round) || (r.records?.length ?? 0) > 0),
182
185
  }
183
186
  registry = recomputeRoundCounts(registry)
184
187
  writeFileSync(fixRegistryPath(projectRoot), JSON.stringify(registry, null, 2), 'utf-8')
@@ -170,9 +170,12 @@ export function registerReviewTool(ctx: { tools: { register: (def: ReturnType<ty
170
170
  const rawRounds = Array.isArray(args.rounds) ? args.rounds : []
171
171
  const rounds: ReviewRound[] = rawRounds
172
172
  .map((r) => {
173
- const rr = r as { round?: number; findings?: unknown }
173
+ const rr = r as { round?: number; findings?: unknown; readFiles?: unknown }
174
174
  const findings = Array.isArray(rr?.findings) ? (rr.findings as ReviewFinding[]) : []
175
- return { round: typeof rr?.round === 'number' ? rr.round : 0, findings }
175
+ const readFiles = Array.isArray(rr?.readFiles)
176
+ ? (rr.readFiles as unknown[]).filter((f): f is string => typeof f === 'string')
177
+ : []
178
+ return { round: typeof rr?.round === 'number' ? rr.round : 0, findings, readFiles }
176
179
  })
177
180
  .filter((r: ReviewRound) => r.round > 0)
178
181
 
@@ -1,4 +1,4 @@
1
- import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
1
+ import { copyFileSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import { defineTool } from '@deepseek-ai/dsh-tools'
4
4
  import type { JsonValue } from '@deepseek-ai/dsh-session'
@@ -231,16 +231,19 @@ function applyEntries(
231
231
  try {
232
232
  writeFileSync(configPath, yamlText, 'utf-8')
233
233
  } catch (err) {
234
- // Rollback: restore the backup (or delete the file we just created).
234
+ // Rollback: restore the backup, or REMOVE the file we just created when
235
+ // there was no prior config — an empty file left behind would poison all
236
+ // future config reads (empty YAML is not a valid mapping).
237
+ let rollbackError = ''
235
238
  try {
236
239
  if (backupPath) copyFileSync(backupPath, configPath)
237
- else if (existsSync(configPath)) writeFileSync(configPath, '', 'utf-8')
238
- } catch {
239
- // Rollback failure is reported, not swallowed silently.
240
+ else if (existsSync(configPath)) rmSync(configPath, { force: true })
241
+ } catch (rbErr) {
242
+ rollbackError = `; rollback also failed: ${String(rbErr)}`
240
243
  }
241
244
  return {
242
245
  ok: false,
243
- error: `Failed to write config: ${String(err)}`,
246
+ error: `Failed to write config: ${String(err)}${rollbackError}`,
244
247
  }
245
248
  }
246
249
 
@@ -46,10 +46,13 @@ async function runCommand(
46
46
  },
47
47
  (error, stdout, stderr) => {
48
48
  const durationMs = Math.round(performance.now() - start)
49
- // error.code is the exit code when the command ran; error.killed means timeout
49
+ // error.code is the exit code when the command ran; when the binary
50
+ // cannot be spawned Node sets error.code to a STRING ('ENOENT' etc).
51
+ // Coerce to a number so the integer output schema is never violated.
52
+ const exitCode = typeof error?.code === 'number' ? error.code : (error ? 1 : 0)
50
53
  resolve({
51
54
  command,
52
- exitCode: error?.code ?? (error ? 1 : 0),
55
+ exitCode,
53
56
  stdout: stdout ?? '',
54
57
  stderr: stderr ?? '',
55
58
  timedOut: error?.killed === true,
package/src/types.ts CHANGED
@@ -76,6 +76,11 @@ export interface ReviewReport {
76
76
  rounds: ReviewRound[]
77
77
  /** Globally deduped, known-intentional-filtered, severity-sorted findings. */
78
78
  findings: ReviewFinding[]
79
+ /**
80
+ * Every file the reviewers self-reported opening (readFiles across rounds).
81
+ * Consumed by the meta-review coverage gate. Optional for back-compat.
82
+ */
83
+ readFiles?: string[]
79
84
  convergence: {
80
85
  totalRounds: number
81
86
  findingsByRound: number[]
@@ -98,6 +103,13 @@ export interface ReviewReport {
98
103
  export interface ReviewRound {
99
104
  round: number
100
105
  findings: ReviewFinding[]
106
+ /**
107
+ * Files the reviewer subagents actually opened with read_file (from their
108
+ * `readFiles` output). Threaded through aggregate → report → meta-review so
109
+ * the coverage gate can compare self-reported reads against the assigned
110
+ * inventory. Optional — older callers omit it.
111
+ */
112
+ readFiles?: string[]
101
113
  }
102
114
 
103
115
  /** Known intentional entry (filtered out from findings) */