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.
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
 
@@ -161,13 +162,17 @@ export function aggregateRounds(
161
162
  // Clamp the allocation bound so a hostile round number cannot OOM the tool.
162
163
  const effectiveMax = Math.min(maxRound, Math.max(1, roundCap * 2))
163
164
 
164
- const findingsByRound: number[] = []
165
- for (let r = 1; r <= effectiveMax; r++) {
166
- let count = 0
167
- for (const key of firstRoundByKey.keys()) {
168
- if (firstRoundByKey.get(key) === r) count++
169
- }
170
- findingsByRound.push(count)
165
+ const findingsByRound: number[] = new Array(effectiveMax).fill(0)
166
+ for (const key of firstRoundByKey.keys()) {
167
+ const firstRound = firstRoundByKey.get(key) ?? 1
168
+ // Fold rounds BEYOND the allocation bound into the highest slot instead of
169
+ // dropping them: a finding first seen in an over-cap round was previously
170
+ // invisible to `findingsByRound`, so a run whose final rounds exceeded the
171
+ // cap looked "converged" even though a real round found new issues. Folding
172
+ // keeps the counts truthful (the sum still equals totalFindings) while the
173
+ // memory bound stays fixed.
174
+ const idx = Math.min(firstRound, effectiveMax) - 1
175
+ findingsByRound[idx] = (findingsByRound[idx] || 0) + 1
171
176
  }
172
177
 
173
178
  return { findings: dedupeFindings(merged), findingsByRound, firstRoundByKey }
@@ -281,8 +286,13 @@ export function buildReviewReport(input: {
281
286
  for (const r of filteredRounds) {
282
287
  if (typeof r.round === 'number' && r.round > lastRound) lastRound = r.round
283
288
  }
289
+ // `lastRound` can exceed the array length when over-cap rounds were FOLDED
290
+ // into the last slot (see aggregateRounds). Read the same clamped index
291
+ // computeConvergence uses, so a real over-cap round is never mis-read as 0
292
+ // new findings (which would falsely report "converged").
293
+ const idx = Math.min(lastRound, findingsByRound.length) - 1
284
294
  const lastRoundCount =
285
- lastRound > 0 ? (findingsByRound[lastRound - 1] ?? 0) : 0
295
+ lastRound > 0 && idx >= 0 ? (findingsByRound[idx] ?? 0) : 0
286
296
  const converged = filteredRounds.length > 0 && lastRoundCount === 0
287
297
 
288
298
  // Attach the normal-mode fix count to the summary (dry-run leaves it absent).
@@ -629,6 +639,12 @@ export function reviewerTaskPrompt(input: {
629
639
  * every dimension reviewer inspects/considers them before judging.
630
640
  */
631
641
  attachments?: ReviewAttachment[]
642
+ /**
643
+ * Configured reasoning effort ('low' | 'medium' | 'high') to apply to this
644
+ * review pass. Injected as a directive so the harness can honor it when
645
+ * running each reviewer subagent. Omitted → follow the provider default.
646
+ */
647
+ effort?: 'low' | 'medium' | 'high'
632
648
  }): string {
633
649
  const parts: string[] = []
634
650
  parts.push(
@@ -636,6 +652,9 @@ export function reviewerTaskPrompt(input: {
636
652
  `Goal: ${input.goal}`,
637
653
  `Scope: ${input.scope === 'full' ? 'entire codebase' : 'changed files only'}.`,
638
654
  )
655
+ if (input.effort) {
656
+ parts.push(`Reasoning effort for this review pass: ${input.effort}.`)
657
+ }
639
658
  if (input.focus) {
640
659
  parts.push(`FOCUS: ${input.focus}`)
641
660
  }
@@ -743,6 +762,12 @@ export function buildReviewPlan(input: {
743
762
  fallbackToFull: boolean
744
763
  /** The attachments threaded into every reviewer prompt (empty when none). */
745
764
  attachments: ReviewAttachment[]
765
+ /**
766
+ * Configured reasoning effort carried on the plan for the orchestrator to
767
+ * honor (null when unset → provider default). Also injected as a directive
768
+ * into every dimension's reviewer prompt.
769
+ */
770
+ reasoningEffort: 'low' | 'medium' | 'high' | null
746
771
  } {
747
772
  // Defensive reads: a malformed config (e.g. `dimensions` as a non-array, or
748
773
  // `review`/`atomic` missing) must degrade to sane defaults instead of
@@ -750,6 +775,12 @@ export function buildReviewPlan(input: {
750
775
  const language = input.config.language === 'zh' ? 'Chinese (中文)' : 'English'
751
776
  const goal = input.config.goal ?? ''
752
777
  const configuredScope = input.config.review?.scope ?? 'full'
778
+ const reasoningEffort =
779
+ input.config.reasoning_effort === 'low' ||
780
+ input.config.reasoning_effort === 'medium' ||
781
+ input.config.reasoning_effort === 'high'
782
+ ? input.config.reasoning_effort
783
+ : null
753
784
  const dimensions = Array.isArray(input.config.dimensions) ? input.config.dimensions : []
754
785
  const maxLines = input.config.atomic?.max_lines ?? 20
755
786
  const changedFiles = Array.isArray(input.changedFiles) ? input.changedFiles : []
@@ -815,6 +846,7 @@ export function buildReviewPlan(input: {
815
846
  scopeFiles: batch,
816
847
  focus: focusMap.get(d),
817
848
  attachments,
849
+ effort: reasoningEffort ?? undefined,
818
850
  }),
819
851
  findingsSchema: findingsSchema(),
820
852
  })
@@ -831,5 +863,6 @@ export function buildReviewPlan(input: {
831
863
  changedFiles: effectiveChangedOnly ? changedFiles : [],
832
864
  fallbackToFull,
833
865
  attachments,
866
+ reasoningEffort,
834
867
  }
835
868
  }
@@ -20,7 +20,7 @@ You have the iterate plugin installed, which registers these tools:
20
20
  - \`iterate_diff\` — show the accumulated diff for a fixed file (vs its original backup) or a per-file summary of all fixes
21
21
  - \`iterate_rollback\` — revert a fix by id: restore the file from its backup, remove the fix from the registry, log a \`revert\` entry. Use when a round's validation fails
22
22
  - \`iterate_checkpoint\` — save / load / clear an iteration checkpoint (\`.iterate/checkpoint.json\`) so a long run can resume where it left off
23
- - \`iterate_status\` — summarize the current run: mode, round, fixes applied, architectural remaining, decision-log size, checkpoint presence, and whether the run was interrupted (a checkpoint left on disk means the previous run was interrupted and can be resumed)
23
+ - \`iterate_status\` — summarize the current run: mode, round, fixes applied, architectural remaining, decision-log size, checkpoint presence, and whether the run was interrupted (a checkpoint on disk with no newer decision-log activity means the previous run was interrupted and can be resumed)
24
24
  - \`iterate_history\` — inspect the runtime state in detail: decision-log entries and applied fixes (optionally scoped to a round or a fixed file)
25
25
  - \`iterate_prune\` — remove stale runtime artifacts (\`.iterate/\` entries). Defaults to a read-only dry-run that reports what WOULD be removed; pass \`dryRun:false\` to actually prune.
26
26
  - \`iterate_transcript\` — runtime observatory file (\`.iterate/transcript.json\`). \`read\` fetches the persisted manifest including any steering \`nudge\` for this run's reviewers; \`capture\` (call once after the final report) persists the per-reviewer threads, convergence trend, findings, fixes, checkpoint, and timeline so the client observatory panel reflects the run; \`nudge\` sets/clears steering text the next round's reviewers read. Purely local, never touches source files.
@@ -81,6 +81,10 @@ const maxRounds = plan.maxReviewRounds
81
81
  const knownIntentional = (plan.knownIntentional || []) // config personalization filter, applied in aggregate
82
82
  let known = [] // cumulative DEDUPED findings fed back to reviewers
83
83
  const rounds = [] // raw per-round findings
84
+ // Tracks whether the LAST executed round produced usable reviewer output. A
85
+ // round where every reviewer subagent failed (or output stayed schema-invalid
86
+ // after retries) is INCONCLUSIVE — it must never make the run look converged.
87
+ let lastRoundOk = true
84
88
 
85
89
  phase('transcript')
86
90
  // Read any steering nudge written (via iterate_transcript nudge) for this run's reviewers.
@@ -118,6 +122,12 @@ for (let r = 1; r <= maxRounds; r++) {
118
122
  JSON.stringify(known) + nudge + '\\nReturn the findings JSON object.'
119
123
  return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta.findingsSchema }, backend))
120
124
  }))
125
+ // A per-dimension reviewer subagent resolves \`null\` on child failure; the
126
+ // aggregate below would report an all-failed round as an empty round, and
127
+ // the convergence math would then read "0 new findings". An all-failed
128
+ // round is NOT a clean pass — it is inconclusive, so the convergence gate
129
+ // below refuses it before any "converged" signal fires.
130
+ const reviewersOk = raw.length > 0 ? raw.some(x => x !== null && typeof x === 'object') : true
121
131
  const thisRound = {
122
132
  round: r,
123
133
  findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])),
@@ -145,23 +155,30 @@ for (let r = 1; r <= maxRounds; r++) {
145
155
  // final summary can explain WHY the round was inconclusive.
146
156
  log('round ' + r + ' output STILL schema-invalid after 3 attempts — marking round inconclusive (NOT converged)')
147
157
  }
158
+ const roundUnusable = schemaInvalid || !reviewersOk
159
+ if (roundUnusable) lastRoundOk = false
148
160
  // Feed the DEDUPED + already-filtered set back (not raw findings) so the known
149
- // list stays bounded and reviewers never see the same issue twice.
150
- if (agg && agg.report && Array.isArray(agg.report.findings)) known = agg.report.findings
161
+ // list stays bounded and reviewers never see the same issue twice. Never feed
162
+ // an unusable round's empty result — it would erase the still-valid known list
163
+ // and let reviewers re-report everything next round.
164
+ if (!roundUnusable && agg && agg.report && Array.isArray(agg.report.findings)) known = agg.report.findings
151
165
  // A round counts as "converged" ONLY when the aggregate accepted it
152
- // (schema-valid) AND it genuinely found zero NEW findings. If reviewers all
153
- // failed or their output stayed schema-invalid after retries, the round is
154
- // INCONCLUSIVE — never report an empty-but-broken round as converged (an
155
- // invalid/failed round must fail the run, not masquerade as a clean pass).
156
- if (!schemaInvalid) {
166
+ // (schema-valid) AND its reviewers actually returned output AND it genuinely
167
+ // found zero NEW findings. If reviewers all failed or their output stayed
168
+ // schema-invalid after retries, the round is INCONCLUSIVE — never report an
169
+ // empty-but-broken round as converged (an invalid/failed round would
170
+ // otherwise masquerade as a clean pass and stop the run early).
171
+ if (!roundUnusable && !schemaInvalid) {
157
172
  const newCount =
158
173
  agg && agg.report && agg.report.convergence
159
174
  ? agg.report.convergence.findingsByRound[r - 1]
160
175
  : thisRound.findings.length
161
176
  if (newCount === 0) {
162
177
  log('round ' + r + ' found 0 new findings — converged')
178
+ lastRoundOk = true
163
179
  break
164
180
  }
181
+ lastRoundOk = true
165
182
  }
166
183
  }
167
184
 
@@ -197,8 +214,13 @@ return {
197
214
  mode: 'dry-run',
198
215
  goal: report.goal,
199
216
  rounds: rounds.length,
200
- converged: report.convergence.converged,
201
- stoppedReason: report.convergence.stoppedReason,
217
+ // The aggregate's convergence flag is only trustworthy when the last round
218
+ // was usable — an all-failed/schema-invalid final round looks like "0 new"
219
+ // to the convergence math but is an inconclusive run, never a clean pass.
220
+ converged: report.convergence.converged && lastRoundOk,
221
+ stoppedReason: report.convergence.converged && !lastRoundOk
222
+ ? 'inconclusive'
223
+ : report.convergence.stoppedReason,
202
224
  findingsByRound: report.convergence.findingsByRound,
203
225
  totalFindings: report.summary.totalFindings,
204
226
  bySeverity: { critical: report.summary.critical, high: report.summary.high, medium: report.summary.medium, low: report.summary.low },
@@ -260,7 +282,7 @@ const configRes = await agent(
260
282
  const cfg = (configRes && configRes.config) ? configRes.config : null
261
283
  const atomicMaxLines = (cfg && cfg.atomic && cfg.atomic.max_lines) ? cfg.atomic.max_lines : 20
262
284
  const planRes = await agent(
263
- 'Call iterate_review({operation:"plan", mode:"normal", maxReviewRounds:' + (args.maxRounds || 3) + '}) and return the plan JSON.',
285
+ 'Call iterate_review({operation:"plan", mode:"normal"' + (args.maxRounds ? ', maxReviewRounds:' + args.maxRounds : '') + '}) and return the plan JSON.',
264
286
  Object.assign({ label: 'review:plan' }, backend)
265
287
  )
266
288
  const plan = (planRes && planRes.plan) ? planRes.plan : null
@@ -274,6 +296,7 @@ const architectural = (checkpoint && Array.isArray(checkpoint.findings)) ? check
274
296
  let fixedCount = (checkpoint && typeof checkpoint.fixedCount === 'number') ? checkpoint.fixedCount : 0
275
297
  let converged = false
276
298
  let abortedByValidation = false
299
+ let schemaFailed = false // last round produced no usable reviewer output (schema-invalid after retries / all reviewers failed)
277
300
  let failedCommands = []
278
301
  let configErrors = [] // validation commands NOT in validation.commands (config gap, no rollback)
279
302
  const fixRecords = [] // observatory fix records collected round by round
@@ -316,6 +339,10 @@ for (let r = startRound; r <= maxRounds; r++) {
316
339
  '\\n Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + nudge + '\\nReturn the findings JSON object.'
317
340
  return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta.findingsSchema }, backend))
318
341
  }))
342
+ // ALL reviewers returning null (child failures) means this round produced
343
+ // no usable output — an empty aggregate must not read like a clean
344
+ // "nothing to fix" convergence. Gate the loop exit + convergence on it.
345
+ const reviewersOk = raw.length > 0 ? raw.some(x => x !== null && typeof x === 'object') : true
319
346
  const thisRound = {
320
347
  round: r,
321
348
  findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])),
@@ -339,10 +366,19 @@ for (let r = startRound; r <= maxRounds; r++) {
339
366
  log('retry ' + attempts + ': round ' + r + ' output failed schema validation — re-running reviewers with strict-JSON emphasis')
340
367
  }
341
368
  } while (schemaInvalid && attempts < 3)
342
- if (schemaInvalid) {
343
- // Bounded exit: never loop forever. Surface the failure so the run's
344
- // final summary can explain WHY the round was inconclusive.
345
- log('round ' + r + ' output STILL schema-invalid after 3 attempts — marking round inconclusive (NOT converged)')
369
+ if (schemaInvalid || !reviewersOk) {
370
+ // Bounded exit: never loop forever, and never let an all-failed/empty round
371
+ // read like a clean convergence. Stop the run WITHOUT applying fixes for
372
+ // this round, record the failure in the audit trail, and leave the
373
+ // checkpoint in place so a user/DM can resume or re-roll after fixing the
374
+ // reviewer configuration.
375
+ schemaFailed = true
376
+ log('round ' + r + ' produced NO usable reviewer output (schema-invalid after retries or all reviewers failed) — stopping run (NOT converged)')
377
+ await agent(
378
+ 'Call iterate_decision_log({operation:"append", type:"round_failed", round:' + r + ', data:{reason:"' + (schemaInvalid ? 'schema_invalid' : 'no_usable_reviewer_output') + '", rolledBack:0}})',
379
+ Object.assign({ label: 'log:schemaFailed:r' + r }, backend)
380
+ )
381
+ break
346
382
  }
347
383
  const findings = (agg && agg.report && agg.report.findings) ? agg.report.findings : thisRound.findings
348
384
  const atomic = findings.filter(f => f.is_atomic === true)
@@ -357,10 +393,10 @@ for (let r = startRound; r <= maxRounds; r++) {
357
393
  const byFile = {}
358
394
  atomic.forEach(f => { (byFile[f.file] = byFile[f.file] || []).push(f) })
359
395
  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, ' +
396
+ 'Apply the fixes for ' + JSON.stringify(file) + ' using iterate_fix. For EACH finding in this list, ' +
361
397
  '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 ' +
398
+ 'iterate_fix({ file: ' + JSON.stringify(file) + ', content: <full new file content>, finding: <that finding>, round: ' + r + ' }). ' +
399
+ 'Apply the findings IN ORDER. After all fixes, call iterate_diff({ file: ' + JSON.stringify(file) + ' }) to verify the accumulated diff and ' +
364
400
  'read its line statistics (lines added/removed). ' +
365
401
  'Findings: ' + JSON.stringify(byFile[file]) + '. Return the array of {id, ok, error, file, linesAdded, linesRemoved} per iterate_fix call ' +
366
402
  '(id/ok required; put the file-wide line stats from iterate_diff on each record, or on the last record and 0 elsewhere).',
@@ -476,7 +512,7 @@ const statusRes = await agent(
476
512
  { label: 'status:final' }
477
513
  )
478
514
  const status = (statusRes && statusRes.ok) ? statusRes : null
479
- if (!abortedByValidation) {
515
+ if (!abortedByValidation && !schemaFailed) {
480
516
  // Iteration finished cleanly → clear the checkpoint so the next run starts fresh.
481
517
  await agent(
482
518
  'Call iterate_checkpoint({ operation: "clear" }) and return {ok, existed}.',
@@ -495,9 +531,11 @@ if (!abortedByValidation) {
495
531
  fixedCount: fixedCount,
496
532
  resumeCount: effectiveResumeCount,
497
533
  }
498
- const stopReason = abortedByValidation
499
- ? (configErrors.length > 0 ? 'aborted_by_config' : 'aborted_by_validation')
500
- : (converged ? 'converged' : 'max_rounds_reached')
534
+ const stopReason = schemaFailed
535
+ ? 'schema_invalid'
536
+ : abortedByValidation
537
+ ? (configErrors.length > 0 ? 'aborted_by_config' : 'aborted_by_validation')
538
+ : (converged ? 'converged' : 'max_rounds_reached')
501
539
  await agent(
502
540
  'Call iterate_transcript({operation:"capture", mode:"normal", goal:' + JSON.stringify(plan.goal) + ', maxRounds:' + maxRounds + ', roundsExecuted:' + rounds.length + ', findingsByRound:' + JSON.stringify(rounds.map(rr => (rr.findings && rr.findings.length) ? rr.findings.length : 0)) + ', stoppedReason:"' + stopReason + '", fixes:' + JSON.stringify(fixRecords) + ', checkpoint:' + JSON.stringify(obsCheckpoint) + ', rounds:' + JSON.stringify(rounds.map(rr => ({ round: rr.round, findings: rr.findings, readFiles: rr.readFiles }))) + '}). Return {operation:"ok"}.',
503
541
  { label: 'transcript:capture' }
@@ -509,6 +547,7 @@ return {
509
547
  maxRounds: maxRounds,
510
548
  converged: converged,
511
549
  abortedByValidation: abortedByValidation,
550
+ schemaFailed: schemaFailed,
512
551
  failedCommands: failedCommands,
513
552
  configErrors: configErrors,
514
553
  stoppedReason: stopReason,
@@ -529,12 +568,12 @@ return {
529
568
  Key rules for normal mode:
530
569
  - Fixers are the ONLY agents allowed to write files, and they must go through \`iterate_fix\` — never edit files directly. That is what gives every change a backup, a diff, and a rollback path. Reviewers read only. Architectural findings are reported, never auto-fixed.
531
570
  - Aggregate the current round deterministically (\`report.findings\`) before fixing, so fixes act on deduped/filtered/sorted findings.
532
- - **Schema validation & retry**: when \`reviewer.output_schema_validation\` is on (default), retry the round's reviewers up to 2 times (3 attempts total) when \`aggregate\` reports \`schemaValidation\` valid=false for it, then re-aggregate. Never forward schema-invalid findings into \`iterate_fix\`. If the output is still schema-invalid after the 3rd attempt, mark the round inconclusive (never report it as a clean convergence).
571
+ - **Schema validation & retry**: when \`reviewer.output_schema_validation\` is on (default), retry the round's reviewers up to 2 times (3 attempts total) when \`aggregate\` reports \`schemaValidation\` valid=false for it, then re-aggregate. Never forward schema-invalid findings into \`iterate_fix\`. If the output is still schema-invalid after the 3rd attempt — or every reviewer subagent failed outright (no usable output at all) — the round is INCONCLUSIVE: \`break\` the loop, log a \`round_failed\` entry, set \`schemaFailed = true\`, and report \`stoppedReason:"schema_invalid"\`/\`"no_usable_reviewer_output"\`. Never report an inconclusive round as a clean convergence, and keep the checkpoint so the run can be resumed after the reviewer configuration is fixed.
533
572
  - Apply atomic fixes **per file**: one fixer agent handles all findings for a given file serially (so the same file is never edited concurrently); different files are fixed in parallel.
534
573
  - **Resume**: load the checkpoint first; if a previous run left one, continue from \`checkpoint.round + 1\` (its \`fixedCount\` and deduped \`findings\` are carried forward).
535
574
  - **Validate after every round** of fixes; on ANY validation failure, roll back the round's fixes via \`iterate_rollback\` and stop (the checkpoint is left in place so the run can be resumed). EXCEPTION: \`iterate_validate\` returning \`allowed:false\` means the command is simply not in \`validation.commands\` — a config gap, not broken code. In that case abort WITHOUT rolling back the round's fixes and surface the missing command + file so the user can fix the trust list.
536
575
  - **Checkpoint after every round**; clear it only when the iteration completes cleanly.
537
- - Stop when a round produces nothing to fix (converged) or maxReviewRounds is reached.
576
+ - Stop when a round produces nothing to fix (converged), maxReviewRounds is reached, validation aborts the run, or a round yields no usable reviewer output (\`stoppedReason:"schema_invalid"\`).
538
577
  - Every round, every rollback, and the final report go to the append-only decision log.
539
578
  - Close with \`iterate_status\` metrics and surface the convergence indicators (fixed count, remaining architectural count, abort reason) in the final summary.
540
579
 
@@ -177,9 +177,17 @@ export function computeStatus(input: {
177
177
  findingsCount: Array.isArray(checkpoint?.findings) ? checkpoint.findings.length : 0,
178
178
  totalDecisionLogEntries: entries.length,
179
179
  hasCheckpoint: checkpoint !== null,
180
- // A checkpoint left on disk means the previous run was interrupted before
181
- // it could clear it — this is the durable "interruption" signal.
182
- interrupted: checkpoint !== null,
180
+ // A checkpoint left on disk is the durable "the run did not finish" signal
181
+ // — EXCEPT when a decision-log entry is NEWER than the checkpoint: the
182
+ // workflow saves a checkpoint at the end of each round (right after that
183
+ // round's review_result log), so a live run proceeding past its last
184
+ // checkpoint would otherwise be flagged as "interrupted" for its whole
185
+ // duration. A fresh checkpoint (or no log at all) is genuinely leftover.
186
+ interrupted:
187
+ checkpoint !== null &&
188
+ !(lastEntry != null &&
189
+ checkpoint.updatedAt &&
190
+ (Date.parse(checkpoint.updatedAt) || 0) >= (Date.parse(lastEntry.timestamp) || 0)),
183
191
  resumeCount: checkpoint?.resumeCount ?? 0,
184
192
  checkpoint,
185
193
  lastUpdated,
@@ -354,7 +362,7 @@ export function registerStatusTool(ctx: { tools: { register: (def: ReturnType<ty
354
362
  findingsCount: { type: 'integer' },
355
363
  totalDecisionLogEntries: { type: 'integer' },
356
364
  hasCheckpoint: { type: 'boolean' },
357
- interrupted: { type: 'boolean', description: 'True when a checkpoint exists, meaning the previous run was interrupted before finishing.' },
365
+ interrupted: { type: 'boolean', description: 'True when a checkpoint exists and no decision-log entry is newer than it — the previous run left a checkpoint that nothing has resumed or progressed past.' },
358
366
  resumeCount: { type: 'integer', description: 'How many times the current checkpoint has already been resumed.' },
359
367
  lastUpdated: { oneOf: [{ type: 'string' }, { type: 'null' }] },
360
368
  qualityGate: { type: 'json', description: 'v3.0: persisted quality-gate snapshot (.iterate/quality-gate.json), when present.' },
@@ -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
  )
@@ -102,7 +102,7 @@ export function registerHistoryTool(ctx: { tools: { register: (def: ReturnType<t
102
102
  },
103
103
  limit: {
104
104
  type: 'integer',
105
- description: `Max entries to return (default: ${DEFAULT_LIMIT}, cap: ${MAX_LIMIT}). Newest first.`,
105
+ description: `Max entries to return (default: ${DEFAULT_LIMIT}, cap: ${MAX_LIMIT}). Returns the newest window in chronological order.`,
106
106
  },
107
107
  path: {
108
108
  type: 'string',