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.
@@ -19,7 +19,7 @@ You have the iterate plugin installed, which registers these tools:
19
19
  - \`iterate_diff\` — show the accumulated diff for a fixed file (vs its original backup) or a per-file summary of all fixes
20
20
  - \`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
21
21
  - \`iterate_checkpoint\` — save / load / clear an iteration checkpoint (\`.iterate/checkpoint.json\`) so a long run can resume where it left off
22
- - \`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)
22
+ - \`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)
23
23
  - \`iterate_history\` — inspect the runtime state in detail: decision-log entries and applied fixes (optionally scoped to a round or a fixed file)
24
24
  - \`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.
25
25
  - \`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.
@@ -80,6 +80,10 @@ const maxRounds = plan.maxReviewRounds
80
80
  const knownIntentional = (plan.knownIntentional || []) // config personalization filter, applied in aggregate
81
81
  let known = [] // cumulative DEDUPED findings fed back to reviewers
82
82
  const rounds = [] // raw per-round findings
83
+ // Tracks whether the LAST executed round produced usable reviewer output. A
84
+ // round where every reviewer subagent failed (or output stayed schema-invalid
85
+ // after retries) is INCONCLUSIVE — it must never make the run look converged.
86
+ let lastRoundOk = true
83
87
 
84
88
  phase('transcript')
85
89
  // Read any steering nudge written (via iterate_transcript nudge) for this run's reviewers.
@@ -117,6 +121,12 @@ for (let r = 1; r <= maxRounds; r++) {
117
121
  JSON.stringify(known) + nudge + '\\nReturn the findings JSON object.'
118
122
  return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta.findingsSchema }, backend))
119
123
  }))
124
+ // A per-dimension reviewer subagent resolves \`null\` on child failure; the
125
+ // aggregate below would report an all-failed round as an empty round, and
126
+ // the convergence math would then read "0 new findings". An all-failed
127
+ // round is NOT a clean pass — it is inconclusive, so the convergence gate
128
+ // below refuses it before any "converged" signal fires.
129
+ const reviewersOk = raw.length > 0 ? raw.some(x => x !== null && typeof x === 'object') : true
120
130
  const thisRound = {
121
131
  round: r,
122
132
  findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])),
@@ -144,23 +154,30 @@ for (let r = 1; r <= maxRounds; r++) {
144
154
  // final summary can explain WHY the round was inconclusive.
145
155
  log('round ' + r + ' output STILL schema-invalid after 3 attempts — marking round inconclusive (NOT converged)')
146
156
  }
157
+ const roundUnusable = schemaInvalid || !reviewersOk
158
+ if (roundUnusable) lastRoundOk = false
147
159
  // Feed the DEDUPED + already-filtered set back (not raw findings) so the known
148
- // list stays bounded and reviewers never see the same issue twice.
149
- if (agg && agg.report && Array.isArray(agg.report.findings)) known = agg.report.findings
160
+ // list stays bounded and reviewers never see the same issue twice. Never feed
161
+ // an unusable round's empty result — it would erase the still-valid known list
162
+ // and let reviewers re-report everything next round.
163
+ if (!roundUnusable && agg && agg.report && Array.isArray(agg.report.findings)) known = agg.report.findings
150
164
  // A round counts as "converged" ONLY when the aggregate accepted it
151
- // (schema-valid) AND it genuinely found zero NEW findings. If reviewers all
152
- // failed or their output stayed schema-invalid after retries, the round is
153
- // INCONCLUSIVE — never report an empty-but-broken round as converged (an
154
- // invalid/failed round must fail the run, not masquerade as a clean pass).
155
- if (!schemaInvalid) {
165
+ // (schema-valid) AND its reviewers actually returned output AND it genuinely
166
+ // found zero NEW findings. If reviewers all failed or their output stayed
167
+ // schema-invalid after retries, the round is INCONCLUSIVE — never report an
168
+ // empty-but-broken round as converged (an invalid/failed round would
169
+ // otherwise masquerade as a clean pass and stop the run early).
170
+ if (!roundUnusable && !schemaInvalid) {
156
171
  const newCount =
157
172
  agg && agg.report && agg.report.convergence
158
173
  ? agg.report.convergence.findingsByRound[r - 1]
159
174
  : thisRound.findings.length
160
175
  if (newCount === 0) {
161
176
  log('round ' + r + ' found 0 new findings — converged')
177
+ lastRoundOk = true
162
178
  break
163
179
  }
180
+ lastRoundOk = true
164
181
  }
165
182
  }
166
183
 
@@ -196,8 +213,13 @@ return {
196
213
  mode: 'dry-run',
197
214
  goal: report.goal,
198
215
  rounds: rounds.length,
199
- converged: report.convergence.converged,
200
- stoppedReason: report.convergence.stoppedReason,
216
+ // The aggregate's convergence flag is only trustworthy when the last round
217
+ // was usable — an all-failed/schema-invalid final round looks like "0 new"
218
+ // to the convergence math but is an inconclusive run, never a clean pass.
219
+ converged: report.convergence.converged && lastRoundOk,
220
+ stoppedReason: report.convergence.converged && !lastRoundOk
221
+ ? 'inconclusive'
222
+ : report.convergence.stoppedReason,
201
223
  findingsByRound: report.convergence.findingsByRound,
202
224
  totalFindings: report.summary.totalFindings,
203
225
  bySeverity: { critical: report.summary.critical, high: report.summary.high, medium: report.summary.medium, low: report.summary.low },
@@ -259,7 +281,7 @@ const configRes = await agent(
259
281
  const cfg = (configRes && configRes.config) ? configRes.config : null
260
282
  const atomicMaxLines = (cfg && cfg.atomic && cfg.atomic.max_lines) ? cfg.atomic.max_lines : 20
261
283
  const planRes = await agent(
262
- 'Call iterate_review({operation:"plan", mode:"normal", maxReviewRounds:' + (args.maxRounds || 3) + '}) and return the plan JSON.',
284
+ 'Call iterate_review({operation:"plan", mode:"normal"' + (args.maxRounds ? ', maxReviewRounds:' + args.maxRounds : '') + '}) and return the plan JSON.',
263
285
  Object.assign({ label: 'review:plan' }, backend)
264
286
  )
265
287
  const plan = (planRes && planRes.plan) ? planRes.plan : null
@@ -273,6 +295,7 @@ const architectural = (checkpoint && Array.isArray(checkpoint.findings)) ? check
273
295
  let fixedCount = (checkpoint && typeof checkpoint.fixedCount === 'number') ? checkpoint.fixedCount : 0
274
296
  let converged = false
275
297
  let abortedByValidation = false
298
+ let schemaFailed = false // last round produced no usable reviewer output (schema-invalid after retries / all reviewers failed)
276
299
  let failedCommands = []
277
300
  let configErrors = [] // validation commands NOT in validation.commands (config gap, no rollback)
278
301
  const fixRecords = [] // observatory fix records collected round by round
@@ -315,6 +338,10 @@ for (let r = startRound; r <= maxRounds; r++) {
315
338
  '\\n Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + nudge + '\\nReturn the findings JSON object.'
316
339
  return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta.findingsSchema }, backend))
317
340
  }))
341
+ // ALL reviewers returning null (child failures) means this round produced
342
+ // no usable output — an empty aggregate must not read like a clean
343
+ // "nothing to fix" convergence. Gate the loop exit + convergence on it.
344
+ const reviewersOk = raw.length > 0 ? raw.some(x => x !== null && typeof x === 'object') : true
318
345
  const thisRound = {
319
346
  round: r,
320
347
  findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])),
@@ -338,10 +365,19 @@ for (let r = startRound; r <= maxRounds; r++) {
338
365
  log('retry ' + attempts + ': round ' + r + ' output failed schema validation — re-running reviewers with strict-JSON emphasis')
339
366
  }
340
367
  } while (schemaInvalid && attempts < 3)
341
- if (schemaInvalid) {
342
- // Bounded exit: never loop forever. Surface the failure so the run's
343
- // final summary can explain WHY the round was inconclusive.
344
- log('round ' + r + ' output STILL schema-invalid after 3 attempts — marking round inconclusive (NOT converged)')
368
+ if (schemaInvalid || !reviewersOk) {
369
+ // Bounded exit: never loop forever, and never let an all-failed/empty round
370
+ // read like a clean convergence. Stop the run WITHOUT applying fixes for
371
+ // this round, record the failure in the audit trail, and leave the
372
+ // checkpoint in place so a user/DM can resume or re-roll after fixing the
373
+ // reviewer configuration.
374
+ schemaFailed = true
375
+ log('round ' + r + ' produced NO usable reviewer output (schema-invalid after retries or all reviewers failed) — stopping run (NOT converged)')
376
+ await agent(
377
+ 'Call iterate_decision_log({operation:"append", type:"round_failed", round:' + r + ', data:{reason:"' + (schemaInvalid ? 'schema_invalid' : 'no_usable_reviewer_output') + '", rolledBack:0}})',
378
+ Object.assign({ label: 'log:schemaFailed:r' + r }, backend)
379
+ )
380
+ break
345
381
  }
346
382
  const findings = (agg && agg.report && agg.report.findings) ? agg.report.findings : thisRound.findings
347
383
  const atomic = findings.filter(f => f.is_atomic === true)
@@ -356,10 +392,10 @@ for (let r = startRound; r <= maxRounds; r++) {
356
392
  const byFile = {}
357
393
  atomic.forEach(f => { (byFile[f.file] = byFile[f.file] || []).push(f) })
358
394
  const fixRes = await parallel(Object.keys(byFile).map(file => () => agent(
359
- 'Apply the fixes for ' + file + ' using iterate_fix. For EACH finding in this list, ' +
395
+ 'Apply the fixes for ' + JSON.stringify(file) + ' using iterate_fix. For EACH finding in this list, ' +
360
396
  'read the current file, compute the edited full content (change <= ' + atomicMaxLines + ' lines), and call ' +
361
- 'iterate_fix({ file: "' + file + '", content: <full new file content>, finding: <that finding>, round: ' + r + ' }). ' +
362
- 'Apply the findings IN ORDER. After all fixes, call iterate_diff({ file: "' + file + '" }) to verify the accumulated diff and ' +
397
+ 'iterate_fix({ file: ' + JSON.stringify(file) + ', content: <full new file content>, finding: <that finding>, round: ' + r + ' }). ' +
398
+ 'Apply the findings IN ORDER. After all fixes, call iterate_diff({ file: ' + JSON.stringify(file) + ' }) to verify the accumulated diff and ' +
363
399
  'read its line statistics (lines added/removed). ' +
364
400
  'Findings: ' + JSON.stringify(byFile[file]) + '. Return the array of {id, ok, error, file, linesAdded, linesRemoved} per iterate_fix call ' +
365
401
  '(id/ok required; put the file-wide line stats from iterate_diff on each record, or on the last record and 0 elsewhere).',
@@ -475,7 +511,7 @@ const statusRes = await agent(
475
511
  { label: 'status:final' }
476
512
  )
477
513
  const status = (statusRes && statusRes.ok) ? statusRes : null
478
- if (!abortedByValidation) {
514
+ if (!abortedByValidation && !schemaFailed) {
479
515
  // Iteration finished cleanly → clear the checkpoint so the next run starts fresh.
480
516
  await agent(
481
517
  'Call iterate_checkpoint({ operation: "clear" }) and return {ok, existed}.',
@@ -494,9 +530,11 @@ if (!abortedByValidation) {
494
530
  fixedCount: fixedCount,
495
531
  resumeCount: effectiveResumeCount,
496
532
  }
497
- const stopReason = abortedByValidation
498
- ? (configErrors.length > 0 ? 'aborted_by_config' : 'aborted_by_validation')
499
- : (converged ? 'converged' : 'max_rounds_reached')
533
+ const stopReason = schemaFailed
534
+ ? 'schema_invalid'
535
+ : abortedByValidation
536
+ ? (configErrors.length > 0 ? 'aborted_by_config' : 'aborted_by_validation')
537
+ : (converged ? 'converged' : 'max_rounds_reached')
500
538
  await agent(
501
539
  '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"}.',
502
540
  { label: 'transcript:capture' }
@@ -508,6 +546,7 @@ return {
508
546
  maxRounds: maxRounds,
509
547
  converged: converged,
510
548
  abortedByValidation: abortedByValidation,
549
+ schemaFailed: schemaFailed,
511
550
  failedCommands: failedCommands,
512
551
  configErrors: configErrors,
513
552
  stoppedReason: stopReason,
@@ -528,12 +567,12 @@ return {
528
567
  Key rules for normal mode:
529
568
  - 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.
530
569
  - Aggregate the current round deterministically (\`report.findings\`) before fixing, so fixes act on deduped/filtered/sorted findings.
531
- - **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).
570
+ - **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.
532
571
  - 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.
533
572
  - **Resume**: load the checkpoint first; if a previous run left one, continue from \`checkpoint.round + 1\` (its \`fixedCount\` and deduped \`findings\` are carried forward).
534
573
  - **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.
535
574
  - **Checkpoint after every round**; clear it only when the iteration completes cleanly.
536
- - Stop when a round produces nothing to fix (converged) or maxReviewRounds is reached.
575
+ - 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"\`).
537
576
  - Every round, every rollback, and the final report go to the append-only decision log.
538
577
  - Close with \`iterate_status\` metrics and surface the convergence indicators (fixed count, remaining architectural count, abort reason) in the final summary.
539
578
 
@@ -148,9 +148,16 @@ export function computeStatus(input) {
148
148
  findingsCount: Array.isArray(checkpoint?.findings) ? checkpoint.findings.length : 0,
149
149
  totalDecisionLogEntries: entries.length,
150
150
  hasCheckpoint: checkpoint !== null,
151
- // A checkpoint left on disk means the previous run was interrupted before
152
- // it could clear it — this is the durable "interruption" signal.
153
- interrupted: checkpoint !== null,
151
+ // A checkpoint left on disk is the durable "the run did not finish" signal
152
+ // — EXCEPT when a decision-log entry is NEWER than the checkpoint: the
153
+ // workflow saves a checkpoint at the end of each round (right after that
154
+ // round's review_result log), so a live run proceeding past its last
155
+ // checkpoint would otherwise be flagged as "interrupted" for its whole
156
+ // duration. A fresh checkpoint (or no log at all) is genuinely leftover.
157
+ interrupted: checkpoint !== null &&
158
+ !(lastEntry != null &&
159
+ checkpoint.updatedAt &&
160
+ (Date.parse(checkpoint.updatedAt) || 0) >= (Date.parse(lastEntry.timestamp) || 0)),
154
161
  resumeCount: checkpoint?.resumeCount ?? 0,
155
162
  checkpoint,
156
163
  lastUpdated,
@@ -315,7 +322,7 @@ export function registerStatusTool(ctx) {
315
322
  findingsCount: { type: 'integer' },
316
323
  totalDecisionLogEntries: { type: 'integer' },
317
324
  hasCheckpoint: { type: 'boolean' },
318
- interrupted: { type: 'boolean', description: 'True when a checkpoint exists, meaning the previous run was interrupted before finishing.' },
325
+ 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.' },
319
326
  resumeCount: { type: 'integer', description: 'How many times the current checkpoint has already been resumed.' },
320
327
  lastUpdated: { oneOf: [{ type: 'string' }, { type: 'null' }] },
321
328
  qualityGate: { type: 'json', description: 'v3.0: persisted quality-gate snapshot (.iterate/quality-gate.json), when present.' },
@@ -1,9 +1,127 @@
1
- import { appendFileSync, readFileSync, mkdirSync, existsSync } from 'node:fs';
1
+ import { appendFileSync, readFileSync, mkdirSync, existsSync, openSync, writeSync, closeSync, unlinkSync, statSync, } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { defineTool } from '@deepseek-ai/dsh-tools';
4
4
  import { resolveProjectRootForExec } from "../config-loader.js";
5
5
  const LOG_DIR = '.iterate';
6
6
  const LOG_FILE = 'decision-log.jsonl';
7
+ // ─── Cross-process log mutex ────────────────────────────────────────────────
8
+ //
9
+ // The prune rewrite (temp + atomic rename) can silently discard an audit line
10
+ // from a CONCURRENT appender whose fd was opened against the pre-rename inode:
11
+ // the appender writes to the unlinked inode, the post-rewrite re-read sees a
12
+ // "stable" file, and the fresh entry is lost. In-process safety is guaranteed
13
+ // by synchronous I/O, but a second plugin process appending to the same
14
+ // project realises the race. The log is serialized with a tiny advisory lock
15
+ // file (exclusive create, pid stamped, stale-stealable) so `append` and the
16
+ // prune rewrite mutually exclude across processes. Best-effort: contention
17
+ // timeouts degrade to "proceed unlocked" (the bounded retry loop in prune.ts
18
+ // stays as defense-in-depth), never to a crash.
19
+ const LOCK_FILE = '.decision-log.lock';
20
+ const LOCK_WAIT_MS = 5000;
21
+ const LOCK_STALE_MS = 10000;
22
+ const LOCK_POLL_MS = 25;
23
+ /** True when a pid refers to a live process (or we lack permission to tell). */
24
+ function processAlive(pid) {
25
+ if (!Number.isInteger(pid) || pid <= 0)
26
+ return false;
27
+ try {
28
+ process.kill(pid, 0);
29
+ return true;
30
+ }
31
+ catch (err) {
32
+ return err.code === 'EPERM';
33
+ }
34
+ }
35
+ /** Synchronous busy-wait sleep (Atomics.wait is a reliable sleep in Node). */
36
+ function sleepSync(ms) {
37
+ const sab = new Int32Array(new SharedArrayBuffer(4));
38
+ Atomics.wait(sab, 0, 0, ms);
39
+ }
40
+ /**
41
+ * Acquire the decision-log lock for `projectRoot`.
42
+ * Returns a release function (always callable; a no-op when the lock could
43
+ * not be taken — never wedge an append behind a vanished holder).
44
+ */
45
+ export function acquireLogLock(projectRoot) {
46
+ const dir = join(projectRoot, LOG_DIR);
47
+ if (!existsSync(dir)) {
48
+ try {
49
+ mkdirSync(dir, { recursive: true });
50
+ }
51
+ catch {
52
+ return () => { };
53
+ }
54
+ }
55
+ const lockPath = join(dir, LOCK_FILE);
56
+ const start = Date.now();
57
+ for (;;) {
58
+ let fd = null;
59
+ try {
60
+ fd = openSync(lockPath, 'wx');
61
+ }
62
+ catch (err) {
63
+ if (err.code !== 'EEXIST')
64
+ return () => { };
65
+ // Lock exists — steal it when the owner is gone or the lock is ancient.
66
+ let stale = false;
67
+ try {
68
+ const st = statSync(lockPath);
69
+ if (Date.now() - st.mtimeMs > LOCK_STALE_MS)
70
+ stale = true;
71
+ else {
72
+ const pid = Number(readFileSync(lockPath, 'utf-8'));
73
+ if (!processAlive(pid))
74
+ stale = true;
75
+ }
76
+ }
77
+ catch {
78
+ stale = true; // unreadable/vanishing lock → retry as stale
79
+ }
80
+ if (stale) {
81
+ try {
82
+ unlinkSync(lockPath);
83
+ }
84
+ catch { /* another holder stole it */ }
85
+ continue;
86
+ }
87
+ if (Date.now() - start > LOCK_WAIT_MS)
88
+ return () => { };
89
+ sleepSync(LOCK_POLL_MS);
90
+ continue;
91
+ }
92
+ // Owned the lock: stamp the holder pid, then release once on return.
93
+ try {
94
+ writeSync(fd, String(process.pid));
95
+ }
96
+ catch { /* pid stamp is advisory */ }
97
+ try {
98
+ closeSync(fd);
99
+ }
100
+ catch { /* best-effort */ }
101
+ let released = false;
102
+ return () => {
103
+ if (released)
104
+ return;
105
+ released = true;
106
+ try {
107
+ unlinkSync(lockPath);
108
+ }
109
+ catch { /* already gone */ }
110
+ };
111
+ }
112
+ }
113
+ // ─── Entry-count cache (avoid an O(n) full re-read per append) ─────────────
114
+ const entryCountCache = new Map();
115
+ /** Line-count the log file from scratch (entries = non-empty JSON lines). */
116
+ function recountEntries(filePath) {
117
+ try {
118
+ const content = readFileSync(filePath, 'utf-8');
119
+ return content.split('\n').filter((l) => l.trim().length > 0).length;
120
+ }
121
+ catch {
122
+ return 1;
123
+ }
124
+ }
7
125
  /** All valid DecisionLogEntry `type` values (must stay in sync with Types). */
8
126
  const VALID_ENTRY_TYPES = new Set([
9
127
  'round_start',
@@ -50,25 +168,32 @@ function logPath(projectRoot) {
50
168
  * miss without failing the mutation they already performed.
51
169
  */
52
170
  export function appendDecisionEntry(projectRoot, entry) {
53
- let filePath;
54
- try {
55
- filePath = logPath(projectRoot);
56
- const line = JSON.stringify(entry) + '\n';
57
- appendFileSync(filePath, line, 'utf-8');
58
- }
59
- catch (err) {
60
- return { count: 0, path: join(projectRoot, LOG_DIR, LOG_FILE), error: `failed to append decision log: ${String(err)}` };
61
- }
62
- // Count entries
63
- let count = 0;
171
+ // Serialize with the cross-process log lock so an append can never interleave
172
+ // with a prune rewrite (which renames the file under us).
173
+ const release = acquireLogLock(projectRoot);
64
174
  try {
65
- const content = readFileSync(filePath, 'utf-8');
66
- count = content.split('\n').filter((l) => l.trim().length > 0).length;
175
+ const filePath = logPath(projectRoot);
176
+ try {
177
+ const line = JSON.stringify(entry) + '\n';
178
+ const prevSize = existsSync(filePath) ? statSync(filePath).size : 0;
179
+ appendFileSync(filePath, line, 'utf-8');
180
+ // Count entries via the size/count cache: when nothing else changed the
181
+ // file since our last append (same byte size), count is just +1 — no full
182
+ // re-read. Falls back to a full re-count whenever the cache is stale.
183
+ const cached = entryCountCache.get(filePath);
184
+ const count = cached && cached.size === prevSize
185
+ ? cached.count + 1
186
+ : recountEntries(filePath);
187
+ entryCountCache.set(filePath, { size: existsSync(filePath) ? statSync(filePath).size : prevSize, count });
188
+ return { count, path: filePath };
189
+ }
190
+ catch (err) {
191
+ return { count: 0, path: join(projectRoot, LOG_DIR, LOG_FILE), error: `failed to append decision log: ${String(err)}` };
192
+ }
67
193
  }
68
- catch {
69
- count = 1;
194
+ finally {
195
+ release();
70
196
  }
71
- return { count, path: filePath };
72
197
  }
73
198
  /**
74
199
  * Read all entries from the decision log, plus the count of corrupt lines.
package/dist/tools/fix.js CHANGED
@@ -364,6 +364,7 @@ export function registerFixTool(ctx) {
364
364
  linesRemoved: { type: 'integer' },
365
365
  diffSummary: { type: 'string' },
366
366
  backupPath: { type: 'string' },
367
+ warning: { type: 'string' },
367
368
  error: { type: 'string' },
368
369
  },
369
370
  },
@@ -451,6 +452,16 @@ export function registerFixTool(ctx) {
451
452
  if (findFixRecord(registry, id)) {
452
453
  return { ok: false, error: `finding already fixed this run (id: ${id})`, id };
453
454
  }
455
+ // No-op guard: content-identical "fixes" (e.g. a fixer that re-sent the
456
+ // file unchanged) must never burn a backup, a write, or a registry/success
457
+ // record. Placed after the registry check so a re-sent fix of an id that
458
+ // was ALREADY fixed is still reported as "already fixed this run".
459
+ if (added === 0 && removed === 0) {
460
+ return {
461
+ ok: false,
462
+ error: `no changes: the supplied content for ${file} is identical to the current content — apply a real edit`,
463
+ };
464
+ }
454
465
  const target = resolveProjectFile(projectRoot, file);
455
466
  if (!target.ok)
456
467
  return { ok: false, error: target.reason };
@@ -521,12 +532,15 @@ export function registerFixTool(ctx) {
521
532
  }
522
533
  return { ok: false, error: `failed to write fix registry: ${String(err)} (file restored from backup)` };
523
534
  }
524
- appendDecisionEntry(projectRoot, {
535
+ const logRes = appendDecisionEntry(projectRoot, {
525
536
  timestamp,
526
537
  round: args.round,
527
538
  type: 'atomic_fix',
528
539
  data: { id, file, finding: finding.summary, linesAdded: added, linesRemoved: removed },
529
540
  });
541
+ // The fix itself succeeded, but a decision-log write failure would leave
542
+ // the audit trail incomplete — surface it (warning, not fatal) so the
543
+ // model/UI knows the record was not persisted.
530
544
  return {
531
545
  ok: true,
532
546
  id,
@@ -536,6 +550,7 @@ export function registerFixTool(ctx) {
536
550
  linesRemoved: removed,
537
551
  diffSummary: record.diffSummary,
538
552
  backupPath,
553
+ ...(logRes.error ? { warning: logRes.error } : {}),
539
554
  };
540
555
  });
541
556
  return result;
@@ -696,6 +711,7 @@ export function registerRollbackTool(ctx) {
696
711
  ok: { type: 'boolean', required: true },
697
712
  id: { type: 'string' },
698
713
  file: { type: 'string' },
714
+ warning: { type: 'string' },
699
715
  error: { type: 'string' },
700
716
  },
701
717
  },
@@ -736,7 +752,7 @@ export function registerRollbackTool(ctx) {
736
752
  catch (err) {
737
753
  return { ok: false, error: `failed to update fix registry: ${String(err)}` };
738
754
  }
739
- appendDecisionEntry(projectRoot, {
755
+ const logRes = appendDecisionEntry(projectRoot, {
740
756
  timestamp: new Date().toISOString(),
741
757
  round: record.round,
742
758
  type: 'revert',
@@ -747,7 +763,12 @@ export function registerRollbackTool(ctx) {
747
763
  // a missing/corrupt transcript is left untouched and never breaks the
748
764
  // rollback flow.
749
765
  await markFixRolledBackInTranscript(projectRoot, id);
750
- return { ok: true, id, file: record.finding.file };
766
+ return {
767
+ ok: true,
768
+ id,
769
+ file: record.finding.file,
770
+ ...(logRes.error ? { warning: logRes.error } : {}),
771
+ };
751
772
  },
752
773
  }));
753
774
  }
@@ -84,7 +84,7 @@ export function registerHistoryTool(ctx) {
84
84
  },
85
85
  limit: {
86
86
  type: 'integer',
87
- description: `Max entries to return (default: ${DEFAULT_LIMIT}, cap: ${MAX_LIMIT}). Newest first.`,
87
+ description: `Max entries to return (default: ${DEFAULT_LIMIT}, cap: ${MAX_LIMIT}). Returns the newest window in chronological order.`,
88
88
  },
89
89
  path: {
90
90
  type: 'string',
@@ -23,12 +23,12 @@
23
23
  * - dryRun=true by default — the caller must explicitly opt into deletion.
24
24
  * - Each deletion is logged to the decision log (when not dry-run).
25
25
  */
26
- import { existsSync, readdirSync, rmSync, unlinkSync } from 'node:fs';
26
+ import { existsSync, readdirSync, rmSync, unlinkSync, statSync } from 'node:fs';
27
27
  import { join } from 'node:path';
28
28
  import { defineTool } from '@deepseek-ai/dsh-tools';
29
29
  import { resolveProjectRootForExec } from "../config-loader.js";
30
30
  import { writeTextAtomic, writeJsonAtomic } from "../atomic-fs.js";
31
- import { readDecisionEntries, appendDecisionEntry } from "./decision-log.js";
31
+ import { readDecisionEntries, appendDecisionEntry, acquireLogLock } from "./decision-log.js";
32
32
  import { readRegistry, recomputeRoundCounts } from "./fix.js";
33
33
  import { readExperienceBank, writeExperienceBank } from "./experience-store.js";
34
34
  import { readDefenseEvents, writeDefenseEvents } from "./defense-store.js";
@@ -143,8 +143,22 @@ export function inspectPrune(projectRoot, retainDays) {
143
143
  // 1. Decision-log entries older than retainDays.
144
144
  const entries = readDecisionEntries(projectRoot);
145
145
  const oldLogEntries = entries.filter((e) => e.timestamp < cutoff).length;
146
- // 2. Checkpoint presence.
146
+ // 2. Checkpoint presence. A checkpoint is only STALE once it is older than
147
+ // retainDays — a fresh checkpoint means a run was interrupted recently and
148
+ // may still be resumed, so `hasCheckpoint` is kept as pure existence while
149
+ // `checkpointStale` decides whether a prune actually deletes it.
147
150
  const hasCheckpoint = existsSync(checkpointPath(projectRoot));
151
+ let checkpointStale = false;
152
+ if (hasCheckpoint) {
153
+ try {
154
+ const st = statSync(checkpointPath(projectRoot));
155
+ if (st && st.mtimeMs < Date.parse(cutoff))
156
+ checkpointStale = true;
157
+ }
158
+ catch {
159
+ checkpointStale = true; // unreadable mtime — treat as garbage / deletable
160
+ }
161
+ }
148
162
  // 3. Stale fix backups: .bak files whose fix-id prefix is not in the registry.
149
163
  const registry = readRegistry(projectRoot);
150
164
  const activeIds = new Set();
@@ -204,6 +218,7 @@ export function inspectPrune(projectRoot, retainDays) {
204
218
  return {
205
219
  oldLogEntries,
206
220
  hasCheckpoint,
221
+ checkpointStale,
207
222
  staleBackups,
208
223
  staleTemps,
209
224
  emptyRounds,
@@ -234,26 +249,36 @@ export function inspectPrune(projectRoot, retainDays) {
234
249
  */
235
250
  function rewriteDecisionLogKeepingRecent(projectRoot, cutoff) {
236
251
  const logPath = join(iterateDir(projectRoot), 'decision-log.jsonl');
237
- for (let attempt = 0; attempt < MAX_LOG_REWRITE_RETRIES; attempt++) {
238
- try {
239
- const entries = readDecisionEntries(projectRoot);
240
- const kept = entries.filter((e) => e.timestamp >= cutoff);
241
- const deleted = entries.length - kept.length;
242
- if (deleted === 0)
243
- return { deleted: 0 };
244
- writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n');
245
- // A concurrent appender may have landed new lines after our read. If the
246
- // log grew during the write window, re-read and prune again instead of
247
- // accepting a lost audit trail.
248
- const after = readDecisionEntries(projectRoot);
249
- if (after.length <= kept.length)
250
- return { deleted };
251
- }
252
- catch (err) {
253
- return { deleted: 0, error: `failed to rewrite decision log: ${String(err)}` };
252
+ // Take the cross-process log lock for the ENTIRE loop: with the mutex held,
253
+ // no concurrent appender can slip a fresh line into the read-before-rewrite
254
+ // window, so the first attempt is nearly always the only one. (The retry
255
+ // loop stays as defense-in-depth for the lock-unavailable path.)
256
+ const release = acquireLogLock(projectRoot);
257
+ try {
258
+ for (let attempt = 0; attempt < MAX_LOG_REWRITE_RETRIES; attempt++) {
259
+ try {
260
+ const entries = readDecisionEntries(projectRoot);
261
+ const kept = entries.filter((e) => e.timestamp >= cutoff);
262
+ const deleted = entries.length - kept.length;
263
+ if (deleted === 0)
264
+ return { deleted: 0 };
265
+ writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n');
266
+ // A concurrent appender (cross-process, lock-unavailable path) may have
267
+ // landed new lines after our read. If the log grew during the write
268
+ // window, re-read and prune again instead of accepting a lost audit trail.
269
+ const after = readDecisionEntries(projectRoot);
270
+ if (after.length <= kept.length)
271
+ return { deleted };
272
+ }
273
+ catch (err) {
274
+ return { deleted: 0, error: `failed to rewrite decision log: ${String(err)}` };
275
+ }
254
276
  }
277
+ return { deleted: 0, error: `failed to rewrite decision log after ${MAX_LOG_REWRITE_RETRIES} attempts` };
278
+ }
279
+ finally {
280
+ release();
255
281
  }
256
- return { deleted: 0, error: `failed to rewrite decision log after ${MAX_LOG_REWRITE_RETRIES} attempts` };
257
282
  }
258
283
  /**
259
284
  * Execute the prune: apply every cleanable item reported by `inspectPrune`.
@@ -282,8 +307,10 @@ export function executePrune(projectRoot, retainDays, report) {
282
307
  if (error)
283
308
  result.errors.push(error);
284
309
  }
285
- // 2. Remove checkpoint.
286
- if (report.hasCheckpoint) {
310
+ // 2. Remove checkpoint — ONLY when it is actually stale (older than
311
+ // retainDays). A fresh checkpoint is a live resume point for a recently
312
+ // interrupted run; pruning it would silently destroy the ability to resume.
313
+ if (report.checkpointStale) {
287
314
  try {
288
315
  rmSync(checkpointPath(projectRoot), { force: true });
289
316
  result.deletedCheckpoint = true;
@@ -412,7 +439,7 @@ export function registerPruneTool(ctx) {
412
439
  const lines = [
413
440
  `[dry-run] prune report (retainDays=${value.retainDays}):`,
414
441
  ` Decision-log entries to remove: ${report?.oldLogEntries ?? '?'} (of ${report?.totalLogEntries ?? '?'})`,
415
- ` Checkpoint to delete: ${report?.hasCheckpoint ? 'yes' : 'none'}`,
442
+ ` Checkpoint to delete: ${report?.checkpointStale ? 'yes (stale)' : report?.hasCheckpoint ? 'no (fresh — resume point, kept)' : 'none'}`,
416
443
  ` Stale backups to delete: ${report?.staleBackups?.length ?? 0}`,
417
444
  ` Stray temp files to delete: ${report?.staleTemps?.length ?? 0}`,
418
445
  ` Empty rounds to trim: ${report?.emptyRounds?.length ?? 0}`,
@@ -459,8 +486,10 @@ export function registerPruneTool(ctx) {
459
486
  };
460
487
  }
461
488
  const result = executePrune(projectRoot, retainDays, report);
462
- // Log the prune to the decision log.
463
- appendDecisionEntry(projectRoot, {
489
+ // Log the prune to the decision log. The mutations already happened, so
490
+ // a failed log append is reported via `errors` — a silent audit miss
491
+ // (F2) must never pretend every deletion was recorded.
492
+ const logRes = appendDecisionEntry(projectRoot, {
464
493
  timestamp: new Date().toISOString(),
465
494
  round: 0,
466
495
  type: 'decision',
@@ -475,6 +504,8 @@ export function registerPruneTool(ctx) {
475
504
  deletedDefenseEvents: result.deletedDefenseEvents,
476
505
  },
477
506
  });
507
+ if (logRes.error)
508
+ result.errors.push(logRes.error);
478
509
  return {
479
510
  ok: true,
480
511
  dryRun: false,