iterate-plugin 3.7.0 → 3.8.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.
package/src/file-lock.ts CHANGED
@@ -30,6 +30,7 @@
30
30
  import {
31
31
  closeSync,
32
32
  existsSync,
33
+ fstatSync,
33
34
  mkdirSync,
34
35
  openSync,
35
36
  readFileSync,
@@ -40,7 +41,14 @@ import {
40
41
  import { join } from 'node:path'
41
42
 
42
43
  const LOCK_DIR = '.iterate'
43
- const DEFAULT_WAIT_MS = 5000
44
+ // Bounded wait before degrading to "proceed unlocked". `acquireProjectLock`
45
+ // spins on `Atomics.wait`, which BLOCKS the single plugin thread for the whole
46
+ // budget — so a long default wedges unrelated tool calls and the UI heartbeat
47
+ // whenever a sibling process holds the lock. Healthy holders release in
48
+ // microseconds, so 1000ms is very generous; anything slower is treated as
49
+ // contention and degrades to the historical unlocked behavior rather than
50
+ // stalling the event loop for five seconds.
51
+ const DEFAULT_WAIT_MS = 1000
44
52
  const DEFAULT_STALE_MS = 10000
45
53
  const POLL_MS = 25
46
54
 
@@ -80,19 +88,28 @@ function lockFileName(name: string): string {
80
88
  return `.${name}.lock`
81
89
  }
82
90
 
91
+ /** Outcome of a checked acquisition: whether the lock was actually TAKEN. */
92
+ export interface AcquireLockResult {
93
+ acquired: boolean
94
+ release: () => void
95
+ }
96
+
83
97
  /**
84
- * Acquire an advisory lock named `name` inside `<projectRoot>/.iterate/`.
98
+ * Acquire an advisory lock named `name` inside `<projectRoot>/.iterate/`,
99
+ * reporting whether it was actually taken.
85
100
  *
86
- * Returns a release function: always callable, idempotent, never throws.
87
- * A no-op (but callable) release is returned when the lock directory cannot be
88
- * created or the wait times out — callers then proceed unlocked, which is the
89
- * historical best-effort behavior, rather than failing the operation.
101
+ * The returned release function is always callable, idempotent, and never
102
+ * throws. `acquired:false` (with a no-op release) is returned when the lock
103
+ * directory cannot be created or the wait times out — callers then proceed
104
+ * unlocked, which is the historical best-effort behavior, rather than failing
105
+ * the operation. Callers that MUST hold the mutex (e.g. a log rewrite that
106
+ * renames the file) branch on `acquired` and refuse to run unlocked.
90
107
  */
91
- export function acquireProjectLock(
108
+ export function acquireProjectLockChecked(
92
109
  projectRoot: string,
93
110
  name: string,
94
111
  options: LockOptions = {},
95
- ): () => void {
112
+ ): AcquireLockResult {
96
113
  const waitMs = Number.isFinite(options.waitMs) && (options.waitMs ?? 0) >= 0
97
114
  ? (options.waitMs as number)
98
115
  : DEFAULT_WAIT_MS
@@ -105,7 +122,7 @@ export function acquireProjectLock(
105
122
  try {
106
123
  mkdirSync(dir, { recursive: true })
107
124
  } catch {
108
- return () => {}
125
+ return { acquired: false, release: () => {} }
109
126
  }
110
127
  }
111
128
  const lockPath = join(dir, lockFileName(name))
@@ -115,7 +132,7 @@ export function acquireProjectLock(
115
132
  try {
116
133
  fd = openSync(lockPath, 'wx')
117
134
  } catch (err) {
118
- if ((err as NodeJS.ErrnoException).code !== 'EEXIST') return () => {}
135
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') return { acquired: false, release: () => {} }
119
136
  // Lock exists — steal it when the owner is gone or the lock is ancient.
120
137
  let stale = false
121
138
  try {
@@ -136,11 +153,20 @@ export function acquireProjectLock(
136
153
  }
137
154
  continue
138
155
  }
139
- if (Date.now() - start > waitMs) return () => {}
156
+ if (Date.now() - start > waitMs) return { acquired: false, release: () => {} }
140
157
  sleepSync(POLL_MS)
141
158
  continue
142
159
  }
143
- // Owned the lock: stamp the holder pid, then release once on return.
160
+ // Owned the lock: capture the created inode (for the ownership-checked
161
+ // release), stamp the holder pid, then release once on return.
162
+ let ino = 0
163
+ try {
164
+ ino = fstatSync(fd).ino
165
+ } catch {
166
+ // inode unavailable on this filesystem — the release falls back to the
167
+ // pid-stamp check only.
168
+ ino = 0
169
+ }
144
170
  try {
145
171
  writeSync(fd, String(process.pid))
146
172
  } catch {
@@ -151,10 +177,26 @@ export function acquireProjectLock(
151
177
  } catch {
152
178
  // best-effort
153
179
  }
154
- return makeLockRelease(lockPath)
180
+ return { acquired: true, release: makeLockRelease(lockPath, ino) }
155
181
  }
156
182
  }
157
183
 
184
+ /**
185
+ * Acquire an advisory lock named `name` inside `<projectRoot>/.iterate/`.
186
+ *
187
+ * Returns a release function: always callable, idempotent, never throws.
188
+ * A no-op (but callable) release is returned when the lock directory cannot be
189
+ * created or the wait times out — callers then proceed unlocked, which is the
190
+ * historical best-effort behavior, rather than failing the operation.
191
+ */
192
+ export function acquireProjectLock(
193
+ projectRoot: string,
194
+ name: string,
195
+ options: LockOptions = {},
196
+ ): () => void {
197
+ return acquireProjectLockChecked(projectRoot, name, options).release
198
+ }
199
+
158
200
  /**
159
201
  * Build the release function for a lock file this process just acquired.
160
202
  *
@@ -168,7 +210,25 @@ export function acquireProjectLock(
168
210
  *
169
211
  * Always callable, idempotent, and never throws.
170
212
  */
171
- export function makeLockRelease(lockPath: string): () => void {
213
+ /**
214
+ * Build the release function for a lock file this process just acquired.
215
+ *
216
+ * Advisory locks here are STOLEN when the recorded owner dies or the mtime
217
+ * goes ancient: the steal unlinks our file and creates a new one stamped with
218
+ * the STEALER's pid. An unguarded release would then unlink the new holder's
219
+ * lock — turning our release into a double-release that admits a third process
220
+ * into a "held" critical section. So the release re-reads the stamp and only
221
+ * unlinks a file that is still ours: same pid (or unstamped) AND, when we know
222
+ * the inode we created, the SAME inode still at the path. The inode check closes
223
+ * the read-then-unlink window where the stamp check alone could race a steal
224
+ * (our pid is alive, but the file was already replaced between the two calls).
225
+ *
226
+ * Always callable, idempotent, and never throws.
227
+ *
228
+ * @param lockPath - the lock file path.
229
+ * @param ino - the inode captured at acquisition (0 = unknown; skip the check).
230
+ */
231
+ export function makeLockRelease(lockPath: string, ino?: number): () => void {
172
232
  let released = false
173
233
  return () => {
174
234
  if (released) return
@@ -183,6 +243,16 @@ export function makeLockRelease(lockPath: string): () => void {
183
243
  // A stamp naming another pid means the lock was stolen and reassigned:
184
244
  // it is no longer ours to delete.
185
245
  if (raw !== '' && Number(raw) !== process.pid) return
246
+ // The path must still refer to the file WE created. A stealer unlinked our
247
+ // file and created a fresh one; that replacement carries a different inode,
248
+ // so unlinking by path would delete the NEW holder's lock.
249
+ if (typeof ino === 'number' && ino !== 0) {
250
+ try {
251
+ if (statSync(lockPath).ino !== ino) return
252
+ } catch {
253
+ return // vanished mid-release — nothing of ours left
254
+ }
255
+ }
186
256
  try {
187
257
  unlinkSync(lockPath)
188
258
  } catch {
package/src/git-scope.ts CHANGED
@@ -22,7 +22,7 @@
22
22
  */
23
23
 
24
24
  import { execFile } from 'node:child_process'
25
- import { existsSync, statSync } from 'node:fs'
25
+ import { lstatSync, realpathSync } from 'node:fs'
26
26
  import { join, sep } from 'node:path'
27
27
 
28
28
  /** A resolved changed-only scope result. */
@@ -113,6 +113,15 @@ export function parseChangedFiles(stdout: string): string[] {
113
113
  * Keep only entries that resolve to an existing regular file under `root`.
114
114
  * Traversal-safe: rejects absolute paths and any relative path that would
115
115
  * escape `root` via `..` (resolved against the root before stat).
116
+ *
117
+ * Symlink handling: `statSync` (and `existsSync`) FOLLOW symlinks, so a git
118
+ * entry that is a link to a file OUTSIDE the project would pass a path-only
119
+ * containment check and enter the changed-only scope. Such a file is then
120
+ * rejected by the evidence gate (realpath out of bounds → `file_not_found`),
121
+ * so every finding on it would fail closed as `EVIDENCE_VIOLATION` forever —
122
+ * a permanent false positive for repos with dotfile or shared-config links.
123
+ * `lstatSync` distinguishes the link itself; a link whose REAL target escapes
124
+ * `root` is dropped here instead.
116
125
  */
117
126
  export function filterExistingFiles(root: string, files: string[]): string[] {
118
127
  const out: string[] = []
@@ -122,7 +131,17 @@ export function filterExistingFiles(root: string, files: string[]): string[] {
122
131
  const candidate = join(root, rel)
123
132
  if (!candidate.startsWith(rootPrefix) && candidate !== root) continue
124
133
  try {
125
- if (existsSync(candidate) && statSync(candidate).isFile()) out.push(rel)
134
+ const st = lstatSync(candidate)
135
+ if (st.isFile()) {
136
+ out.push(rel)
137
+ continue
138
+ }
139
+ if (!st.isSymbolicLink()) continue
140
+ // A link whose real target stays inside the project is a legitimate,
141
+ // reviewable file; one that escapes is excluded from scope (fail-closed
142
+ // at the source instead of as a permanent evidence violation).
143
+ const real = realpathSync(candidate)
144
+ if (real === root || real.startsWith(rootPrefix)) out.push(rel)
126
145
  } catch {
127
146
  // Unreadable entry (e.g. a broken symlink) is not a valid review target.
128
147
  continue
@@ -135,7 +135,7 @@ for (let r = 1; r <= maxRounds; r++) {
135
135
  (attachments.length > 0 ? '\\n User-attached images are part of the evidence; use their descriptions when judging (you see the metadata/descriptions below, not the pixels): ' + JSON.stringify(attachments) + '.' : '') +
136
136
  '\\n Already-known findings (do NOT re-report): ' +
137
137
  JSON.stringify(known) + nudge + '\\nReturn the findings JSON object.'
138
- return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta.findingsSchema }, backend))
138
+ return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta?.findingsSchema }, backend))
139
139
  }))
140
140
  // A per-dimension reviewer subagent resolves \`null\` on child failure; the
141
141
  // aggregate below would report an all-failed round as an empty round, and
@@ -464,7 +464,7 @@ for (let r = startRound; r <= maxRounds; r++) {
464
464
  (steering ? '\\n STEERING — read this first: ' + steering : '') +
465
465
  (attachments.length > 0 ? '\\n User-attached images are part of the evidence; use their descriptions when judging (you see the metadata/descriptions below, not the pixels): ' + JSON.stringify(attachments) + '.' : '') +
466
466
  '\\n Do NOT re-report already-known architectural findings: ' + JSON.stringify(architectural) + nudge + '\\nReturn the findings JSON object.'
467
- return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta.findingsSchema }, backend))
467
+ return agent(base + extra, Object.assign({ label: 'review:' + dim + ':r' + r, schema: meta?.findingsSchema }, backend))
468
468
  }))
