iterate-plugin 3.5.6 → 3.5.7

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.
@@ -84,6 +84,9 @@ const rounds = [] // raw per-round findings
84
84
  // round where every reviewer subagent failed (or output stayed schema-invalid
85
85
  // after retries) is INCONCLUSIVE — it must never make the run look converged.
86
86
  let lastRoundOk = true
87
+ // Slot index of the current round within this run's rounds array. A resumed
88
+ // run starts at startRound > 1, so the round NUMBER is not a valid array index.
89
+ let roundSlot = -1
87
90
 
88
91
  phase('transcript')
89
92
  // Read any steering nudge written (via iterate_transcript nudge) for this run's reviewers.
@@ -96,9 +99,14 @@ const steering = transRead && typeof transRead.nudge === 'string' && transRead.n
96
99
  phase('review')
97
100
  for (let r = 1; r <= maxRounds; r++) {
98
101
  log('round ' + r + ' of ' + maxRounds + ' — finding NEW issues only')
102
+ roundSlot = rounds.length
99
103
  let agg = null
100
104
  let schemaInvalid = false
101
105
  let attempts = 0
106
+ // Declared OUTSIDE the do-block: it is read after the retry loop exits, and a
107
+ // block-scoped const here raised ReferenceError at runtime (crashing both
108
+ // canonical workflows after the first round).
109
+ let reviewersOk = true
102
110
  do {
103
111
  attempts += 1
104
112
  // Schema validation retry: on the 2nd+ pass, nudge reviewers toward strict JSON.
@@ -126,7 +134,7 @@ for (let r = 1; r <= maxRounds; r++) {
126
134
  // the convergence math would then read "0 new findings". An all-failed
127
135
  // round is NOT a clean pass — it is inconclusive, so the convergence gate
128
136
  // below refuses it before any "converged" signal fires.
129
- const reviewersOk = raw.length > 0 ? raw.some(x => x !== null && typeof x === 'object') : true
137
+ reviewersOk = raw.length > 0 ? raw.some(x => x !== null && typeof x === 'object') : true
130
138
  const thisRound = {
131
139
  round: r,
132
140
  findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])),
@@ -134,7 +142,7 @@ for (let r = 1; r <= maxRounds; r++) {
134
142
  // gate can compare self-reported reads against the assigned inventory.
135
143
  readFiles: [].concat(...raw.map(x => x && Array.isArray(x.readFiles) ? x.readFiles : [])),
136
144
  }
137
- if (rounds.length >= r) rounds[r - 1] = thisRound; else rounds.push(thisRound)
145
+ if (roundSlot < rounds.length) rounds[roundSlot] = thisRound; else rounds.push(thisRound)
138
146
  // Deterministic aggregate: cross-round dedupe + known_intentional filter + severity sort.
139
147
  agg = await agent(
140
148
  'Call iterate_review({operation:"aggregate", mode:"dry-run", rounds:' + JSON.stringify(rounds) + ', maxReviewRounds:' + maxRounds + ', knownIntentional:' + JSON.stringify(knownIntentional) + '}) and return the report JSON.',
@@ -299,6 +307,12 @@ let schemaFailed = false // last round produced no usable reviewer output
299
307
  let failedCommands = []
300
308
  let configErrors = [] // validation commands NOT in validation.commands (config gap, no rollback)
301
309
  const fixRecords = [] // observatory fix records collected round by round
310
+ // Slot index of the current round within this run's rounds array. A resumed
311
+ // run starts at startRound > 1, so the round NUMBER is not a valid array index.
312
+ let roundSlot = -1
313
+ // Highest round number actually executed by THIS run (carried so a resumed run
314
+ // reports its true round, not the count of rounds it happened to run).
315
+ let lastRound = startRound - 1
302
316
 
303
317
  // Read any steering nudge intended for this run's reviewers.
304
318
  const transRead = await agent(
@@ -310,6 +324,8 @@ const steering = transRead && typeof transRead.nudge === 'string' && transRead.n
310
324
  phase('loop')
311
325
  for (let r = startRound; r <= maxRounds; r++) {
312
326
  log('round ' + r + ' of ' + maxRounds + ' — review current state, fix atomics via iterate_fix, validate')
327
+ roundSlot = rounds.length
328
+ lastRound = r
313
329
  // Audit-trail: record the round start (SKILL.md Phase 4 requires per-round records).
314
330
  await agent(
315
331
  'Call iterate_decision_log({operation:"append", type:"round_start", round:' + r + ', data:{maxRounds:' + maxRounds + ', fixedSoFar:' + fixedCount + '}})',
@@ -318,6 +334,10 @@ for (let r = startRound; r <= maxRounds; r++) {
318
334
  let agg = null
319
335
  let schemaInvalid = false
320
336
  let attempts = 0
337
+ // Declared OUTSIDE the do-block: it is read after the retry loop exits, and a
338
+ // block-scoped const here raised ReferenceError at runtime (crashing both
339
+ // canonical workflows after the first round).
340
+ let reviewersOk = true
321
341
  do {
322
342
  attempts += 1
323
343
  // Schema validation retry: on the 2nd+ pass, nudge reviewers toward strict JSON.
@@ -341,13 +361,13 @@ for (let r = startRound; r <= maxRounds; r++) {
341
361
  // ALL reviewers returning null (child failures) means this round produced
342
362
  // no usable output — an empty aggregate must not read like a clean
343
363
  // "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
364
+ reviewersOk = raw.length > 0 ? raw.some(x => x !== null && typeof x === 'object') : true
345
365
  const thisRound = {
346
366
  round: r,
347
367
  findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])),
348
368
  readFiles: [].concat(...raw.map(x => x && Array.isArray(x.readFiles) ? x.readFiles : [])),
349
369
  }
350
- if (rounds.length >= r) rounds[r - 1] = thisRound; else rounds.push(thisRound)
370
+ if (roundSlot < rounds.length) rounds[roundSlot] = thisRound; else rounds.push(thisRound)
351
371
 
352
372
  // Deterministic dedupe / known_intentional filter / severity sort for this round.
353
373
  // \`fixedCount\` is threaded into the report summary so the client dashboard can
@@ -502,7 +522,7 @@ for (let r = startRound; r <= maxRounds; r++) {
502
522
 
503
523
  phase('report')
504
524
  await agent(
505
- 'Call iterate_decision_log({operation:"append", type:"report", round:' + rounds.length +
525
+ 'Call iterate_decision_log({operation:"append", type:"report", round:' + lastRound +
506
526
  ', data:{mode:"normal", fixed:' + fixedCount + ', architectural:' + architectural.length + '}})',
507
527
  { label: 'report:log' }
508
528
  )
@@ -525,7 +545,7 @@ if (!abortedByValidation && !schemaFailed) {
525
545
  // would hide the very thing F5 is meant to resume. Reflect what is on disk.
526
546
  const obsCheckpoint = {
527
547
  mode: 'normal',
528
- round: rounds.length,
548
+ round: lastRound,
529
549
  maxRounds: maxRounds,
530
550
  fixedCount: fixedCount,
531
551
  resumeCount: effectiveResumeCount,
@@ -536,13 +556,13 @@ if (!abortedByValidation && !schemaFailed) {
536
556
  ? (configErrors.length > 0 ? 'aborted_by_config' : 'aborted_by_validation')
537
557
  : (converged ? 'converged' : 'max_rounds_reached')
538
558
  await agent(
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"}.',
559
+ 'Call iterate_transcript({operation:"capture", mode:"normal", goal:' + JSON.stringify(plan.goal) + ', maxRounds:' + maxRounds + ', roundsExecuted:' + lastRound + ', 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"}.',
540
560
  { label: 'transcript:capture' }
541
561
  )
542
562
  return {
543
563
  mode: 'normal',
544
564
  goal: plan.goal,
545
- roundsExecuted: rounds.length,
565
+ roundsExecuted: lastRound,
546
566
  maxRounds: maxRounds,
547
567
  converged: converged,
548
568
  abortedByValidation: abortedByValidation,
@@ -122,6 +122,17 @@ function recountEntries(filePath) {
122
122
  return 1;
123
123
  }
124
124
  }
125
+ /**
126
+ * Drop the cached entry count for a log file. The cache is keyed by the log
127
+ * path and assumes "same byte size ⇒ same entry count"; a prune rewrite
128
+ * (temp + atomic rename) replaces the file with a DIFFERENT number of entries,
129
+ * so only a stale-cache invalidation keeps the fast path honest. Without it, a
130
+ * rewrite that lands on the exact same byte size as the previous append would
131
+ * make the next append report a wrong `count`.
132
+ */
133
+ export function invalidateLogCountCache(filePath) {
134
+ entryCountCache.delete(filePath);
135
+ }
125
136
  /** All valid DecisionLogEntry `type` values (must stay in sync with Types). */
126
137
  const VALID_ENTRY_TYPES = new Set([
127
138
  'round_start',
@@ -28,7 +28,7 @@ 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, acquireLogLock } from "./decision-log.js";
31
+ import { readDecisionEntries, appendDecisionEntry, acquireLogLock, invalidateLogCountCache } 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";
@@ -263,6 +263,12 @@ function rewriteDecisionLogKeepingRecent(projectRoot, cutoff) {
263
263
  if (deleted === 0)
264
264
  return { deleted: 0 };
265
265
  writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n');
266
+ // The append-side entry-count cache is keyed by path and assumes "same
267
+ // byte size ⇒ same count"; the rename above replaced the file with a
268
+ // different number of entries, so drop the stale cache or the next
269
+ // append can report a wrong entryCount when the sizes coincidentally
270
+ // match.
271
+ invalidateLogCountCache(logPath);
266
272
  // A concurrent appender (cross-process, lock-unavailable path) may have
267
273
  // landed new lines after our read. If the log grew during the write
268
274
  // window, re-read and prune again instead of accepting a lost audit trail.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iterate-plugin",
3
- "version": "3.5.6",
3
+ "version": "3.5.7",
4
4
  "description": "dsh plugin that turns the iterate skill into an autonomous closed-loop harness with quality command center and experience bank (v3.3). Features: plan -> parallel review xN -> atomic fixes -> validate -> loop -> auto-stop, plus dry-run pure-review mode, quality gate compute/persist, writable experience bank, defense events stream (record + bilingual labels), and native command buttons — with a live quality command center (F8/F9/F10 render real session data), §8 assign-fix instruction, and task_mode indicator wired end to end.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -42,7 +42,8 @@
42
42
  "0.1.6-alpha.2": "compatible",
43
43
  "0.1.7-alpha.1": "compatible",
44
44
  "0.1.7-alpha.2": "compatible",
45
- "0.1.7-rc.1": "compatible"
45
+ "0.1.7-rc.1": "compatible",
46
+ "0.1.7-rc.2": "compatible"
46
47
  }
47
48
  },
48
49
  "client": {
@@ -80,14 +81,14 @@
80
81
  },
81
82
  "dependencies": {
82
83
  "@deepseek-ai/cordis": "4.0.4",
83
- "@deepseek-ai/dsh-tools": "0.1.7-rc.1",
84
- "@deepseek-ai/dsh-util-values": "0.1.7-rc.1",
84
+ "@deepseek-ai/dsh-tools": "0.1.7-rc.2",
85
+ "@deepseek-ai/dsh-util-values": "0.1.7-rc.2",
85
86
  "js-yaml": "4.3.2"
86
87
  },
87
88
  "devDependencies": {
88
- "@deepseek-ai/dsh-agent": "0.1.7-rc.1",
89
- "@deepseek-ai/dsh-jobs": "0.1.7-rc.1",
90
- "@deepseek-ai/dsh-session": "0.1.7-rc.1",
89
+ "@deepseek-ai/dsh-agent": "0.1.7-rc.2",
90
+ "@deepseek-ai/dsh-jobs": "0.1.7-rc.2",
91
+ "@deepseek-ai/dsh-session": "0.1.7-rc.2",
91
92
  "@types/js-yaml": "4.0.9",
92
93
  "@types/node": "22.15.0",
93
94
  "@types/react": "19.2.2",
@@ -85,6 +85,9 @@ const rounds = [] // raw per-round findings
85
85
  // round where every reviewer subagent failed (or output stayed schema-invalid
86
86
  // after retries) is INCONCLUSIVE — it must never make the run look converged.
87
87
  let lastRoundOk = true
88
+ // Slot index of the current round within this run's rounds array. A resumed
89
+ // run starts at startRound > 1, so the round NUMBER is not a valid array index.
90
+ let roundSlot = -1
88
91
 
89
92
  phase('transcript')
90
93
  // Read any steering nudge written (via iterate_transcript nudge) for this run's reviewers.
@@ -97,9 +100,14 @@ const steering = transRead && typeof transRead.nudge === 'string' && transRead.n
97
100
  phase('review')
98
101
  for (let r = 1; r <= maxRounds; r++) {
99
102
  log('round ' + r + ' of ' + maxRounds + ' — finding NEW issues only')
103
+ roundSlot = rounds.length
100
104
  let agg = null
101
105
  let schemaInvalid = false
102
106
  let attempts = 0
107
+ // Declared OUTSIDE the do-block: it is read after the retry loop exits, and a
108
+ // block-scoped const here raised ReferenceError at runtime (crashing both
109
+ // canonical workflows after the first round).
110
+ let reviewersOk = true
103
111
  do {
104
112
  attempts += 1
105
113
  // Schema validation retry: on the 2nd+ pass, nudge reviewers toward strict JSON.
@@ -127,7 +135,7 @@ for (let r = 1; r <= maxRounds; r++) {
127
135
  // the convergence math would then read "0 new findings". An all-failed
128
136
  // round is NOT a clean pass — it is inconclusive, so the convergence gate
129
137
  // below refuses it before any "converged" signal fires.
130
- const reviewersOk = raw.length > 0 ? raw.some(x => x !== null && typeof x === 'object') : true
138
+ reviewersOk = raw.length > 0 ? raw.some(x => x !== null && typeof x === 'object') : true
131
139
  const thisRound = {
132
140
  round: r,
133
141
  findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])),
@@ -135,7 +143,7 @@ for (let r = 1; r <= maxRounds; r++) {
135
143
  // gate can compare self-reported reads against the assigned inventory.
136
144
  readFiles: [].concat(...raw.map(x => x && Array.isArray(x.readFiles) ? x.readFiles : [])),
137
145
  }
138
- if (rounds.length >= r) rounds[r - 1] = thisRound; else rounds.push(thisRound)
146
+ if (roundSlot < rounds.length) rounds[roundSlot] = thisRound; else rounds.push(thisRound)
139
147
  // Deterministic aggregate: cross-round dedupe + known_intentional filter + severity sort.
140
148
  agg = await agent(
141
149
  'Call iterate_review({operation:"aggregate", mode:"dry-run", rounds:' + JSON.stringify(rounds) + ', maxReviewRounds:' + maxRounds + ', knownIntentional:' + JSON.stringify(knownIntentional) + '}) and return the report JSON.',
@@ -300,6 +308,12 @@ let schemaFailed = false // last round produced no usable reviewer output
300
308
  let failedCommands = []
301
309
  let configErrors = [] // validation commands NOT in validation.commands (config gap, no rollback)
302
310
  const fixRecords = [] // observatory fix records collected round by round
311
+ // Slot index of the current round within this run's rounds array. A resumed
312
+ // run starts at startRound > 1, so the round NUMBER is not a valid array index.
313
+ let roundSlot = -1
314
+ // Highest round number actually executed by THIS run (carried so a resumed run
315
+ // reports its true round, not the count of rounds it happened to run).
316
+ let lastRound = startRound - 1
303
317
 
304
318
  // Read any steering nudge intended for this run's reviewers.
305
319
  const transRead = await agent(
@@ -311,6 +325,8 @@ const steering = transRead && typeof transRead.nudge === 'string' && transRead.n
311
325
  phase('loop')
312
326
  for (let r = startRound; r <= maxRounds; r++) {
313
327
  log('round ' + r + ' of ' + maxRounds + ' — review current state, fix atomics via iterate_fix, validate')
328
+ roundSlot = rounds.length
329
+ lastRound = r
314
330
  // Audit-trail: record the round start (SKILL.md Phase 4 requires per-round records).
315
331
  await agent(
316
332
  'Call iterate_decision_log({operation:"append", type:"round_start", round:' + r + ', data:{maxRounds:' + maxRounds + ', fixedSoFar:' + fixedCount + '}})',
@@ -319,6 +335,10 @@ for (let r = startRound; r <= maxRounds; r++) {
319
335
  let agg = null
320
336
  let schemaInvalid = false
321
337
  let attempts = 0
338
+ // Declared OUTSIDE the do-block: it is read after the retry loop exits, and a
339
+ // block-scoped const here raised ReferenceError at runtime (crashing both
340
+ // canonical workflows after the first round).
341
+ let reviewersOk = true
322
342
  do {
323
343
  attempts += 1
324
344
  // Schema validation retry: on the 2nd+ pass, nudge reviewers toward strict JSON.
@@ -342,13 +362,13 @@ for (let r = startRound; r <= maxRounds; r++) {
342
362
  // ALL reviewers returning null (child failures) means this round produced
343
363
  // no usable output — an empty aggregate must not read like a clean
344
364
  // "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
365
+ reviewersOk = raw.length > 0 ? raw.some(x => x !== null && typeof x === 'object') : true
346
366
  const thisRound = {
347
367
  round: r,
348
368
  findings: [].concat(...raw.map(x => x && x.findings ? x.findings : [])),
349
369
  readFiles: [].concat(...raw.map(x => x && Array.isArray(x.readFiles) ? x.readFiles : [])),
350
370
  }
351
- if (rounds.length >= r) rounds[r - 1] = thisRound; else rounds.push(thisRound)
371
+ if (roundSlot < rounds.length) rounds[roundSlot] = thisRound; else rounds.push(thisRound)
352
372
 
353
373
  // Deterministic dedupe / known_intentional filter / severity sort for this round.
354
374
  // \`fixedCount\` is threaded into the report summary so the client dashboard can
@@ -503,7 +523,7 @@ for (let r = startRound; r <= maxRounds; r++) {
503
523
 
504
524
  phase('report')
505
525
  await agent(
506
- 'Call iterate_decision_log({operation:"append", type:"report", round:' + rounds.length +
526
+ 'Call iterate_decision_log({operation:"append", type:"report", round:' + lastRound +
507
527
  ', data:{mode:"normal", fixed:' + fixedCount + ', architectural:' + architectural.length + '}})',
508
528
  { label: 'report:log' }
509
529
  )
@@ -526,7 +546,7 @@ if (!abortedByValidation && !schemaFailed) {
526
546
  // would hide the very thing F5 is meant to resume. Reflect what is on disk.
527
547
  const obsCheckpoint = {
528
548
  mode: 'normal',
529
- round: rounds.length,
549
+ round: lastRound,
530
550
  maxRounds: maxRounds,
531
551
  fixedCount: fixedCount,
532
552
  resumeCount: effectiveResumeCount,
@@ -537,13 +557,13 @@ if (!abortedByValidation && !schemaFailed) {
537
557
  ? (configErrors.length > 0 ? 'aborted_by_config' : 'aborted_by_validation')
538
558
  : (converged ? 'converged' : 'max_rounds_reached')
539
559
  await agent(
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"}.',
560
+ 'Call iterate_transcript({operation:"capture", mode:"normal", goal:' + JSON.stringify(plan.goal) + ', maxRounds:' + maxRounds + ', roundsExecuted:' + lastRound + ', 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"}.',
541
561
  { label: 'transcript:capture' }
542
562
  )
543
563
  return {
544
564
  mode: 'normal',
545
565
  goal: plan.goal,
546
- roundsExecuted: rounds.length,
566
+ roundsExecuted: lastRound,
547
567
  maxRounds: maxRounds,
548
568
  converged: converged,
549
569
  abortedByValidation: abortedByValidation,
@@ -121,6 +121,18 @@ function recountEntries(filePath: string): number {
121
121
  }
122
122
  }
123
123
 
124
+ /**
125
+ * Drop the cached entry count for a log file. The cache is keyed by the log
126
+ * path and assumes "same byte size ⇒ same entry count"; a prune rewrite
127
+ * (temp + atomic rename) replaces the file with a DIFFERENT number of entries,
128
+ * so only a stale-cache invalidation keeps the fast path honest. Without it, a
129
+ * rewrite that lands on the exact same byte size as the previous append would
130
+ * make the next append report a wrong `count`.
131
+ */
132
+ export function invalidateLogCountCache(filePath: string): void {
133
+ entryCountCache.delete(filePath)
134
+ }
135
+
124
136
  /** All valid DecisionLogEntry `type` values (must stay in sync with Types). */
125
137
  const VALID_ENTRY_TYPES = new Set<DecisionLogEntry['type']>([
126
138
  'round_start',
@@ -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, acquireLogLock } from './decision-log.ts'
33
+ import { readDecisionEntries, appendDecisionEntry, acquireLogLock, invalidateLogCountCache } 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'
@@ -291,6 +291,12 @@ function rewriteDecisionLogKeepingRecent(projectRoot: string, cutoff: string): {
291
291
  const deleted = entries.length - kept.length
292
292
  if (deleted === 0) return { deleted: 0 }
293
293
  writeTextAtomic(logPath, kept.map((e) => JSON.stringify(e)).join('\n') + '\n')
294
+ // The append-side entry-count cache is keyed by path and assumes "same
295
+ // byte size ⇒ same count"; the rename above replaced the file with a
296
+ // different number of entries, so drop the stale cache or the next
297
+ // append can report a wrong entryCount when the sizes coincidentally
298
+ // match.
299
+ invalidateLogCountCache(logPath)
294
300
  // A concurrent appender (cross-process, lock-unavailable path) may have
295
301
  // landed new lines after our read. If the log grew during the write
296
302
  // window, re-read and prune again instead of accepting a lost audit trail.