iterate-plugin 3.5.6 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +64 -20
  2. package/README.zh-CN.md +53 -17
  3. package/dist/approval-gate.js +143 -23
  4. package/dist/atomic-fs.js +148 -17
  5. package/dist/config-loader.js +509 -34
  6. package/dist/config-write.js +83 -22
  7. package/dist/evidence.js +141 -87
  8. package/dist/file-lock.js +165 -0
  9. package/dist/index.js +54 -28
  10. package/dist/jobs.js +56 -11
  11. package/dist/live.js +52 -13
  12. package/dist/meta-review.js +132 -28
  13. package/dist/method-scope.js +31 -8
  14. package/dist/review-scope.js +32 -9
  15. package/dist/review.js +96 -17
  16. package/dist/session-hooks.js +55 -13
  17. package/dist/skill-prompt.js +417 -67
  18. package/dist/tools/checkpoint.js +85 -19
  19. package/dist/tools/config.js +44 -2
  20. package/dist/tools/context.js +22 -5
  21. package/dist/tools/decision-log.js +122 -27
  22. package/dist/tools/defense-events.js +90 -31
  23. package/dist/tools/defense-store.js +28 -3
  24. package/dist/tools/experience-bank.js +108 -43
  25. package/dist/tools/experience-store.js +54 -4
  26. package/dist/tools/fix.js +347 -119
  27. package/dist/tools/history.js +68 -14
  28. package/dist/tools/present.js +80 -0
  29. package/dist/tools/prune.js +156 -53
  30. package/dist/tools/quality-gate.js +52 -2
  31. package/dist/tools/quality-store.js +17 -1
  32. package/dist/tools/review.js +158 -7
  33. package/dist/tools/transcript.js +173 -36
  34. package/dist/tools/triage.js +67 -12
  35. package/dist/tools/validate.js +115 -24
  36. package/dist/transcript.js +267 -4
  37. package/lib/client.js +945 -181
  38. package/lib/parse.js +760 -57
  39. package/package.json +15 -12
  40. package/src/approval-gate.ts +160 -23
  41. package/src/atomic-fs.ts +152 -17
  42. package/src/client/index.ts +600 -129
  43. package/src/config-loader.ts +542 -35
  44. package/src/config-write.ts +108 -26
  45. package/src/evidence.ts +194 -92
  46. package/src/file-lock.ts +184 -0
  47. package/src/index.ts +53 -29
  48. package/src/jobs.ts +63 -12
  49. package/src/live.ts +48 -11
  50. package/src/meta-review.ts +151 -34
  51. package/src/method-scope.ts +31 -8
  52. package/src/review-scope.ts +33 -10
  53. package/src/review.ts +96 -17
  54. package/src/session-hooks.ts +54 -12
  55. package/src/skill-prompt.ts +417 -67
  56. package/src/tools/checkpoint.ts +81 -19
  57. package/src/tools/config.ts +44 -2
  58. package/src/tools/context.ts +25 -5
  59. package/src/tools/decision-log.ts +127 -22
  60. package/src/tools/defense-events.ts +87 -31
  61. package/src/tools/defense-store.ts +30 -3
  62. package/src/tools/experience-bank.ts +112 -44
  63. package/src/tools/experience-store.ts +54 -5
  64. package/src/tools/fix.ts +258 -20
  65. package/src/tools/history.ts +74 -15
  66. package/src/tools/present.ts +89 -0
  67. package/src/tools/prune.ts +160 -51
  68. package/src/tools/quality-gate.ts +57 -2
  69. package/src/tools/quality-store.ts +18 -1
  70. package/src/tools/review.ts +155 -6
  71. package/src/tools/transcript.ts +178 -36
  72. package/src/tools/triage.ts +70 -13
  73. package/src/tools/validate.ts +138 -32
  74. package/src/transcript.ts +279 -4
  75. package/src/types.ts +29 -1
@@ -20,6 +20,7 @@ import { readDecisionEntries } from './decision-log.ts'
20
20
  import { readQualityGate } from './quality-store.ts'
