iterate-plugin 3.5.7 → 3.7.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 (79) hide show
  1. package/README.md +97 -22
  2. package/README.zh-CN.md +79 -19
  3. package/dist/approval-gate.js +143 -23
  4. package/dist/atomic-fs.js +148 -17
  5. package/dist/config-loader.js +516 -35
  6. package/dist/config-write.js +83 -22
  7. package/dist/evidence.js +141 -87
  8. package/dist/file-lock.js +193 -0
  9. package/dist/index.js +61 -28
  10. package/dist/jobs.js +56 -11
  11. package/dist/live.js +54 -14
  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 +61 -14
  17. package/dist/skill-prompt.js +396 -66
  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 +106 -28
  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 +150 -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/dist/working-directory.js +114 -0
  38. package/lib/client.js +1912 -512
  39. package/lib/parse.js +1001 -103
  40. package/package.json +16 -9
  41. package/src/approval-gate.ts +160 -23
  42. package/src/atomic-fs.ts +152 -17
  43. package/src/client/i18n.ts +517 -0
  44. package/src/client/index.ts +1031 -369
  45. package/src/client/session-cache.ts +54 -0
  46. package/src/config-loader.ts +549 -36
  47. package/src/config-write.ts +108 -26
  48. package/src/evidence.ts +194 -92
  49. package/src/file-lock.ts +211 -0
  50. package/src/index.ts +60 -29
  51. package/src/jobs.ts +63 -12
  52. package/src/live.ts +50 -12
  53. package/src/meta-review.ts +151 -34
  54. package/src/method-scope.ts +31 -8
  55. package/src/review-scope.ts +33 -10
  56. package/src/review.ts +96 -17
  57. package/src/session-hooks.ts +60 -13
  58. package/src/skill-prompt.ts +396 -66
  59. package/src/tools/checkpoint.ts +81 -19
  60. package/src/tools/config.ts +44 -2
  61. package/src/tools/context.ts +25 -5
  62. package/src/tools/decision-log.ts +114 -23
  63. package/src/tools/defense-events.ts +87 -31
  64. package/src/tools/defense-store.ts +30 -3
  65. package/src/tools/experience-bank.ts +112 -44
  66. package/src/tools/experience-store.ts +54 -5
  67. package/src/tools/fix.ts +258 -20
  68. package/src/tools/history.ts +74 -15
  69. package/src/tools/present.ts +89 -0
  70. package/src/tools/prune.ts +154 -51
  71. package/src/tools/quality-gate.ts +57 -2
  72. package/src/tools/quality-store.ts +18 -1
  73. package/src/tools/review.ts +155 -6
  74. package/src/tools/transcript.ts +178 -36
  75. package/src/tools/triage.ts +70 -13
  76. package/src/tools/validate.ts +138 -32
  77. package/src/transcript.ts +279 -4
  78. package/src/types.ts +29 -1
  79. package/src/working-directory.ts +126 -0
@@ -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
  }),
@@ -13,6 +13,7 @@ import { join } from 'node:path'
13
13
  import { defineTool } from '@deepseek-ai/dsh-tools'
14
14
  import type { JsonValue } from '@deepseek-ai/dsh-util-values'
15
15
  import { resolveProjectRootForExec } from '../config-loader.ts'
16
+ import { makeLockRelease } from '../file-lock.ts'
16
17
  import type { DecisionLogEntry } from '../types.ts'
17
18
 
18
19
  const LOG_DIR = '.iterate'
@@ -27,9 +28,11 @@ const LOG_FILE = 'decision-log.jsonl'
27
28
  // by synchronous I/O, but a second plugin process appending to the same
28
29
  // project realises the race. The log is serialized with a tiny advisory lock
29
30
  // 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.
31
+ // prune rewrite mutually exclude across processes. Appends are BEST-EFFORT:
32
+ // contention timeouts degrade to "proceed unlocked" (an audit line must never
33
+ // be lost to a wedged append), never to a crash. The prune REWRITE is the
34
+ // opposite — it renames the log and therefore refuses to run when the lock
35
+ // cannot be taken (see `rewriteDecisionLogKeepingRecent` in prune.ts).
33
36
 
34
37
  const LOCK_FILE = '.decision-log.lock'
35
38
  const LOCK_WAIT_MS = 5000