469
469
  // ALL reviewers returning null (child failures) means this round produced
470
470
  // no usable output — an empty aggregate must not read like a clean
@@ -14,6 +14,7 @@ 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
16
  import { writeJsonAtomic } from '../atomic-fs.ts'
17
+ import { withProjectLock } from '../file-lock.ts'
17
18
  import { checkpointPath, iterateDir, transcriptPath } from '../paths.ts'
18
19
  import { readRegistry } from './fix.ts'
19
20
  import { readDecisionEntries } from './decision-log.ts'
@@ -322,22 +323,28 @@ export function registerCheckpointTool(ctx: { tools: { register: (def: ReturnTyp
322
323
  }
323
324
 
324
325
  if (args.operation === 'resume') {
325
- const current = readCheckpoint(projectRoot)
326
- if (!current) {
327
- return { operation: 'resume', ok: false, error: 'no checkpoint to resume — run `save` first' }
328
- }
329
- const resumed: IterationCheckpoint = {
330
- ...current,
331
- resumeCount: (current.resumeCount ?? 0) + 1,
332
- updatedAt: new Date().toISOString(),
333
- }
334
- try {
335
- mkdirSync(iterateDir(projectRoot), { recursive: true })
336
- writeJsonAtomic(checkpointPath(projectRoot), resumed)
337
- } catch (err) {
338
- return { operation: 'resume', ok: false, error: `failed to persist resumed checkpoint: ${String(err)}` }
339
- }
340
- return { operation: 'resume', ok: true, checkpoint: resumed as unknown as JsonValue }
326
+ // Read-modify-write across processes: a concurrent resume in a
327
+ // sibling process would read the same resumeCount and write it back,
328
+ // losing one increment. Serialize on a checkpoint lock (the same
329
+ // advisory pattern the config/triage/transcript writers use).
330
+ return withProjectLock(projectRoot, 'checkpoint', () => {
331
+ const current = readCheckpoint(projectRoot)
332
+ if (!current) {
333
+ return { operation: 'resume', ok: false, error: 'no checkpoint to resume — run `save` first' }
334
+ }
335
+ const resumed: IterationCheckpoint = {
336
+ ...current,
337
+ resumeCount: (current.resumeCount ?? 0) + 1,
338
+ updatedAt: new Date().toISOString(),
339
+ }
340
+ try {
341
+ mkdirSync(iterateDir(projectRoot), { recursive: true })
342
+ writeJsonAtomic(checkpointPath(projectRoot), resumed)
343
+ } catch (err) {
344
+ return { operation: 'resume', ok: false, error: `failed to persist resumed checkpoint: ${String(err)}` }
345
+ }
346
+ return { operation: 'resume', ok: true, checkpoint: resumed as unknown as JsonValue }
347
+ })
341
348
  }
342
349
 
343
350
  if (args.operation === 'clear') {
@@ -9,6 +9,7 @@ import {
9
9
  validateConfigUpdates,
10
10
  writeConfigFile,
11
11
  } from '../config-write.ts'
12
+ import { withProjectLock } from '../file-lock.ts'
12
13
 
13
14
  /**
14
15
  * Register the `iterate_config` tool.
@@ -121,40 +122,48 @@ export function registerConfigTool(ctx: { tools: { register: (def: ReturnType<ty
121
122
  if (updateErrors.length > 0) {
122
123
  return { operation: 'write', ok: false, found: false, errors: updateErrors }
123
124
  }
124
- let base: Record<string, unknown>
125
- try {
126
- // Partial updates must be mergeable on a FRESH project (no config
127
- // file yet): otherwise `{max_rounds: 5}` alone fails schema
128
- // validation with a misleading "missing goal". Merge against the
129
- // built-in defaults when there is no file to read, so the written
130
- // config captures the effective config (defaults + update) and the
131
- // documented "write a partial update" contract works out of the box.
132
- base = existsSync(join(projectRoot, 'iterate.config.yaml'))
133
- ? readRawConfig(join(projectRoot, 'iterate.config.yaml'))
134
- : (defaultConfig() as unknown as Record<string, unknown>)
135
- } catch (err) {
136
- return { operation: 'write', ok: false, found: false, error: `failed to read config: ${String(err)}` }
137
- }
138
- const merged = applyConfigUpdates(base, updates ?? {})
139
- const schemaErrors = validateConfig(merged)
140
- if (schemaErrors.length > 0) {
125
+ // Serialize the read→merge→validate→write cycle against any other
126
+ // config/triage writer in a sibling process: without the lock, two
127
+ // sessions on the same project each read a stale base and the second
128
+ // merge silently discards the first's update (lost update). The same
129
+ // lock name is shared with `iterate_triage apply` so the two writers
130
+ // are mutually exclusive.
131
+ return withProjectLock(projectRoot, 'config-write', () => {
132
+ let base: Record<string, unknown>
133
+ try {
134
+ // Partial updates must be mergeable on a FRESH project (no config
135
+ // file yet): otherwise `{max_rounds: 5}` alone fails schema
136
+ // validation with a misleading "missing goal". Merge against the
137
+ // built-in defaults when there is no file to read, so the written
138
+ // config captures the effective config (defaults + update) and the
139
+ // documented "write a partial update" contract works out of the box.
140
+ base = existsSync(join(projectRoot, 'iterate.config.yaml'))
141
+ ? readRawConfig(join(projectRoot, 'iterate.config.yaml'))
142
+ : (defaultConfig() as unknown as Record<string, unknown>)
143
+ } catch (err) {
144
+ return { operation: 'write', ok: false, found: false, error: `failed to read config: ${String(err)}` }
145
+ }
146
+ const merged = applyConfigUpdates(base, updates ?? {})
147
+ const schemaErrors = validateConfig(merged)
148
+ if (schemaErrors.length > 0) {
149
+ return {
150
+ operation: 'write',
151
+ ok: false,
152
+ found: false,
153
+ errors: schemaErrors.map((e) => `missing/required field: ${e}`),
154
+ }
155
+ }
156
+ const result = writeConfigFile(projectRoot, merged)
157
+ if (!result.ok) return { operation: 'write', ok: false, found: false, error: result.error }
158
+ const { config } = loadEffectiveConfig(projectRoot)
141
159
  return {
142
160
  operation: 'write',
143
- ok: false,
144
- found: false,
145
- errors: schemaErrors.map((e) => `missing/required field: ${e}`),
161
+ ok: true,
162
+ found: true,
163
+ backupPath: result.backupPath ?? null,
164
+ config: config as unknown as JsonValue,
146
165
  }
147
- }
148
- const result = writeConfigFile(projectRoot, merged)
149
- if (!result.ok) return { operation: 'write', ok: false, found: false, error: result.error }
150
- const { config } = loadEffectiveConfig(projectRoot)
151
- return {
152
- operation: 'write',
153
- ok: true,
154
- found: true,
155
- backupPath: result.backupPath ?? null,
156
- config: config as unknown as JsonValue,
157
- }
166
+ })
158
167
  }
159
168
 
160
169
  // ── Read operations (original behavior) ────────────────────────────
@@ -3,17 +3,13 @@ import {
3
3
  readFileSync,
4
4
  mkdirSync,
5
5
  existsSync,
6
- openSync,
7
- writeSync,
8
- closeSync,
9
- unlinkSync,
10
6
  statSync,
11
7
  } from 'node:fs'
12
8
  import { join } from 'node:path'
13
9
  import { defineTool } from '@deepseek-ai/dsh-tools'
14
10
  import type { JsonValue } from '@deepseek-ai/dsh-util-values'
15
11
  import { resolveProjectRootForExec } from '../config-loader.ts'
16
- import { makeLockRelease } from '../file-lock.ts'
12
+ import { acquireProjectLockChecked } from '../file-lock.ts'
17
13
  import type { DecisionLogEntry } from '../types.ts'
18
14
 
19
15
  const LOG_DIR = '.iterate'
@@ -34,35 +30,16 @@ const LOG_FILE = 'decision-log.jsonl'
34
30
  // opposite — it renames the log and therefore refuses to run when the lock
35
31
  // cannot be taken (see `rewriteDecisionLogKeepingRecent` in prune.ts).
36
32
 
37
- const LOCK_FILE = '.decision-log.lock'
38
- const LOCK_WAIT_MS = 5000
39
- const LOCK_STALE_MS = 10000
40
- const LOCK_POLL_MS = 25
41
-
42
- /** True when a pid refers to a live process (or we lack permission to tell). */
43
- function processAlive(pid: number): boolean {
44
- if (!Number.isInteger(pid) || pid <= 0) return false
45
- try {
46
- process.kill(pid, 0)
47
- return true
48
- } catch (err) {
49
- return (err as NodeJS.ErrnoException).code === 'EPERM'
50
- }
51
- }
52
-
53
- /** Synchronous busy-wait sleep (Atomics.wait is a reliable sleep in Node). */
54
- function sleepSync(ms: number): void {
55
- const sab = new Int32Array(new SharedArrayBuffer(4))
56
- Atomics.wait(sab, 0, 0, ms)
57
- }
33
+ /** Lock name shared with the prune rewrite (→ `.iterate/.decision-log.lock`). */
34
+ const LOG_LOCK_NAME = 'decision-log'
58
35
 
59
36
  /**
60
37
  * Acquire the decision-log lock for `projectRoot`.
61
38
  * Returns a release function (always callable; a no-op when the lock could
62
39
  * not be taken — never wedge an append behind a vanished holder).
63
40
  *
64
- * `options.waitMs` / `options.staleMs` override the defaults (tests use short
65
- * waits so a live-holder timeout is exercised in milliseconds, not 5s).
41
+ * `options.waitMs` / `options.staleMs` override the shared defaults (tests use
42
+ * short waits so a live-holder timeout is exercised in milliseconds, not 5s).
66
43
  */
67
44
  export function acquireLogLock(
68
45
  projectRoot: string,
@@ -77,63 +54,18 @@ export function acquireLogLock(
77
54
  * audit line must not be lost to a wedged append), but the rewrite renames the
78
55
  * log under any unlocked concurrent appender — it must refuse to run without
79
56
  * the mutex rather than silently dropping an audit line.
57
+ *
58
+ * Delegates to the shared `acquireProjectLockChecked` with lock name
59
+ * `decision-log`: the decision-log mutex IS that generic project lock. This
60
+ * module used to hand-roll an identical copy of the acquire/steal/wait logic,
61
+ * and the two stale/wait parameter sets could drift apart — one implementation,
62
+ * one set of defaults, one ownership-checked release.
80
63
  */
81
64
  export function acquireLogLockChecked(
82
65
  projectRoot: string,
83
66
  options: { waitMs?: number; staleMs?: number } = {},
84
67
  ): { 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
91
- const dir = join(projectRoot, LOG_DIR)
92
- if (!existsSync(dir)) {
93
- try {
94
- mkdirSync(dir, { recursive: true })
95
- } catch {
96
- return { acquired: false, release: () => {} }
97
- }
98
- }
99
- const lockPath = join(dir, LOCK_FILE)
100
- const start = Date.now()
101
- for (;;) {
102
- let fd: number | null = null
103
- try {
104
- fd = openSync(lockPath, 'wx')
105
- } catch (err) {
106
- if ((err as NodeJS.ErrnoException).code !== 'EEXIST') return { acquired: false, release: () => {} }
107
- // Lock exists — steal it when the owner is gone or the lock is ancient.
108
- let stale = false
109
- try {
110
- const st = statSync(lockPath)
111
- if (Date.now() - st.mtimeMs > staleMs) stale = true
112
- else {
113
- const pid = Number(readFileSync(lockPath, 'utf-8'))
114
- if (!processAlive(pid)) stale = true
115
- }
116
- } catch {
117
- stale = true // unreadable/vanishing lock → retry as stale
118
- }
119
- if (stale) {
120
- try { unlinkSync(lockPath) } catch { /* another holder stole it */ }
121
- continue
122
- }
123
- if (Date.now() - start > waitMs) return { acquired: false, release: () => {} }
124
- sleepSync(LOCK_POLL_MS)
125
- continue
126
- }
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.
130
- try { writeSync(fd, String(process.pid)) } catch { /* pid stamp is advisory */ }
131
- try { closeSync(fd) } catch { /* best-effort */ }
132
- return {
133
- acquired: true,
134
- release: makeLockRelease(lockPath),
135
- }
136
- }
68
+ return acquireProjectLockChecked(projectRoot, LOG_LOCK_NAME, options)
137
69
  }
138
70
 
139
71
  // ─── Entry-count cache (avoid an O(n) full re-read per append) ─────────────