21
21
  import { readExperienceBank } from './experience-store.ts'
22
22
  import { readDefenseEvents } from './defense-store.ts'
23
+ import { asNumber, asRecord, parseRenderedJson } from './present.ts'
23
24
  import type { DefenseEventType, IterationCheckpoint, IterationStatus, QualityGateSnapshot } from '../types.ts'
24
25
 
25
26
  // ─── Pure helpers (exported for unit tests) ─────────────────────────────────
@@ -28,6 +29,23 @@ import type { DefenseEventType, IterationCheckpoint, IterationStatus, QualityGat
28
29
  * bounded, mirroring MAX_FIX_CONTENT_CHARS for fix content). */
29
30
  export const MAX_CHECKPOINT_FINDINGS = 1000
30
31
 
32
+ /**
33
+ * Sanity cap for round/counter fields. Status output fields are declared
34
+ * `type: 'integer'` in the tool schema; flooring + capping here guarantees a
35
+ * hand-edited value can never reach dsh's post-execute output validation as a
36
+ * float (INVALID_TOOL_OUTPUT on iterate_status).
37
+ */
38
+ const MAX_COUNTER = 1_000_000_000
39
+
40
+ /**
41
+ * Coerce a numeric field to a non-negative, in-cap INTEGER.
42
+ * Non-numeric (string/NaN/undefined) → `fallback`.
43
+ */
44
+ function toInteger(v: unknown, fallback: number): number {
45
+ if (typeof v !== 'number' || !Number.isFinite(v)) return fallback
46
+ return Math.min(Math.max(Math.floor(v), 0), MAX_COUNTER)
47
+ }
48
+
31
49
  /** Read the current checkpoint from disk (missing/corrupt → null). */