@@ -57,14 +60,40 @@ function sleepSync(ms: number): void {
57
60
  * Acquire the decision-log lock for `projectRoot`.
58
61
  * Returns a release function (always callable; a no-op when the lock could
59
62
  * not be taken — never wedge an append behind a vanished holder).
63
+ *
64
+ * `options.waitMs` / `options.staleMs` override the defaults (tests use short
65
+ * waits so a live-holder timeout is exercised in milliseconds, not 5s).
60
66
  */
61
- export function acquireLogLock(projectRoot: string): () => void {
67
+ export function acquireLogLock(
68
+ projectRoot: string,
69
+ options: { waitMs?: number; staleMs?: number } = {},
70
+ ): () => void {
71
+ return acquireLogLockChecked(projectRoot, options).release
72
+ }
73
+
74
+ /**
75
+ * {@link acquireLogLock} plus the one thing the prune rewrite needs: whether
76
+ * the lock was actually TAKEN. Appends stay best-effort on contention (an
77
+ * audit line must not be lost to a wedged append), but the rewrite renames the
78
+ * log under any unlocked concurrent appender — it must refuse to run without
79
+ * the mutex rather than silently dropping an audit line.
80
+ */
81
+ export function acquireLogLockChecked(
82
+ projectRoot: string,
83
+ options: { waitMs?: number; staleMs?: number } = {},
84
+ ): { acquired: boolean; release: () => void } {
85
+ const waitMs = Number.isFinite(options.waitMs) && (options.waitMs ?? 0) >= 0
86
+ ? (options.waitMs as number)
87
+ : LOCK_WAIT_MS
88
+ const staleMs = Number.isFinite(options.staleMs) && (options.staleMs ?? 0) >= 0
89
+ ? (options.staleMs as number)
90
+ : LOCK_STALE_MS
62
91
  const dir = join(projectRoot, LOG_DIR)
63
92
  if (!existsSync(dir)) {
64
93
  try {
65
94
  mkdirSync(dir, { recursive: true })
66
95
  } catch {
67
- return () => {}
96
+ return { acquired: false, release: () => {} }
68
97
  }
69
98
  }
70
99
  const lockPath = join(dir, LOCK_FILE)
@@ -74,12 +103,12 @@ export function acquireLogLock(projectRoot: string): () => void {
74
103
  try {
75
104
  fd = openSync(lockPath, 'wx')
76
105
  } catch (err) {
77
- if ((err as NodeJS.ErrnoException).code !== 'EEXIST') return () => {}
106
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') return { acquired: false, release: () => {} }
78
107
  // Lock exists — steal it when the owner is gone or the lock is ancient.
79
108
  let stale = false
80
109
  try {
81
110
  const st = statSync(lockPath)
82
- if (Date.now() - st.mtimeMs > LOCK_STALE_MS) stale = true
111
+ if (Date.now() - st.mtimeMs > staleMs) stale = true
83
112
  else {
84
113
  const pid = Number(readFileSync(lockPath, 'utf-8'))
85
114
  if (!processAlive(pid)) stale = true
@@ -91,33 +120,78 @@ export function acquireLogLock(projectRoot: string): () => void {
91
120
  try { unlinkSync(lockPath) } catch { /* another holder stole it */ }
92
121
  continue
93
122
  }
94
- if (Date.now() - start > LOCK_WAIT_MS) return () => {}
123
+ if (Date.now() - start > waitMs) return { acquired: false, release: () => {} }
95
124
  sleepSync(LOCK_POLL_MS)
96
125
  continue
97
126
  }
98
127
  // Owned the lock: stamp the holder pid, then release once on return.
128
+ // The release is ownership-checked (see `makeLockRelease`): a lock stolen
129
+ // from this process after its owner went stale must not be unlinked by us.
99
130
  try { writeSync(fd, String(process.pid)) } catch { /* pid stamp is advisory */ }
100
131
  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 */ }
132
+ return {
133
+ acquired: true,
134
+ release: makeLockRelease(lockPath),
106
135
  }
107
136
  }
108
137
  }
109
138
 
110
139
  // ─── Entry-count cache (avoid an O(n) full re-read per append) ─────────────
111
140
 
141
+ /**
142
+ * Max distinct log paths tracked by the entry-count cache. The cache is keyed
143
+ * by PATH (one entry per project's log) with no natural eviction — a long-lived
144
+ * process touching many projects would otherwise grow it without bound. A
145
+ * simple insertion-ordered LRU: a hit re-inserts (refreshing recency), a set
146
+ * past the cap evicts the least-recently-used path. Small on purpose: the
147
+ * hot path is one project's log.
148
+ */
149
+ export const LOG_COUNT_CACHE_CAP = 64
150
+
112
151
  const entryCountCache = new Map<string, { size: number; count: number }>()
113
152
 
114
- /** Line-count the log file from scratch (entries = non-empty JSON lines). */
115
- function recountEntries(filePath: string): number {
153
+ /** LRU-ish get: refresh recency by re-inserting the key. */
154
+ function cacheGet(filePath: string): { size: number; count: number } | undefined {
155
+ const hit = entryCountCache.get(filePath)
156
+ if (hit) {
157
+ entryCountCache.delete(filePath)
158
+ entryCountCache.set(filePath, hit)
159
+ }
160
+ return hit
161
+ }
162
+
163
+ /** Insert (or refresh) a cache entry, evicting past LOG_COUNT_CACHE_CAP. */
164
+ function cacheSet(filePath: string, value: { size: number; count: number }): void {
165
+ entryCountCache.delete(filePath)
166
+ entryCountCache.set(filePath, value)
167
+ while (entryCountCache.size > LOG_COUNT_CACHE_CAP) {
168
+ const oldest = entryCountCache.keys().next().value
169
+ if (oldest === undefined) break
170
+ entryCountCache.delete(oldest)
171
+ }
172
+ }
173
+
174
+ /** Current cache occupancy (exported for unit tests of the capacity bound). */
175
+ export function logCountCacheSize(): number {
176
+ return entryCountCache.size
177
+ }
178
+
179
+ /**
180
+ * Line-count the log file from scratch (entries = non-empty JSON lines).
181
+ * Exported for unit tests.
182
+ *
183
+ * A read failure (file vanished, permission, replaced by a directory) returns
184
+ * `{ ok: false }` with the last KNOWN cached count for this path — or 0 when
185
+ * the path was never counted. The old code returned a fabricated `1` here,
186
+ * which the caller then cached, poisoning the base count for every later
187
+ * append. The `ok` flag lets the caller skip caching a failed read entirely.
188
+ */
189
+ export function recountEntries(filePath: string): { count: number; ok: boolean } {
116
190
  try {
117
191
  const content = readFileSync(filePath, 'utf-8')
118
- return content.split('\n').filter((l) => l.trim().length > 0).length
192
+ return { count: content.split('\n').filter((l) => l.trim().length > 0).length, ok: true }
119
193
  } catch {
120
- return 1
194
+ return { count: cacheGet(filePath)?.count ?? 0, ok: false }
121
195
  }
122
196
  }
123
197
 
@@ -186,19 +260,36 @@ export function appendDecisionEntry(projectRoot: string, entry: DecisionLogEntry
186
260
  // with a prune rewrite (which renames the file under us).
187
261
  const release = acquireLogLock(projectRoot)
188
262
  try {
189
- const filePath = logPath(projectRoot)
263
+ // EVERYTHING that can throw — including `logPath`'s mkdirSync (EACCES when
264
+ // `.iterate` cannot be created, ENOTDIR when an ancestor is a regular file)
265
+ // and the statSync probes — sits inside the inner try: callers run this
266
+ // AFTER their own mutations and only handle the structured form, so an
267
+ // escaping exception would abort the whole iterate_fix/iterate_prune call.
190
268
  try {
269
+ const filePath = logPath(projectRoot)
191
270
  const line = JSON.stringify(entry) + '\n'
192
271
  const prevSize = existsSync(filePath) ? statSync(filePath).size : 0
193
272
  appendFileSync(filePath, line, 'utf-8')
194
273
  // Count entries via the size/count cache: when nothing else changed the
195
274
  // file since our last append (same byte size), count is just +1 — no full
196
275
  // re-read. Falls back to a full re-count whenever the cache is stale.
197
- const cached = entryCountCache.get(filePath)
198
- const count = cached && cached.size === prevSize
199
- ? cached.count + 1
200
- : recountEntries(filePath)
201
- entryCountCache.set(filePath, { size: existsSync(filePath) ? statSync(filePath).size : prevSize, count })
276
+ const cached = cacheGet(filePath)
277
+ let count: number
278
+ if (cached && cached.size === prevSize) {
279
+ count = cached.count + 1
280
+ } else {
281
+ const recounted = recountEntries(filePath)
282
+ if (!recounted.ok) {
283
+ // The append LANDED but the read-back failed (file vanished /
284
+ // replaced mid-call). Report the best count we actually know —
285
+ // cached value or 0 — and DO NOT touch the cache: caching a failed
286
+ // read's fallback under the new byte size would poison every later
287
+ // append's count.
288
+ return { count: recounted.count, path: filePath }
289
+ }
290
+ count = recounted.count
291
+ }
292
+ cacheSet(filePath, { size: existsSync(filePath) ? statSync(filePath).size : prevSize, count })
202
293
  return { count, path: filePath }
203
294
  } catch (err) {
204
295
  return { count: 0, path: join(projectRoot, LOG_DIR, LOG_FILE), error: `failed to append decision log: ${String(err)}` }