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