32
50
  export function readCheckpoint(projectRoot: string): IterationCheckpoint | null {
33
51
  const file = checkpointPath(projectRoot)
@@ -38,23 +56,27 @@ export function readCheckpoint(projectRoot: string): IterationCheckpoint | null
38
56
  if (parsed.mode !== 'dry-run' && parsed.mode !== 'normal') return null
39
57
  // Strict round gate: non-numeric (incl. NaN — `NaN <= 0` is false and
40
58
  // `NaN < 0` also false) round values must not survive into the status.
59
+ // The floor also kills `round: 2.5` — validateCheckpoint only ever SAVES
60
+ // integers, so a non-integer on disk is hand-edited/corrupt.
41
61
  const round =
42
62
  typeof parsed.round === 'number' && Number.isFinite(parsed.round)
43
63
  ? Math.floor(parsed.round)
44
64
  : null
45
- if (round === null || round < 0) return null
46
- const num = (v: unknown, fallback: number): number =>
47
- typeof v === 'number' && Number.isFinite(v) ? v : fallback
65
+ if (round === null || round < 0 || round > MAX_COUNTER) return null
48
66
  // Normalize every field so a hand-edited/corrupt checkpoint (string
49
- // maxRounds, missing fixedCount, …) can never leak a non-numeric value
50
- // into the status output or crash a consumer.
67
+ // maxRounds, float fixedCount, missing architecturalCount, …) can never
68
+ // leak a non-integer value into the status output or crash a consumer.
69
+ const maxRounds = toInteger(parsed.maxRounds, round)
70
+ // Consistent with validateCheckpoint's save gate: a checkpoint claiming a
71
+ // round past its cap is corrupt — otherwise status renders "Round 99 / 5".
72
+ if (round > maxRounds) return null
51
73
  return {
52
74
  mode: parsed.mode,
53
75
  round,
54
- maxRounds: num(parsed.maxRounds, round),
55
- fixedCount: num(parsed.fixedCount, 0),
56
- architecturalCount: num(parsed.architecturalCount, 0),
57
- resumeCount: num(parsed.resumeCount, 0),
76
+ maxRounds,
77
+ fixedCount: toInteger(parsed.fixedCount, 0),
78
+ architecturalCount: toInteger(parsed.architecturalCount, 0),
79
+ resumeCount: toInteger(parsed.resumeCount, 0),
58
80
  findings: Array.isArray(parsed.findings) ? (parsed.findings as IterationCheckpoint['findings']) : [],
59
81
  startedAt: typeof parsed.startedAt === 'string' ? parsed.startedAt : new Date().toISOString(),
60
82
  updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : new Date().toISOString(),
@@ -146,24 +168,30 @@ export function computeStatus(input: {
146
168
  const lastUpdated = lastEntry?.timestamp ?? checkpoint?.updatedAt ?? null
147
169
 
148
170
  // Round = checkpoint.round (explicit) or max round seen in the decision log.
149
- let currentRound = checkpoint?.round ?? 0
150
- if (!checkpoint) {
171
+ // Everything is funneled through toInteger: a hand-edited `round: 2.5` entry
172
+ // or an injected float checkpoint must never surface as a non-integer in the
173
+ // status output (schema `type: 'integer'`).
174
+ let currentRound = 0
175
+ if (checkpoint) {
176
+ currentRound = toInteger(checkpoint.round, 0)
177
+ } else {
151
178
  for (const e of entries) {
152
- if (typeof e.round === 'number' && e.round > currentRound) currentRound = e.round
179
+ const r = toInteger(e.round, 0)
180
+ if (r > currentRound) currentRound = r
153
181
  }
154
182
  }
155
183
 
156
- const totalRounds = checkpoint?.maxRounds ?? currentRound
184
+ const totalRounds = checkpoint ? toInteger(checkpoint.maxRounds, currentRound) : currentRound
157
185
  // Coerce the registry's per-round counts defensively: a hand-edited registry
158
186
  // round missing fixedCount/failedCount would otherwise feed NaN into the
159
- // status integer fields.
160
- const registryFixed = registry.rounds.reduce((sum, r) => sum + (Number(r.fixedCount) || 0), 0)
161
- const failedCount = registry.rounds.reduce((sum, r) => sum + (Number(r.failedCount) || 0), 0)
187
+ // status integer fields — and fractional counts (1.5) must be floored too.
188
+ const registryFixed = registry.rounds.reduce((sum, r) => sum + toInteger(r.fixedCount, 0), 0)
189
+ const failedCount = registry.rounds.reduce((sum, r) => sum + toInteger(r.failedCount, 0), 0)
162
190
  // When a checkpoint exists, its snapshot fields are authoritative for resume
163
191
  // (fixedCount / architecturalCount / findings); otherwise derive from the
164
192
  // live fix registry and decision log.
165
- const fixedCount = checkpoint ? checkpoint.fixedCount : registryFixed
166
- const architecturalCount = checkpoint?.architecturalCount ?? 0
193
+ const fixedCount = checkpoint ? toInteger(checkpoint.fixedCount, 0) : registryFixed
194
+ const architecturalCount = toInteger(checkpoint?.architecturalCount, 0)
167
195
 
168
196
  return {
169
197
  mode: checkpoint?.mode ?? null,
@@ -188,7 +216,7 @@ export function computeStatus(input: {
188
216
  !(lastEntry != null &&
189
217
  checkpoint.updatedAt &&
190
218
  (Date.parse(checkpoint.updatedAt) || 0) >= (Date.parse(lastEntry.timestamp) || 0)),
191
- resumeCount: checkpoint?.resumeCount ?? 0,
219
+ resumeCount: toInteger(checkpoint?.resumeCount, 0),
192
220
  checkpoint,
193
221
  lastUpdated,
194
222
  // v3.0: quality command-center snapshots (present only when the caller
@@ -210,6 +238,40 @@ export function registerCheckpointTool(ctx: { tools: { register: (def: ReturnTyp
210
238
  ctx.tools.register(
211
239
  defineTool({
212
240
  name: 'iterate_checkpoint',
241
+ // Result card (#12): the render is a raw JSON blob — turn it into a
242
+ // progress headline ("Checkpoint saved — round 2/5 (3 fixed)"). This
243
+ // tool has no `status` operation (progress lives on iterate_status), so
244
+ // save/load/resume/clear each get their headline. Pure: parsed from the
245
+ // rendered result only; an unreadable shape declines the card and the UI
246
+ // falls back to the raw JSON.
247
+ presentResult: (args, result) => {
248
+ const a = args as { operation?: unknown }
249
+ const op = typeof a.operation === 'string' && a.operation ? a.operation : undefined
250
+ if (!op) return undefined
251
+ const value = parseRenderedJson(result)
252
+ if (!value) return undefined
253
+ if (value.ok !== true) {
254
+ const err = typeof value.error === 'string' && value.error ? value.error : 'unknown error'
255
+ return { card: 'generic', title: `Checkpoint ${op} failed: ${err}` }
256
+ }
257
+ if (op === 'clear') {
258
+ return { card: 'generic', title: value.existed === true ? 'Checkpoint cleared' : 'No checkpoint to clear' }
259
+ }
260
+ const checkpoint = asRecord(value.checkpoint)
261
+ if (!checkpoint) {
262
+ // `load` with nothing on disk is a valid, useful outcome.
263
+ return op === 'load' ? { card: 'generic', title: 'No checkpoint on disk' } : undefined
264
+ }
265
+ const round = asNumber(checkpoint.round)
266
+ const maxRounds = asNumber(checkpoint.maxRounds)
267
+ const fixed = asNumber(checkpoint.fixedCount)
268
+ const resumeCount = asNumber(checkpoint.resumeCount)
269
+ const base = op === 'save' ? 'Checkpoint saved' : op === 'resume' ? 'Checkpoint resumed' : 'Checkpoint loaded'
270
+ const resume = op === 'resume' && resumeCount !== undefined ? ` (resume #${resumeCount})` : ''
271
+ const progress = round !== undefined ? ` — round ${round}${maxRounds !== undefined ? `/${maxRounds}` : ''}` : ''
272
+ const fixedPart = fixed !== undefined ? ` (${fixed} fixed)` : ''
273
+ return { card: 'generic', title: base + resume + progress + fixedPart }
274
+ },
213
275
  description:
214
276
  'Save / load / resume / clear the iteration checkpoint. The workflow saves a checkpoint at the start of ' +
215
277
  'each round (so a long run can resume) and clears it when the iteration completes. ' +
@@ -1,5 +1,5 @@
1
1
  import { join } from 'node:path'
2
- import { existsSync } from 'node:fs'
2
+ import { existsSync, readFileSync } from 'node:fs'
3
3
  import { defineTool } from '@deepseek-ai/dsh-tools'
4
4
  import type { JsonValue } from '@deepseek-ai/dsh-util-values'
5
5
  import { defaultConfig, loadEffectiveConfig, validateConfig, resolveProjectRootForExec } from '../config-loader.ts'
@@ -101,6 +101,22 @@ export function registerConfigTool(ctx: { tools: { register: (def: ReturnType<ty
101
101
  // ── Write operation ────────────────────────────────────────────────
102
102
  if (args.operation === 'write') {
103
103
  const updates = args.updates as Record<string, unknown> | undefined
104
+ // Missing/empty `updates` used to re-dump the config as-is: the
105
+ // write "succeeded" (ok:true + a spurious backup) while silently
106
+ // re-serializing the file and losing any hand-written comments.
107
+ // There is nothing to merge — refuse instead.
108
+ const isEmptyUpdate =
109
+ updates === undefined ||
110
+ updates === null ||
111
+ (typeof updates === 'object' && !Array.isArray(updates) && Object.keys(updates).length === 0)
112
+ if (isEmptyUpdate) {
113
+ return {
114
+ operation: 'write',
115
+ ok: false,
116
+ found: false,
117
+ error: 'updates is required (and must be non-empty) for operation "write"',
118
+ }
119
+ }
104
120
  const updateErrors = validateConfigUpdates(updates ?? {})
105
121
  if (updateErrors.length > 0) {
106
122
  return { operation: 'write', ok: false, found: false, errors: updateErrors }
@@ -147,6 +163,26 @@ export function registerConfigTool(ctx: { tools: { register: (def: ReturnType<ty
147
163
 
148
164
  if (args.validate) {
149
165
  const errors = validateConfig(config)
166
+ // A config file that EXISTS but cannot be parsed silently degrades to
167
+ // the built-in defaults (loadEffectiveConfig never throws). Reporting
168
+ // `valid: true` for that file would wave through the breakage —
169
+ // re-read it raw and surface the parse failure as a validation error.
170
+ const configPath = join(projectRoot, 'iterate.config.yaml')
171
+ if (source === 'defaults' && existsSync(configPath)) {
172
+ const raw = readFileSync(configPath, 'utf-8')
173
+ if (raw.trim().length > 0) {
174
+ try {
175
+ readRawConfig(configPath)
176
+ } catch (err) {
177
+ return {
178
+ found: true,
179
+ valid: false,
180
+ errors: [`iterate.config.yaml is not usable: ${String((err as Error).message ?? err)}`],
181
+ section: 'validation_report',
182
+ }
183
+ }
184
+ }
185
+ }
150
186
  return {
151
187
  found: hasOverride,
152
188
  valid: errors.length === 0,
@@ -157,7 +193,13 @@ export function registerConfigTool(ctx: { tools: { register: (def: ReturnType<ty
157
193
 
158
194
  if (args.section) {
159
195
  const configRecord = config as unknown as Record<string, unknown>
160
- const section = configRecord[args.section]
196
+ // Object.hasOwn, NOT `configRecord[args.section]`: a plain
197
+ // `configRecord["toString"]` resolves through the PROTOTYPE CHAIN to
198
+ // a function (non-JSON → ToolOutputError) and `["__proto__"]`
199
+ // returns Object.prototype dressed up as an "existing section".
200
+ const section = Object.hasOwn(configRecord, args.section)
201
+ ? (configRecord[args.section] as unknown)
202
+ : undefined
161
203
  if (section === undefined) {
162
204
  return {
163
205
  found: hasOverride,
@@ -24,10 +24,19 @@ export function isAllowedSkillDir(skillDir: string, allowedRoots: readonly strin
24
24
  if (!existsSync(dir) || !statSync(dir).isDirectory()) return false
25
25
  return allowedRoots.some((root) => {
26
26
  if (typeof root !== 'string' || root.length === 0) return false
27
- const rootReal = realpathSync(root)
28
- const rootPrefix = rootReal.endsWith(sep) ? rootReal : rootReal + sep
29
- const real = realpathSync(dir)
30
- return real === rootReal || real.startsWith(rootPrefix)
27
+ // Per-root try/catch: realpathSync THROWS for a root that does not exist
28
+ // yet. One throw around the whole `.some()` would abort the scan and
29
+ // reject a perfectly valid skillDir sitting inside a LATER root (the
30
+ // caller then silently falls back to auto-detection) — skip only the
31
+ // unusable root and keep checking the remaining ones.
32
+ try {
33
+ const rootReal = realpathSync(root)
34
+ const rootPrefix = rootReal.endsWith(sep) ? rootReal : rootReal + sep
35
+ const real = realpathSync(dir)
36
+ return real === rootReal || real.startsWith(rootPrefix)
37
+ } catch {
38
+ return false // broken/missing allowed root — not a match, keep scanning
39
+ }
31
40
  })
32
41
  } catch {
33
42
  return false
@@ -307,7 +316,11 @@ export function registerContextTool(ctx: { tools: { register: (def: ReturnType<t
307
316
  searched: string[]
308
317
  attachments?: string[]
309
318
  attachmentErrors?: string[]
310
- } = { found: true, searched: [] }
319
+ // `found` starts false and is DERIVED at the end from the content
320
+ // actually returned — hardcoding `true` here meant a miss (no
321
+ // SKILL.md/ITERATE.md anywhere) still reported `found: true` while
322
+ // the renderer said "No files found".
323
+ } = { found: false, searched: [] }
311
324
 
312
325
  // Relay user-attached image metadata into the review context. The
313
326
  // orchestrator observes attached images in the session and passes their
@@ -358,6 +371,13 @@ export function registerContextTool(ctx: { tools: { register: (def: ReturnType<t
358
371
  result.project = readProjectFile(projectRoot, 'ITERATE.md')
359
372
  }
360
373
 
374
+ // Derive `found` from what was actually returned. It mirrors the
375
+ // renderer's "No files found" branch exactly: requested file content,
376
+ // or relayed attachments (attachmentErrors alone never make a find).
377
+ result.found =
378
+ Boolean(result.skill) ||
379
+ Boolean(result.project) ||
380
+ (Array.isArray(result.attachments) && result.attachments.length > 0)
361
381
  return result
362
382
  },
363
383
  }),
@@ -27,9 +27,11 @@ const LOG_FILE = 'decision-log.jsonl'
27
27
  // by synchronous I/O, but a second plugin process appending to the same
28
28
  // project realises the race. The log is serialized with a tiny advisory lock
29
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.
30
+ // prune rewrite mutually exclude across processes. Appends are BEST-EFFORT:
31
+ // contention timeouts degrade to "proceed unlocked" (an audit line must never
32
+ // be lost to a wedged append), never to a crash. The prune REWRITE is the
33
+ // opposite — it renames the log and therefore refuses to run when the lock
34
+ // cannot be taken (see `rewriteDecisionLogKeepingRecent` in prune.ts).
33
35
 
34
36
  const LOCK_FILE = '.decision-log.lock'
35
37
  const LOCK_WAIT_MS = 5000
@@ -57,14 +59,40 @@ function sleepSync(ms: number): void {
57
59
  * Acquire the decision-log lock for `projectRoot`.
58
60
  * Returns a release function (always callable; a no-op when the lock could
59
61
  * not be taken — never wedge an append behind a vanished holder).
62
+ *
63
+ * `options.waitMs` / `options.staleMs` override the defaults (tests use short
64
+ * waits so a live-holder timeout is exercised in milliseconds, not 5s).
60
65
  */
61
- export function acquireLogLock(projectRoot: string): () => void {
66
+ export function acquireLogLock(
67
+ projectRoot: string,
68
+ options: { waitMs?: number; staleMs?: number } = {},
69
+ ): () => void {
70
+ return acquireLogLockChecked(projectRoot, options).release
71
+ }
72
+
73
+ /**
74
+ * {@link acquireLogLock} plus the one thing the prune rewrite needs: whether
75
+ * the lock was actually TAKEN. Appends stay best-effort on contention (an
76
+ * audit line must not be lost to a wedged append), but the rewrite renames the
77
+ * log under any unlocked concurrent appender — it must refuse to run without
78
+ * the mutex rather than silently dropping an audit line.
79
+ */
80
+ export function acquireLogLockChecked(
81
+ projectRoot: string,
82
+ options: { waitMs?: number; staleMs?: number } = {},
83
+ ): { acquired: boolean; release: () => void } {
84
+ const waitMs = Number.isFinite(options.waitMs) && (options.waitMs ?? 0) >= 0
85
+ ? (options.waitMs as number)
86
+ : LOCK_WAIT_MS
87
+ const staleMs = Number.isFinite(options.staleMs) && (options.staleMs ?? 0) >= 0
88
+ ? (options.staleMs as number)
89
+ : LOCK_STALE_MS
62
90
  const dir = join(projectRoot, LOG_DIR)
63
91
  if (!existsSync(dir)) {
64
92
  try {
65
93
  mkdirSync(dir, { recursive: true })
66
94
  } catch {
67
- return () => {}
95
+ return { acquired: false, release: () => {} }
68
96
  }
69
97
  }
70
98
  const lockPath = join(dir, LOCK_FILE)
@@ -74,12 +102,12 @@ export function acquireLogLock(projectRoot: string): () => void {
74
102
  try {
75
103
  fd = openSync(lockPath, 'wx')
76
104
  } catch (err) {
77
- if ((err as NodeJS.ErrnoException).code !== 'EEXIST') return () => {}
105
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') return { acquired: false, release: () => {} }
78
106
  // Lock exists — steal it when the owner is gone or the lock is ancient.
79
107
  let stale = false
80
108
  try {
81
109
  const st = statSync(lockPath)
82
- if (Date.now() - st.mtimeMs > LOCK_STALE_MS) stale = true
110
+ if (Date.now() - st.mtimeMs > staleMs) stale = true
83
111
  else {
84
112
  const pid = Number(readFileSync(lockPath, 'utf-8'))
85
113
  if (!processAlive(pid)) stale = true
@@ -91,7 +119,7 @@ export function acquireLogLock(projectRoot: string): () => void {
91
119
  try { unlinkSync(lockPath) } catch { /* another holder stole it */ }
92
120
  continue
93
121
  }
94
- if (Date.now() - start > LOCK_WAIT_MS) return () => {}
122
+ if (Date.now() - start > waitMs) return { acquired: false, release: () => {} }
95
123
  sleepSync(LOCK_POLL_MS)
96
124
  continue
97
125
  }
@@ -99,28 +127,88 @@ export function acquireLogLock(projectRoot: string): () => void {
99
127
  try { writeSync(fd, String(process.pid)) } catch { /* pid stamp is advisory */ }
100
128
  try { closeSync(fd) } catch { /* best-effort */ }
101
129
  let released = false
102
- return () => {
103
- if (released) return
104
- released = true
105
- try { unlinkSync(lockPath) } catch { /* already gone */ }
130
+ return {
131
+ acquired: true,
132
+ release: () => {
133
+ if (released) return
134
+ released = true
135
+ try { unlinkSync(lockPath) } catch { /* already gone */ }
136
+ },
106
137
  }
107
138
  }
108
139
  }
109
140
 
110
141
  // ─── Entry-count cache (avoid an O(n) full re-read per append) ─────────────
111
142
 
143
+ /**
144
+ * Max distinct log paths tracked by the entry-count cache. The cache is keyed
145
+ * by PATH (one entry per project's log) with no natural eviction — a long-lived
146
+ * process touching many projects would otherwise grow it without bound. A
147
+ * simple insertion-ordered LRU: a hit re-inserts (refreshing recency), a set
148
+ * past the cap evicts the least-recently-used path. Small on purpose: the
149
+ * hot path is one project's log.
150
+ */
151
+ export const LOG_COUNT_CACHE_CAP = 64
152
+
112
153
  const entryCountCache = new Map<string, { size: number; count: number }>()
113
154
 
114
- /** Line-count the log file from scratch (entries = non-empty JSON lines). */
115
- function recountEntries(filePath: string): number {
155
+ /** LRU-ish get: refresh recency by re-inserting the key. */
156
+ function cacheGet(filePath: string): { size: number; count: number } | undefined {
157
+ const hit = entryCountCache.get(filePath)
158
+ if (hit) {
159
+ entryCountCache.delete(filePath)
160
+ entryCountCache.set(filePath, hit)
161
+ }
162
+ return hit
163
+ }
164
+
165
+ /** Insert (or refresh) a cache entry, evicting past LOG_COUNT_CACHE_CAP. */
166
+ function cacheSet(filePath: string, value: { size: number; count: number }): void {
167
+ entryCountCache.delete(filePath)
168
+ entryCountCache.set(filePath, value)
169
+ while (entryCountCache.size > LOG_COUNT_CACHE_CAP) {
170
+ const oldest = entryCountCache.keys().next().value
171
+ if (oldest === undefined) break
172
+ entryCountCache.delete(oldest)
173
+ }
174
+ }
175
+
176
+ /** Current cache occupancy (exported for unit tests of the capacity bound). */
177
+ export function logCountCacheSize(): number {
178
+ return entryCountCache.size
179
+ }
180
+
181
+ /**
182
+ * Line-count the log file from scratch (entries = non-empty JSON lines).
183
+ * Exported for unit tests.
184
+ *
185
+ * A read failure (file vanished, permission, replaced by a directory) returns
186
+ * `{ ok: false }` with the last KNOWN cached count for this path — or 0 when
187
+ * the path was never counted. The old code returned a fabricated `1` here,
188
+ * which the caller then cached, poisoning the base count for every later
189
+ * append. The `ok` flag lets the caller skip caching a failed read entirely.
190
+ */
191
+ export function recountEntries(filePath: string): { count: number; ok: boolean } {
116
192
  try {
117
193
  const content = readFileSync(filePath, 'utf-8')
118
- return content.split('\n').filter((l) => l.trim().length > 0).length
194
+ return { count: content.split('\n').filter((l) => l.trim().length > 0).length, ok: true }
119
195
  } catch {
120
- return 1
196
+ return { count: cacheGet(filePath)?.count ?? 0, ok: false }
121
197
  }
122
198
  }
123
199
 
200
+ /**
201
+ * Drop the cached entry count for a log file. The cache is keyed by the log
202
+ * path and assumes "same byte size ⇒ same entry count"; a prune rewrite
203
+ * (temp + atomic rename) replaces the file with a DIFFERENT number of entries,
204
+ * so only a stale-cache invalidation keeps the fast path honest. Without it, a
205
+ * rewrite that lands on the exact same byte size as the previous append would
206
+ * make the next append report a wrong `count`.
207
+ */
208
+ export function invalidateLogCountCache(filePath: string): void {
209
+ entryCountCache.delete(filePath)
210
+ }
211
+
124
212
  /** All valid DecisionLogEntry `type` values (must stay in sync with Types). */
125
213
  const VALID_ENTRY_TYPES = new Set<DecisionLogEntry['type']>([
126
214
  'round_start',
@@ -174,19 +262,36 @@ export function appendDecisionEntry(projectRoot: string, entry: DecisionLogEntry
174
262
  // with a prune rewrite (which renames the file under us).
175
263
  const release = acquireLogLock(projectRoot)
176
264
  try {
177
- const filePath = logPath(projectRoot)
265
+ // EVERYTHING that can throw — including `logPath`'s mkdirSync (EACCES when
266
+ // `.iterate` cannot be created, ENOTDIR when an ancestor is a regular file)
267
+ // and the statSync probes — sits inside the inner try: callers run this
268
+ // AFTER their own mutations and only handle the structured form, so an
269
+ // escaping exception would abort the whole iterate_fix/iterate_prune call.
178
270
  try {
271
+ const filePath = logPath(projectRoot)
179
272
  const line = JSON.stringify(entry) + '\n'
180
273
  const prevSize = existsSync(filePath) ? statSync(filePath).size : 0
181
274
  appendFileSync(filePath, line, 'utf-8')
182
275
  // Count entries via the size/count cache: when nothing else changed the
183
276
  // file since our last append (same byte size), count is just +1 — no full
184
277
  // 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 })
278
+ const cached = cacheGet(filePath)
279
+ let count: number
280
+ if (cached && cached.size === prevSize) {
281
+ count = cached.count + 1
282
+ } else {
283
+ const recounted = recountEntries(filePath)
284
+ if (!recounted.ok) {
285
+ // The append LANDED but the read-back failed (file vanished /
286
+ // replaced mid-call). Report the best count we actually know —
287
+ // cached value or 0 — and DO NOT touch the cache: caching a failed
288
+ // read's fallback under the new byte size would poison every later
289
+ // append's count.
290
+ return { count: recounted.count, path: filePath }
291
+ }
292
+ count = recounted.count
293
+ }
294
+ cacheSet(filePath, { size: existsSync(filePath) ? statSync(filePath).size : prevSize, count })
190
295
  return { count, path: filePath }
191
296
  } catch (err) {
192
297
  return { count: 0, path: join(projectRoot, LOG_DIR, LOG_FILE), error: `failed to append decision log: ${String(err)}` }