opencode-dejavu 2.6.0 → 2.27.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/store.ts CHANGED
@@ -1,10 +1,11 @@
1
+ import { existsSync } from "node:fs"
1
2
  import { appendFile, mkdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises"
2
3
  import { dirname, join } from "node:path"
3
- import { canBlock, canRemind, fuzzySimilar, FUZZY_MAX_LEN, isRepoLocal, scrubSecrets } from "./patterns"
4
+ import { canBlock, canRemind, fuzzySimilar, FUZZY_MAX_LEN, hasResidualIdentity, isNoiseError, isRepoLocal, looksLikeFailure, sanitizeForStore, scrubSecrets, suggestCorrection } from "./patterns"
4
5
  import { coerceGateShape, repairGate } from "./validate"
5
6
 
6
7
  /** Bumped on behavior changes; stamped into init log events so stale sessions are visible. */
7
- export const PLUGIN_VERSION = "2.6.0"
8
+ export const PLUGIN_VERSION = "2.27.0"
8
9
 
9
10
  export interface Gate {
10
11
  /** sha1 signature prefix — the pattern identity */
@@ -31,9 +32,28 @@ export interface Gate {
31
32
  recurredAfterReminder: number
32
33
  /** the core health metric: failures of this pattern AFTER it became a gate */
33
34
  recurredAfterGate: number
35
+ /** explicit bypasses (dejavu:proceed) against this gate — negative feedback:
36
+ * a gate the agent keeps overriding is friction, not teaching */
37
+ overrideCount: number
38
+ /** demoted once by behavioral feedback (recurrences/overrides). Such gates
39
+ * never re-promote mechanically — a human re-enforces by clearing the flag */
40
+ feedbackDemoted?: boolean
41
+ /** counters at the moment of feedback demotion. A human re-enforcement
42
+ * (status set back to enforced) must get a FRESH grace window — without the
43
+ * baseline, the stale counters would re-demotion on the very next failure */
44
+ feedbackBaseline?: { recurred: number; overrides: number }
34
45
  /** consecutive successes after the gate was enforced — reaching HEAL_SUCCESSES
35
46
  * retires the gate to watching (the underlying command got fixed) */
36
47
  succeededAfterGate?: number
48
+ /** lifetime promotions of this pattern — never reset by the lifecycle reset.
49
+ * The definitive flapping measure (promote→heal→promote oscillation); doctor
50
+ * escalates FLAPPY with it. Report-only: no mechanical auto-demotion. */
51
+ promotionCount?: number
52
+ /** count at the moment the gate retired (healed or taught). `count`/`sessions`
53
+ * are lifetime-cumulative, so without damping a retired gate re-promoted on the
54
+ * VERY NEXT single failure (promote→heal→promote oscillation). Re-promotion now
55
+ * requires a full fresh bar: `count - retireBaseline.count >= threshold`. */
56
+ retireBaseline?: { count: number }
37
57
  /** flagged for manual review when the gate fires often but errors stopped */
38
58
  review?: boolean
39
59
  /** sessions currently reminded about this gate: sessionID -> remind time (ms).
@@ -44,17 +64,32 @@ export interface Gate {
44
64
  * Their next attempt blocks. Expires like remindedSessions — a stale block
45
65
  * with no live session is a leak, not enforcement. */
46
66
  failedSessions?: Record<string, number>
67
+ /** distinct sessions that reoffended AFTER being reminded (capped). The
68
+ * demotion vote counts only failures the gate had a chance to prevent —
69
+ * first-encounter failures never saw a reminder and must not demote. */
70
+ reoffenseSessions?: string[]
47
71
  }
48
72
 
49
73
  interface GatesFile {
50
74
  version: 1
51
75
  gates: Gate[]
76
+ /** PLUGIN_VERSION that last ran migrate() on this file — lets subsequent
77
+ * starts skip the full per-gate scan (init storm killer) */
78
+ migrated?: string
79
+ /** PLUGIN_VERSION of the process that last SAVED this file. Unlike
80
+ * `migrated` (which converges to the newest version as every reader re-loads
81
+ * it), this is stamped with the WRITER's own version on every save, so a
82
+ * stale plugin session writing here leaves its old version behind — the
83
+ * durable version-drift signal (log init events rotate away). */
84
+ lastInitVersion?: string
52
85
  }
53
86
 
54
87
  /** Cross-project pattern index: which project dirs have seen each key. */
55
88
  interface IndexEntry {
56
89
  projects: string[]
57
90
  lastSeen: string
91
+ /** set when no scope visible to the sweeper holds the gate; pruned if it stays absent past ORPHAN_CANDIDATE_DAYS */
92
+ orphanCandidateSince?: number
58
93
  }
59
94
 
60
95
  interface IndexFile {
@@ -71,13 +106,30 @@ export type LogEventType =
71
106
  | "override"
72
107
  | "expired"
73
108
  | "recurred-after-gate"
109
+ | "demoted"
74
110
  | "init"
111
+ | "health"
75
112
  | "repaired"
76
113
  | "quarantined"
77
114
  | "degraded"
78
115
  | "retired-healed"
116
+ | "retired-taught"
79
117
  | "healed"
80
118
 
119
+ /** Events that change what the machine remembers — the only ones worth the
120
+ * global log lock (the most-contended lock, shared by every window of every
121
+ * project). High-volume events (detected/reminded/blocked/retry-allowed/
122
+ * recurred-after-gate) stay in the project log only. */
123
+ const GLOBAL_LOG_EVENTS = new Set<LogEventType>([
124
+ "init",
125
+ "promoted",
126
+ "demoted",
127
+ "healed",
128
+ "retired-healed",
129
+ "retired-taught",
130
+ "override",
131
+ ])
132
+
81
133
  export interface LogEvent {
82
134
  type: LogEventType
83
135
  key: string
@@ -95,7 +147,7 @@ export interface LogEvent {
95
147
  version?: string
96
148
  }
97
149
 
98
- const MAX_SESSIONS = 50
150
+ export const MAX_SESSIONS = 50
99
151
  const MAX_PROJECTS = 20
100
152
  const LOG_ROTATE_BYTES = 512 * 1024
101
153
  /** the global log aggregates every project — rotate it later or forensics vanish in a day */
@@ -105,6 +157,13 @@ const DAY_MS = 24 * 60 * 60 * 1000
105
157
  /** trust the loaded-gates cache this long without re-statting (hot path: every tool call) */
106
158
  const LOAD_CACHE_TTL_MS = 1000
107
159
 
160
+ /** distinct project dirs in the global index before a pattern escalates to
161
+ * the global store (agent-level habit, not a repo quirk) */
162
+ export const GLOBAL_PROJECTS = 2
163
+ /** gates expire when the pattern has not recurred for this many days */
164
+ export const TTL_DAYS = 60
165
+ /** weak one-off patterns (below promotion threshold, never enforced) rot this fast */
166
+ export const NOISE_TTL_DAYS = 7
108
167
  /** failures required before a pattern becomes an enforced gate */
109
168
  export const PROMOTE_COUNT = 3
110
169
  /** file-probe tools fail routinely during normal probing — higher bar, never block */
@@ -118,6 +177,19 @@ export const HEAL_SUCCESSES = 3
118
177
  /** store size bound: flooding with unique failures must not bloat gates.json
119
178
  * or slow the fuzzy scan — the weakest watching gate is evicted past this */
120
179
  export const MAX_GATES = 2000
180
+ /** enforcement feedback: an enforced gate whose pattern fails this many times
181
+ * AFTER promotion is not teaching (iteration or a useless correction) —
182
+ * demote it instead of nagging/blocking forever */
183
+ export const DEMOTE_RECURRENCES = 3
184
+ /** enforcement feedback: this many explicit bypasses mean the agent considers
185
+ * the gate friction — demote it regardless of recurrence */
186
+ export const DEMOTE_OVERRIDES = 3
187
+ /** recurrence demotion additionally requires this many DISTINCT sessions that
188
+ * reoffended after a reminder — one bad session (or one bad model in a shared
189
+ * store) must not be able to demote a gate for everyone else */
190
+ export const DEMOTE_REOFFENSE_SESSIONS = 2
191
+ /** prune an index key absent from every scope visible to the sweeper after this many days (a live gate in an unopened project clears its own candidacy) */
192
+ export const ORPHAN_CANDIDATE_DAYS = 7
121
193
 
122
194
  // --- Windows-safe fs helpers -------------------------------------------------
123
195
 
@@ -161,14 +233,22 @@ const LOCK_WAIT_MS = 3000
161
233
  * Exclusive lockfile ("wx" create) with stale-lock stealing and graceful
162
234
  * degradation: if the lock cannot be acquired within LOCK_WAIT_MS the
163
235
  * critical section runs unlocked rather than hanging the tool pipeline.
236
+ * Stealing is pid-liveness-gated and reported via onSteal.
164
237
  */
165
- async function withLock<T>(lockTarget: string, fn: () => Promise<T>, onDegrade?: () => void): Promise<T> {
238
+ async function withLock<T>(
239
+ lockTarget: string,
240
+ fn: () => Promise<T>,
241
+ onDegrade?: () => void,
242
+ onSteal?: (heldMs: number, previousPid: string) => void,
243
+ ): Promise<T> {
166
244
  const lock = `${lockTarget}.lock`
167
245
  await mkdir(ntPath(dirname(lock)), { recursive: true })
168
246
  const started = Date.now()
247
+ let acquired = false
169
248
  for (;;) {
170
249
  try {
171
250
  await writeFile(ntPath(lock), String(process.pid), { flag: "wx" })
251
+ acquired = true
172
252
  break
173
253
  } catch (error) {
174
254
  const code = (error as { code?: string }).code ?? ""
@@ -176,8 +256,40 @@ async function withLock<T>(lockTarget: string, fn: () => Promise<T>, onDegrade?:
176
256
  try {
177
257
  const info = await stat(ntPath(lock))
178
258
  if (Date.now() - info.mtimeMs > LOCK_STALE_MS) {
179
- await unlink(ntPath(lock)).catch(() => {})
180
- continue
259
+ // Steal only if the recorded holder is dead: a live holder may
260
+ // simply be slow (>5s critical section), and stealing from it
261
+ // opens the critical section to concurrent entry — the residual
262
+ // corruption window. ESRCH = dead; EPERM = alive but not ours.
263
+ let holderPid = ""
264
+ try {
265
+ holderPid = ((await readFile(ntPath(lock), "utf8")) ?? "").trim()
266
+ } catch {
267
+ // unreadable lockfile — treat as stealable
268
+ }
269
+ let holderAlive = false
270
+ const holderPidNum = Number(holderPid)
271
+ if (holderPid !== "" && Number.isFinite(holderPidNum) && holderPidNum > 0) {
272
+ if (holderPidNum === process.pid) {
273
+ // Same-process holder (another async context / window in this
274
+ // process) always releases via finally — wait for it, never steal
275
+ // (stealing from ourselves breaks in-process serialization).
276
+ holderAlive = true
277
+ } else {
278
+ try {
279
+ process.kill(holderPidNum, 0)
280
+ holderAlive = true
281
+ } catch (killError) {
282
+ // ESRCH = dead; EPERM/other = alive but not signalable by us
283
+ holderAlive = (killError as { code?: string }).code !== "ESRCH"
284
+ }
285
+ }
286
+ }
287
+ if (!holderAlive) {
288
+ await unlink(ntPath(lock)).catch(() => {})
289
+ if (onSteal) onSteal(Date.now() - info.mtimeMs, holderPid)
290
+ continue
291
+ }
292
+ // live slow holder — fall through to the timeout check
181
293
  }
182
294
  } catch {
183
295
  continue // lock vanished between attempts
@@ -193,10 +305,18 @@ async function withLock<T>(lockTarget: string, fn: () => Promise<T>, onDegrade?:
193
305
  try {
194
306
  return await fn()
195
307
  } finally {
196
- try {
197
- await unlink(ntPath(lock))
198
- } catch {
199
- // best effort
308
+ // A degraded waiter never owned the lock — unlinking it would delete the
309
+ // LIVE holder's lockfile and let a third process enter the critical
310
+ // section concurrently (the corruption seen in production logs).
311
+ // Even a legitimate holder must verify ownership: if a stale-steal gave
312
+ // the lock away mid-hold, unlinking would delete the STEALER's lockfile.
313
+ if (acquired) {
314
+ try {
315
+ const owner = await readFile(ntPath(lock), "utf8").catch(() => "")
316
+ if (owner === String(process.pid)) await unlink(ntPath(lock))
317
+ } catch {
318
+ // best effort
319
+ }
200
320
  }
201
321
  }
202
322
  }
@@ -210,9 +330,74 @@ export class GateStore {
210
330
  private enforcedCache: Gate[] | null = null
211
331
  private index: IndexFile | null = null
212
332
  private indexMtimeMs = 0
333
+ /** PLUGIN_VERSION that last ran migrate() — persisted in gates.json so the
334
+ * 2nd..Nth start of the same version skips the full per-gate scan */
335
+ private migratedStamp: string | null = null
336
+ /** events queued inside the gates lock, flushed on the next log() — logging
337
+ * under the gates lock extends the critical section into degrade storms */
338
+ private deferredEvents: LogEvent[] = []
339
+ /** Set by Stores on the PROJECT store → the global store. Deferred events
340
+ * bypass logAll's routing, so a salient event deferred on the project store
341
+ * (demoted in migrate, retired-healed in expireAll) would never reach the
342
+ * global forensics; on flush/log the salient subset of the drained batch is
343
+ * mirrored here. Direct (non-deferred) events are NOT mirrored — logAll
344
+ * already routes those, so mirroring them would double-write. */
345
+ routeSalientTo: GateStore | null = null
213
346
 
214
347
  constructor(public readonly dir: string) {}
215
348
 
349
+ /** PLUGIN_VERSION that last ran migrate() on this store (null = never). */
350
+ get migratedVersion(): string | null {
351
+ return this.migratedStamp
352
+ }
353
+
354
+ /** Set the migration stamp (persisted by the next save()). */
355
+ set migratedVersion(version: string | null) {
356
+ this.migratedStamp = version
357
+ }
358
+
359
+ /** Queue an event while holding the gates lock; the next log() flushes it. */
360
+ deferEvent(event: LogEvent): void {
361
+ this.deferredEvents.push(event)
362
+ }
363
+
364
+ /**
365
+ * Flush queued deferred events now. Scripts (doctor, migrate) repair stores
366
+ * and then exit without any further log() call — without this, the deferred
367
+ * repaired/quarantined/demoted/expired events are silently lost, breaking
368
+ * the "every repair is logged" invariant. No-op when the queue is empty.
369
+ */
370
+ async flushDeferred(): Promise<void> {
371
+ if (this.deferredEvents.length === 0) return
372
+ let salient: LogEvent[] = []
373
+ await withLock(this.logPath, async () => {
374
+ if (this.deferredEvents.length === 0) return
375
+ const batch = this.deferredEvents
376
+ this.deferredEvents = []
377
+ if (this.routeSalientTo !== null) salient = batch.filter((e) => GLOBAL_LOG_EVENTS.has(e.type))
378
+ await mkdir(ntPath(this.dir), { recursive: true })
379
+ for (const e of batch) {
380
+ const line = `${JSON.stringify({ ts: new Date().toISOString(), ...e })}\n`
381
+ await appendFile(ntPath(this.logPath), line, "utf8")
382
+ }
383
+ })
384
+ if (salient.length > 0 && this.routeSalientTo !== null) await this.routeSalientTo.appendBatch(salient)
385
+ }
386
+
387
+ /** Append an already-formed batch of events under the log lock. Used by a peer
388
+ * store mirroring its salient deferred events into the global forensics — the
389
+ * batch is fully captured before this runs, so nothing here can be lost. */
390
+ async appendBatch(events: LogEvent[]): Promise<void> {
391
+ if (events.length === 0) return
392
+ await withLock(this.logPath, async () => {
393
+ await mkdir(ntPath(this.dir), { recursive: true })
394
+ for (const e of events) {
395
+ const line = `${JSON.stringify({ ts: new Date().toISOString(), ...e })}\n`
396
+ await appendFile(ntPath(this.logPath), line, "utf8")
397
+ }
398
+ })
399
+ }
400
+
216
401
  private get gatesPath(): string {
217
402
  return join(this.dir, "gates.json")
218
403
  }
@@ -227,9 +412,16 @@ export class GateStore {
227
412
 
228
413
  /** Run a load→mutate→save section under the store's exclusive lock. */
229
414
  async runLocked<T>(fn: () => Promise<T>): Promise<T> {
230
- return withLock(this.gatesPath, fn, () => {
231
- this.log({ type: "degraded", key: "gates.lock", snippet: `lock contention exceeded ${LOCK_WAIT_MS}ms; critical section ran unlocked` }).catch(() => {})
232
- })
415
+ return withLock(
416
+ this.gatesPath,
417
+ fn,
418
+ () => {
419
+ this.deferEvent({ type: "degraded", key: "gates.lock", snippet: `lock contention exceeded ${LOCK_WAIT_MS}ms; critical section ran unlocked` })
420
+ },
421
+ (heldMs, previousPid) => {
422
+ this.deferEvent({ type: "repaired", key: "gates.lock", snippet: `stale lock stolen (held ${heldMs}ms, previous pid ${previousPid || "?"})` })
423
+ },
424
+ )
233
425
  }
234
426
 
235
427
  /**
@@ -244,30 +436,41 @@ export class GateStore {
244
436
  if (!force && this.gates !== null && Date.now() < this.cacheUntilMs) {
245
437
  return this.gates
246
438
  }
439
+ let info: Awaited<ReturnType<typeof stat>>
440
+ let raw: string
247
441
  try {
248
- const info = await stat(ntPath(this.gatesPath))
442
+ info = await stat(ntPath(this.gatesPath))
249
443
  if (!force && this.gates !== null && info.mtimeMs === this.mtimeMs) {
250
444
  this.cacheUntilMs = Date.now() + LOAD_CACHE_TTL_MS
251
445
  return this.gates
252
446
  }
253
- const raw = await readFile(ntPath(this.gatesPath), "utf8")
254
- const parsed = JSON.parse(raw) as Partial<GatesFile>
255
- const records = Array.isArray(parsed.gates) ? parsed.gates : []
256
- const gates: Gate[] = []
257
- for (const record of records) {
258
- const gate = coerceGateShape(record)
259
- if (gate === null) continue
260
- repairGate(gate)
261
- gates.push(gate)
447
+ raw = await readFile(ntPath(this.gatesPath), "utf8")
448
+ } catch {
449
+ // missing or unreadable gates.json treat as an empty store
450
+ if (this.gates === null) {
451
+ this.gates = []
452
+ this.keyIndex = new Map()
453
+ this.enforcedCache = []
262
454
  }
263
- this.gates = gates
264
- this.keyIndex = new Map(gates.map((g) => [g.key, g]))
265
- this.enforcedCache = gates.filter((g) => g.status !== "watching")
266
- this.mtimeMs = info.mtimeMs
267
455
  this.cacheUntilMs = Date.now() + LOAD_CACHE_TTL_MS
268
456
  return this.gates
457
+ }
458
+ let records: unknown[] | null = null
459
+ try {
460
+ const parsed = JSON.parse(raw) as Partial<GatesFile>
461
+ if (Array.isArray(parsed.gates)) {
462
+ records = parsed.gates
463
+ this.migratedStamp = typeof parsed.migrated === "string" ? parsed.migrated : null
464
+ }
269
465
  } catch {
270
- // missing or unreadable gates.json treat as an empty store
466
+ // fall through to the corruption branch
467
+ }
468
+ if (records === null) {
469
+ // Corruption ≠ absence: an unparseable file quarantines (bytes kept,
470
+ // fresh store started) instead of silently emptying — the silent path
471
+ // let the next save() overwrite recoverable gates with a blank store.
472
+ // Only under the lock (force): unlocked reads never write.
473
+ if (force) await this.quarantineGatesFile(raw)
271
474
  if (this.gates === null) {
272
475
  this.gates = []
273
476
  this.keyIndex = new Map()
@@ -276,6 +479,19 @@ export class GateStore {
276
479
  this.cacheUntilMs = Date.now() + LOAD_CACHE_TTL_MS
277
480
  return this.gates
278
481
  }
482
+ const gates: Gate[] = []
483
+ for (const record of records) {
484
+ const gate = coerceGateShape(record)
485
+ if (gate === null) continue
486
+ repairGate(gate)
487
+ gates.push(gate)
488
+ }
489
+ this.gates = gates
490
+ this.keyIndex = new Map(gates.map((g) => [g.key, g]))
491
+ this.enforcedCache = gates.filter((g) => g.status !== "watching")
492
+ this.mtimeMs = info.mtimeMs
493
+ this.cacheUntilMs = Date.now() + LOAD_CACHE_TTL_MS
494
+ return this.gates
279
495
  }
280
496
 
281
497
  /** O(1) exact lookup over the cached gates (call load() first to refresh). */
@@ -298,6 +514,10 @@ export class GateStore {
298
514
  if (this.gates === null) return
299
515
  await mkdir(ntPath(this.dir), { recursive: true })
300
516
  const payload: GatesFile = { version: 1, gates: this.gates }
517
+ if (this.migratedStamp !== null) payload.migrated = this.migratedStamp
518
+ // The WRITER's own version, stamped on every save — a stale plugin session
519
+ // leaves its old version here (durable drift signal; log inits rotate away).
520
+ payload.lastInitVersion = PLUGIN_VERSION
301
521
  await atomicWrite(this.gatesPath, `${JSON.stringify(payload, null, 2)}\n`)
302
522
  try {
303
523
  this.mtimeMs = (await stat(ntPath(this.gatesPath))).mtimeMs
@@ -342,21 +562,44 @@ export class GateStore {
342
562
 
343
563
  /** Run an index load→mutate→save section under the index's own lock. */
344
564
  async runLockedIndex<T>(fn: () => Promise<T>): Promise<T> {
345
- return withLock(this.indexPath, fn, () => {
346
- this.log({ type: "degraded", key: "index.lock", snippet: `lock contention exceeded ${LOCK_WAIT_MS}ms; critical section ran unlocked` }).catch(() => {})
347
- })
565
+ return withLock(
566
+ this.indexPath,
567
+ fn,
568
+ () => {
569
+ this.deferEvent({ type: "degraded", key: "index.lock", snippet: `lock contention exceeded ${LOCK_WAIT_MS}ms; critical section ran unlocked` })
570
+ },
571
+ (heldMs, previousPid) => {
572
+ this.deferEvent({ type: "repaired", key: "index.lock", snippet: `stale lock stolen (held ${heldMs}ms, previous pid ${previousPid || "?"})` })
573
+ },
574
+ )
348
575
  }
349
576
 
350
577
  /**
351
578
  * Append under the log lock: every OpenCode window shares the global log,
352
579
  * and unlocked concurrent appends interleave into broken JSON lines.
580
+ * Also flushes events deferred from inside the gates lock — logging there
581
+ * extends the critical section into degrade storms. The drain happens INSIDE
582
+ * the log lock so events deferred between the call and lock acquisition are
583
+ * included in this flush (draining before the lock dropped them).
353
584
  */
354
585
  async log(event: LogEvent): Promise<void> {
586
+ let salientDeferred: LogEvent[] = []
355
587
  await withLock(this.logPath, async () => {
588
+ const batch = this.deferredEvents
589
+ this.deferredEvents = []
590
+ // Route only the deferred batch — it bypassed logAll. The direct `event`
591
+ // is routed by the caller (logAll); mirroring it here too would write it
592
+ // to the global log twice.
593
+ if (this.routeSalientTo !== null) salientDeferred = batch.filter((e) => GLOBAL_LOG_EVENTS.has(e.type))
594
+ batch.push(event)
356
595
  await mkdir(ntPath(this.dir), { recursive: true })
357
- const line = `${JSON.stringify({ ts: new Date().toISOString(), ...event })}\n`
358
- await appendFile(ntPath(this.logPath), line, "utf8")
596
+ for (const e of batch) {
597
+ const line = `${JSON.stringify({ ts: new Date().toISOString(), ...e })}\n`
598
+ await appendFile(ntPath(this.logPath), line, "utf8")
599
+ }
359
600
  })
601
+ // Mirror runs after our own log lock releases — leaf locks, one at a time.
602
+ if (salientDeferred.length > 0 && this.routeSalientTo !== null) await this.routeSalientTo.appendBatch(salientDeferred)
360
603
  }
361
604
 
362
605
  /**
@@ -368,7 +611,10 @@ export class GateStore {
368
611
  const gates = await this.load(true)
369
612
  const now = Date.now()
370
613
  const expired = gates.filter((g) => {
371
- const ttl = g.status !== "watching" || g.count >= PROMOTE_COUNT ? ttlDays : noiseTtlDays
614
+ // The long TTL belongs to patterns that reached THEIR promotion bar
615
+ // probe tools promote at 5, so a probe gate at count 3-4 is still noise.
616
+ const threshold = PROBE_TOOLS.has(g.tool) ? PROMOTE_COUNT_PROBE : PROMOTE_COUNT
617
+ const ttl = g.status !== "watching" || g.count >= threshold ? ttlDays : noiseTtlDays
372
618
  return Date.parse(g.lastSeen) < now - ttl * DAY_MS
373
619
  })
374
620
  if (expired.length === 0) return []
@@ -414,7 +660,8 @@ export class GateStore {
414
660
  * log.jsonl.corrupt. Every repair is logged — healing must be visible.
415
661
  */
416
662
  async reconcile(): Promise<void> {
417
- await withLock(this.gatesPath, async () => {
663
+ // runLocked (not bare withLock) so stale-steal/degrade are reported.
664
+ await this.runLocked(async () => {
418
665
  let raw: string | null = null
419
666
  try {
420
667
  raw = await readFile(ntPath(this.gatesPath), "utf8")
@@ -429,22 +676,13 @@ export class GateStore {
429
676
  parsed = null
430
677
  }
431
678
  if (parsed === null || typeof parsed !== "object" || !Array.isArray(parsed.gates)) {
432
- // SQLite-style quarantine: move aside, keep the bytes, start clean.
433
- // Scrub before preserving — raw bytes may carry unredacted secrets.
434
- const quarantine = `${this.gatesPath}.corrupt-${Date.now()}`
435
- try {
436
- await writeFile(ntPath(quarantine), scrubSecrets(raw), "utf8")
437
- await unlink(ntPath(this.gatesPath))
438
- this.gates = []
439
- this.keyIndex = null
440
- this.enforcedCache = null
441
- this.mtimeMs = 0
442
- await this.save()
443
- await this.log({ type: "quarantined", key: "gates.json", snippet: `unparseable gates file quarantined (scrubbed) to ${quarantine}` })
444
- } catch {
445
- // quarantine failed — next reconcile retries; never destroy the file
446
- }
679
+ await this.quarantineGatesFile(raw)
447
680
  } else {
681
+ // Preserve the migration stamp: reconcile parses the file directly
682
+ // (bypassing load()) and save() only writes the stamp it knows — if
683
+ // we dropped it here, the next migrate() would re-run its full scan
684
+ // on every startup, killing the init-storm optimization.
685
+ this.migratedStamp = typeof parsed.migrated === "string" ? parsed.migrated : null
448
686
  let dropped = 0
449
687
  let repaired = 0
450
688
  const byKey = new Map<string, Gate>()
@@ -467,7 +705,7 @@ export class GateStore {
467
705
  this.mtimeMs = 0
468
706
  await this.save()
469
707
  if (dropped > 0 || repaired > 0 || merged > 0) {
470
- await this.log({
708
+ this.deferEvent({
471
709
  type: "repaired",
472
710
  key: "gates.json",
473
711
  snippet: `dropped ${dropped} hopeless record(s), repaired ${repaired}, merged ${merged} duplicate key(s)`,
@@ -475,43 +713,78 @@ export class GateStore {
475
713
  }
476
714
  }
477
715
  }
478
- await this.exciseCorruptLogLines()
479
716
  })
717
+ // Log hygiene runs OUTSIDE the gates lock: the log lock is a leaf, and
718
+ // holding the gates lock across a full log read+parse+rewrite extends the
719
+ // critical section exactly at init-storm time (round-4 lesson).
720
+ await this.exciseCorruptLogLines()
480
721
  }
481
722
 
482
- /** Move unparseable JSONL lines to log.jsonl.corrupt; good lines stay. */
483
- private async exciseCorruptLogLines(): Promise<void> {
484
- let raw: string
723
+ /**
724
+ * SQLite-style quarantine: unparseable gates bytes move aside (scrubbed —
725
+ * they may carry unredacted secrets), a clean empty store starts. Caller
726
+ * must hold the store lock. Never destroys the bytes.
727
+ */
728
+ private async quarantineGatesFile(raw: string): Promise<void> {
729
+ const quarantine = `${this.gatesPath}.corrupt-${Date.now()}`
485
730
  try {
486
- raw = await readFile(ntPath(this.logPath), "utf8")
731
+ await writeFile(ntPath(quarantine), scrubSecrets(raw), "utf8")
732
+ await unlink(ntPath(this.gatesPath))
733
+ this.gates = []
734
+ this.keyIndex = new Map()
735
+ this.enforcedCache = []
736
+ this.mtimeMs = 0
737
+ this.migratedStamp = null
738
+ await this.save()
739
+ this.deferEvent({ type: "quarantined", key: "gates.json", snippet: `unparseable gates file quarantined (scrubbed) to ${quarantine}` })
487
740
  } catch {
488
- return // no log yet
741
+ // quarantine failed — next reconcile retries; never destroy the file
489
742
  }
490
- const good: string[] = []
491
- const bad: string[] = []
492
- for (const line of raw.split("\n")) {
493
- if (line.trim() === "") continue
743
+ }
744
+
745
+ /** Move unparseable JSONL lines to log.jsonl.corrupt; good lines stay.
746
+ * The read happens INSIDE the log lock: reading outside and rewriting
747
+ * inside dropped every line another window appended between the two
748
+ * (concurrent OpenCode startups all reconcile at once). */
749
+ private async exciseCorruptLogLines(): Promise<void> {
750
+ let excised = 0
751
+ await withLock(this.logPath, async () => {
752
+ let raw: string
494
753
  try {
495
- JSON.parse(line)
496
- good.push(line)
754
+ raw = await readFile(ntPath(this.logPath), "utf8")
497
755
  } catch {
498
- bad.push(line)
756
+ return // no log yet
499
757
  }
500
- }
501
- if (bad.length === 0) return
502
- await withLock(this.logPath, async () => {
758
+ const good: string[] = []
759
+ const bad: string[] = []
760
+ for (const line of raw.split("\n")) {
761
+ if (line.trim() === "") continue
762
+ try {
763
+ JSON.parse(line)
764
+ good.push(line)
765
+ } catch {
766
+ bad.push(line)
767
+ }
768
+ }
769
+ if (bad.length === 0) return
503
770
  // Scrub: excised raw lines may carry unredacted secrets.
504
771
  await appendFile(ntPath(`${this.logPath}.corrupt`), `${scrubSecrets(bad.join("\n"))}\n`, "utf8")
505
772
  await atomicWrite(this.logPath, good.length > 0 ? `${good.join("\n")}\n` : "")
773
+ excised = bad.length
506
774
  })
507
- await this.log({ type: "repaired", key: "log.jsonl", snippet: `excised ${bad.length} corrupt line(s) to log.jsonl.corrupt` })
775
+ if (excised > 0) {
776
+ this.deferEvent({ type: "repaired", key: "log.jsonl", snippet: `excised ${excised} corrupt line(s) to log.jsonl.corrupt` })
777
+ }
508
778
  }
509
779
  }
510
780
 
511
781
  /** Merge a gate's accumulated evidence into an existing gate with the same key. */
512
782
  export function mergeGate(target: Gate, source: Gate): void {
513
- // blocking is the stronger state — a merge must never demote an enforced gate
514
- if (source.status === "blocking") target.status = "blocking"
783
+ // Rank-preserving: blocking > reminding > watching — a merge never demotes
784
+ // (a reminding source merged into a watching target used to lose its tier).
785
+ if (source.status === "blocking" || (source.status === "reminding" && target.status === "watching")) {
786
+ target.status = source.status
787
+ }
515
788
  target.count += source.count
516
789
  for (const session of source.sessions) {
517
790
  if (!target.sessions.includes(session)) target.sessions.push(session)
@@ -530,8 +803,31 @@ export function mergeGate(target: Gate, source: Gate): void {
530
803
  target.blockedCount += source.blockedCount
531
804
  target.recurredAfterReminder += source.recurredAfterReminder
532
805
  target.recurredAfterGate += source.recurredAfterGate
806
+ target.overrideCount += source.overrideCount
807
+ if (source.promotionCount !== undefined) {
808
+ target.promotionCount = (target.promotionCount ?? 0) + source.promotionCount
809
+ }
533
810
  if (target.correction === undefined && source.correction !== undefined) target.correction = source.correction
534
811
  if (source.review === true) target.review = true
812
+ // A demotion is earned behavior — merging must never launder it away.
813
+ if (source.feedbackDemoted === true) target.feedbackDemoted = true
814
+ // Baselines track the counters' scale: counters sum across merges, so the
815
+ // baseline sums too (the grace-window delta is preserved).
816
+ if (source.feedbackBaseline !== undefined) {
817
+ if (target.feedbackBaseline === undefined) {
818
+ target.feedbackBaseline = { recurred: source.feedbackBaseline.recurred, overrides: source.feedbackBaseline.overrides }
819
+ } else {
820
+ target.feedbackBaseline.recurred += source.feedbackBaseline.recurred
821
+ target.feedbackBaseline.overrides += source.feedbackBaseline.overrides
822
+ }
823
+ }
824
+ // Retirement damping baseline: keep the target's if present (its count already
825
+ // anchors it); otherwise adopt the source's. Never fabricate one — merging
826
+ // retired gates is rare (dedupe/escalation) and a wrong baseline would either
827
+ // re-open the oscillation or lock the gate out of re-promotion.
828
+ if (target.retireBaseline === undefined && source.retireBaseline !== undefined) {
829
+ target.retireBaseline = { count: source.retireBaseline.count }
830
+ }
535
831
  // Session enforcement state must survive merges — dropping it silently
536
832
  // resets the remind→block chain on every escalation/dedupe.
537
833
  if (source.remindedSessions !== undefined) {
@@ -550,6 +846,42 @@ export function mergeGate(target: Gate, source: Gate): void {
550
846
  if (existing === undefined || at > existing) target.failedSessions[session] = at
551
847
  }
552
848
  }
849
+ if (source.reoffenseSessions !== undefined) {
850
+ if (target.reoffenseSessions === undefined) target.reoffenseSessions = []
851
+ for (const session of source.reoffenseSessions) {
852
+ if (!target.reoffenseSessions.includes(session)) target.reoffenseSessions.push(session)
853
+ }
854
+ if (target.reoffenseSessions.length > MAX_SESSIONS) target.reoffenseSessions = target.reoffenseSessions.slice(-MAX_SESSIONS)
855
+ }
856
+ }
857
+
858
+ /**
859
+ * Enforcement feedback (the negative twin of `healed`): a gate that keeps
860
+ * failing after promotion, or keeps getting explicitly bypassed, is friction
861
+ * — it does not teach. Demote to watching and mark `feedbackDemoted` so the
862
+ * promotion logic never re-enforces it mechanically. The baseline records
863
+ * WHERE the counters stood at demotion: a human re-enforcement starts a fresh
864
+ * grace window instead of re-demoting on the next failure.
865
+ *
866
+ * Recurrence demotion additionally requires DISTINCT reoffense sessions
867
+ * (failures after a reminder — failures the gate had a chance to prevent):
868
+ * first-encounter failures never saw a reminder and must not demote, and one
869
+ * bad session/model in a shared store must not demote a gate for everyone.
870
+ * Returns true when the gate changed; the caller saves and logs.
871
+ */
872
+ export function checkFeedbackDemotion(gate: Gate): boolean {
873
+ if (gate.status === "watching") return false
874
+ const baseRecurred = gate.feedbackBaseline?.recurred ?? 0
875
+ const baseOverrides = gate.feedbackBaseline?.overrides ?? 0
876
+ const recurredEnough = gate.recurredAfterGate - baseRecurred >= DEMOTE_RECURRENCES
877
+ const reoffenseVotes = gate.reoffenseSessions?.length ?? 0
878
+ if ((recurredEnough && reoffenseVotes >= DEMOTE_REOFFENSE_SESSIONS) || gate.overrideCount - baseOverrides >= DEMOTE_OVERRIDES) {
879
+ gate.status = "watching"
880
+ gate.feedbackDemoted = true
881
+ gate.feedbackBaseline = { recurred: gate.recurredAfterGate, overrides: gate.overrideCount }
882
+ return true
883
+ }
884
+ return false
553
885
  }
554
886
 
555
887
  /**
@@ -561,7 +893,15 @@ export class Stores {
561
893
  constructor(
562
894
  public readonly globalStore: GateStore,
563
895
  public readonly projectStore: GateStore | null,
564
- ) {}
896
+ ) {
897
+ // Deferred events bypass logAll's routing, so wire the project store to
898
+ // mirror its salient deferred events (demoted in migrate, retired-healed in
899
+ // expireAll) into the global forensics. The global store gets no peer — it
900
+ // must never route to itself.
901
+ if (this.projectStore !== null && this.projectStore !== this.globalStore) {
902
+ this.projectStore.routeSalientTo = this.globalStore
903
+ }
904
+ }
565
905
 
566
906
  private scopes(): GateStore[] {
567
907
  return this.projectStore ? [this.projectStore, this.globalStore] : [this.globalStore]
@@ -588,6 +928,9 @@ export class Stores {
588
928
  }
589
929
  // Over-long signatures match exactly only — see FUZZY_MAX_LEN.
590
930
  if (signature.length > FUZZY_MAX_LEN) return null
931
+ // Over-generic bash shapes match exactly only: fuzzy-matching them onto
932
+ // concrete gates would enforce/pollute unrelated calls (family noise).
933
+ if (signature.startsWith("bash:") && !hasResidualIdentity(signature)) return null
591
934
  let best: { gate: Gate; store: GateStore; score: number } | null = null
592
935
  for (const store of this.scopes()) {
593
936
  for (const gate of store.enforcedOnly()) {
@@ -610,8 +953,16 @@ export class Stores {
610
953
  }
611
954
 
612
955
  async logAll(event: LogEvent): Promise<void> {
613
- for (const store of this.scopes()) {
614
- await store.log(event)
956
+ if (this.projectStore) {
957
+ // Project log keeps the complete forensics (low contention). The global
958
+ // log is shared by every window of every project — the most-contended
959
+ // lock — so it only gets the events that change what the machine
960
+ // remembers. Detected/reminded/blocked are high-volume and stay local.
961
+ await this.projectStore.log(event)
962
+ if (GLOBAL_LOG_EVENTS.has(event.type)) await this.globalStore.log(event)
963
+ } else {
964
+ // Sole store: it is the only forensics — keep everything.
965
+ await this.globalStore.log(event)
615
966
  }
616
967
  }
617
968
 
@@ -622,23 +973,48 @@ export class Stores {
622
973
  for (const gate of expired) {
623
974
  // Correction lifecycle: a corrected gate that never recurred after
624
975
  // promotion means the pattern died out — the mechanical signal that
625
- // the teaching worked.
976
+ // the teaching worked. Deferred: logging under the gates lock
977
+ // extends the critical section (a big sweep = N log-lock takes).
626
978
  if (gate.correction !== undefined && gate.recurredAfterGate === 0) {
627
- await store.log({ type: "retired-healed", key: gate.key, tool: gate.tool, snippet: gate.correction.slice(0, 200) })
979
+ store.deferEvent({ type: "retired-healed", key: gate.key, tool: gate.tool, snippet: gate.correction.slice(0, 200) })
628
980
  } else {
629
- await store.log({ type: "expired", key: gate.key, tool: gate.tool })
981
+ store.deferEvent({ type: "expired", key: gate.key, tool: gate.tool })
630
982
  }
631
983
  }
632
984
  })
633
985
  }
986
+ // Keys this process can see (own project + global). A key absent here may
987
+ // still live in another project's store — orphan pruning is therefore a
988
+ // time-decayed candidacy, not an immediate delete.
989
+ const visibleKeys = new Set<string>()
990
+ for (const store of this.scopes()) {
991
+ for (const gate of await store.load()) visibleKeys.add(gate.key)
992
+ }
634
993
  // The cross-project index rots on the same schedule as the gates.
635
994
  await this.globalStore.runLockedIndex(async () => {
636
995
  const index = await this.globalStore.loadIndex(true)
637
- const cutoff = Date.now() - ttlDays * DAY_MS
996
+ const now = Date.now()
997
+ const cutoff = now - ttlDays * DAY_MS
638
998
  let changed = false
639
999
  for (const key of Object.keys(index.keys)) {
640
1000
  const entry = index.keys[key]
641
- if (entry && Date.parse(entry.lastSeen) < cutoff) {
1001
+ if (!entry) continue
1002
+ if (Date.parse(entry.lastSeen) < cutoff) {
1003
+ delete index.keys[key]
1004
+ changed = true
1005
+ continue
1006
+ }
1007
+ if (visibleKeys.has(key)) {
1008
+ if (entry.orphanCandidateSince !== undefined) {
1009
+ delete entry.orphanCandidateSince
1010
+ changed = true
1011
+ }
1012
+ continue
1013
+ }
1014
+ if (entry.orphanCandidateSince === undefined) {
1015
+ entry.orphanCandidateSince = now
1016
+ changed = true
1017
+ } else if (now - entry.orphanCandidateSince > ORPHAN_CANDIDATE_DAYS * DAY_MS) {
642
1018
  delete index.keys[key]
643
1019
  changed = true
644
1020
  }
@@ -654,6 +1030,15 @@ export class Stores {
654
1030
  }
655
1031
  }
656
1032
 
1033
+ /** Flush deferred events on every scope (timer sweeps defer expired/
1034
+ * retired-healed events that would otherwise wait for the next hook log,
1035
+ * and are lost if the process exits first). */
1036
+ async flushDeferredAll(): Promise<void> {
1037
+ for (const store of this.scopes()) {
1038
+ await store.flushDeferred()
1039
+ }
1040
+ }
1041
+
657
1042
  /** Forget per-session enforcement state when a session dies. */
658
1043
  async forgetSession(sessionID: string): Promise<void> {
659
1044
  for (const store of this.scopes()) {
@@ -678,14 +1063,21 @@ export class Stores {
678
1063
  }
679
1064
 
680
1065
  /**
681
- * One-time (idempotent) schema/behavior migration:
1066
+ * Idempotent schema/behavior migration:
682
1067
  * - probe-tool gates never block (they were learned under the old policy)
683
1068
  * - signatures and snippets are secret-scrubbed (cleans historical leaks)
684
1069
  * - project copies of already-global keys merge into the global gate
1070
+ * `force` re-runs the full per-gate scan even when the version stamp already
1071
+ * matches — doctor --repair and the migrate script must apply ALL healing
1072
+ * regardless of the stamp; only normal startup uses the init-storm skip.
685
1073
  */
686
- async migrate(): Promise<void> {
1074
+ async migrate(force = false): Promise<void> {
687
1075
  for (const store of this.scopes()) {
688
1076
  await store.runLocked(async () => {
1077
+ // Init-storm killer: the 2nd..Nth start of the same version skips the
1078
+ // full per-gate scan. Policy re-checks still run on every load via
1079
+ // repairGate, and new gates are created compliant, so the stamp is safe.
1080
+ if (!force && store.migratedVersion === PLUGIN_VERSION) return
689
1081
  const gates = await store.load(true)
690
1082
  let changed = false
691
1083
  for (const gate of gates) {
@@ -701,17 +1093,28 @@ export class Stores {
701
1093
  }
702
1094
  if (
703
1095
  gate.status === "watching" &&
1096
+ gate.feedbackDemoted !== true &&
1097
+ gate.retireBaseline === undefined &&
704
1098
  canRemind(gate.tool, gate.signature) &&
705
1099
  gate.count >= PROMOTE_COUNT &&
706
1100
  gate.sessions.length >= PROMOTE_SESSIONS
707
1101
  ) {
708
1102
  // Recurring diagnostics already proven under the old policy start
709
1103
  // reminding immediately instead of waiting for the next failure.
1104
+ // feedbackDemoted gates are exempt: the agent's behavior already
1105
+ // voted against enforcement — re-enforcing on every restart would
1106
+ // violate "never re-promotes mechanically".
1107
+ // retireBaseline gates are exempt for the same reason: they RETIRED
1108
+ // on evidence (healed/taught) — the lifetime count that clears this
1109
+ // bar is the pre-retirement evidence the damping baseline exists to
1110
+ // discount. Re-promoting them here on every migrate would re-open the
1111
+ // promote→heal→promote oscillation the baseline was added to kill;
1112
+ // their re-promotion must earn a fresh bar via recordFailure.
710
1113
  gate.status = "reminding"
711
1114
  changed = true
712
1115
  }
713
- const signature = scrubSecrets(gate.signature)
714
- const snippet = scrubSecrets(gate.snippet)
1116
+ const signature = sanitizeForStore(gate.signature)
1117
+ const snippet = sanitizeForStore(gate.snippet)
715
1118
  if (signature !== gate.signature) {
716
1119
  gate.signature = signature
717
1120
  changed = true
@@ -721,13 +1124,48 @@ export class Stores {
721
1124
  changed = true
722
1125
  }
723
1126
  if (gate.correction !== undefined) {
724
- const correction = scrubSecrets(gate.correction)
1127
+ const correction = sanitizeForStore(gate.correction)
725
1128
  if (correction !== gate.correction) {
726
1129
  gate.correction = correction
727
1130
  changed = true
728
1131
  }
729
1132
  }
1133
+ // Backfill: an enforced gate with no correction gets a mechanical
1134
+ // default so it teaches immediately instead of sitting "NOT TEACHING".
1135
+ if (gate.status !== "watching" && gate.correction === undefined) {
1136
+ gate.correction = suggestCorrection(gate.signature, gate.snippet)
1137
+ changed = true
1138
+ }
1139
+ // Feedback catch-up: gates that already crossed the demotion
1140
+ // thresholds before the counters existed are demoted on the spot —
1141
+ // enforcement must reflect the agent's actual behavior.
1142
+ if (checkFeedbackDemotion(gate)) {
1143
+ changed = true
1144
+ store.deferEvent({
1145
+ type: "demoted",
1146
+ key: gate.key,
1147
+ tool: gate.tool,
1148
+ snippet: `feedback demotion (recurred ${gate.recurredAfterGate}, overridden ${gate.overrideCount})`,
1149
+ })
1150
+ }
730
1151
  }
1152
+ // Retroactive noise cleanup: patterns the current policy classifies as
1153
+ // infrastructure noise (lsp daemon, mcp transport, non-2xx) were
1154
+ // recorded as failures by older versions and bloat the store/index.
1155
+ // Backdate them to the epoch so this init's TTL sweep expires them
1156
+ // (both dates, or repairGate's inverted-date swap would undo it).
1157
+ const epoch = new Date(0).toISOString()
1158
+ for (const gate of gates) {
1159
+ if (isNoiseError(gate.signature) || isNoiseError(gate.snippet)) {
1160
+ gate.firstSeen = epoch
1161
+ gate.lastSeen = epoch
1162
+ changed = true
1163
+ }
1164
+ }
1165
+ // Stamp the migration and persist repairs (save is idempotent when
1166
+ // nothing changed beyond the stamp itself).
1167
+ store.migratedVersion = PLUGIN_VERSION
1168
+ changed = true
731
1169
  if (changed) await store.save()
732
1170
  })
733
1171
  }
@@ -765,7 +1203,7 @@ export class Stores {
765
1203
  * Structural self-healing across both scopes plus index reconciliation.
766
1204
  * Idempotent; runs at plugin init and via `doctor --repair`.
767
1205
  */
768
- async reconcileAll(globalProjects = 2): Promise<void> {
1206
+ async reconcileAll(globalProjects = GLOBAL_PROJECTS): Promise<void> {
769
1207
  for (const store of this.scopes()) {
770
1208
  await store.reconcile()
771
1209
  }
@@ -776,9 +1214,19 @@ export class Stores {
776
1214
  const projectStore = this.projectStore
777
1215
  if (projectStore) {
778
1216
  const index = await this.globalStore.loadIndex()
779
- const toEscalate = (await projectStore.load(true)).filter((g) => {
1217
+ // Non-force load: this is a routing-hint read (the authoritative
1218
+ // load(true) happens under the locks below). The force path would
1219
+ // quarantine an unparseable file WITHOUT the gates lock — the very
1220
+ // write-without-lock class round 3 fixed in doctor.
1221
+ const toEscalate = (await projectStore.load()).filter((g) => {
780
1222
  const entry = index.keys[g.key]
781
- return entry !== undefined && entry.projects.length >= globalProjects && !isRepoLocal(g.signature)
1223
+ // Count only project dirs that still exist on disk (ghost dirs from
1224
+ // renamed/moved repos must not strengthen escalation).
1225
+ return (
1226
+ entry !== undefined &&
1227
+ entry.projects.filter((p) => existsSync(p)).length >= globalProjects &&
1228
+ !isRepoLocal(g.signature)
1229
+ )
782
1230
  })
783
1231
  if (toEscalate.length > 0) {
784
1232
  await projectStore.runLocked(async () => {
@@ -805,42 +1253,37 @@ export class Stores {
805
1253
  }
806
1254
  }
807
1255
 
808
- // The index must mirror reality: a key absent from every scope is an
809
- // orphan (its gate expired or was deleted); a global gate missing from
810
- // the index loses cross-project history. Heal both directions.
811
- const knownKeys = new Set<string>()
812
- for (const store of this.scopes()) {
813
- for (const gate of await store.load(true)) knownKeys.add(gate.key)
814
- }
1256
+ // The index must mirror reality: a global gate missing from the index
1257
+ // loses cross-project history rebuild it. No orphan pruning here: this
1258
+ // process sees ONE project store + global, so an index key whose gate
1259
+ // lives in ANOTHER project is invisible, not dead — pruning it would
1260
+ // destroy cross-project escalation evidence. Genuine rot is handled by
1261
+ // the TTL sweep in expireAll; doctor reports true orphans across ALL
1262
+ // scopes (it discovers them from the index itself).
1263
+ // Log OUTSIDE the index lock (the log lock is the most-contended lock;
1264
+ // acquiring it while holding the index lock extends the index critical
1265
+ // section at exactly init-storm time).
1266
+ let rebuilt = 0
815
1267
  await this.globalStore.runLockedIndex(async () => {
816
1268
  const index = await this.globalStore.loadIndex(true)
817
- let pruned = 0
818
- for (const key of Object.keys(index.keys)) {
819
- const entry = index.keys[key]
820
- // Young entries may belong to a promotion in flight in another window
821
- // (its gates were snapshotted after this key was written) — only prune
822
- // orphans that have been stale for a day.
823
- if (entry && !knownKeys.has(key) && Date.now() - Date.parse(entry.lastSeen) > DAY_MS) {
824
- delete index.keys[key]
825
- pruned += 1
826
- }
827
- }
828
- let rebuilt = 0
829
- for (const gate of await this.globalStore.load(true)) {
1269
+ // Non-force load: we hold the INDEX lock, not the gates lock — the force
1270
+ // path could quarantine global gates.json without its lock. reconcile()
1271
+ // refreshed this cache moments ago, so the peek is fresh.
1272
+ for (const gate of await this.globalStore.load()) {
830
1273
  if (!index.keys[gate.key]) {
831
1274
  index.keys[gate.key] = { projects: [...gate.projects], lastSeen: gate.lastSeen }
832
1275
  rebuilt += 1
833
1276
  }
834
1277
  }
835
- if (pruned > 0 || rebuilt > 0) {
836
- await this.globalStore.saveIndex()
837
- await this.globalStore.log({
838
- type: "repaired",
839
- key: "index.json",
840
- snippet: `pruned ${pruned} orphan key(s), rebuilt ${rebuilt} missing entr(y/ies)`,
841
- })
842
- }
1278
+ if (rebuilt > 0) await this.globalStore.saveIndex()
843
1279
  })
1280
+ if (rebuilt > 0) {
1281
+ await this.globalStore.log({
1282
+ type: "repaired",
1283
+ key: "index.json",
1284
+ snippet: `rebuilt ${rebuilt} missing index entr(y/ies)`,
1285
+ })
1286
+ }
844
1287
  }
845
1288
 
846
1289
  async recordFailure(input: {
@@ -855,13 +1298,32 @@ export class Stores {
855
1298
  const now = new Date().toISOString()
856
1299
  // Route to the store that already knows this key (cheap unlocked peek).
857
1300
  let store = this.projectStore ?? this.globalStore
858
- if (!(await store.load()).some((g) => g.key === input.key)) {
859
- if ((await this.globalStore.load()).some((g) => g.key === input.key)) {
1301
+ await store.load()
1302
+ if (store.byKey(input.key) === undefined) {
1303
+ await this.globalStore.load()
1304
+ if (this.globalStore.byKey(input.key) !== undefined) {
860
1305
  store = this.globalStore
861
1306
  }
862
1307
  }
863
1308
 
864
- return store.runLocked(async () => {
1309
+ // FLAT lock phases — the previous implementation held the project gates
1310
+ // lock across the index lock + the global gates lock + two saves: the
1311
+ // longest critical section in the system, and every other window's waiter
1312
+ // degraded to unlocked after LOCK_WAIT_MS (the lost-update window the
1313
+ // `degraded` event documents). Each phase now holds exactly one lock.
1314
+ // Race windows introduced are benign/self-healing:
1315
+ // (a) a failure landing between the Phase-1 save and the Phase-3 extract
1316
+ // adds at most one concurrent failure's evidence delta to a gate that
1317
+ // is about to move — the pattern re-converges from its next failure;
1318
+ // (b) a concurrent escalation of the same key just merges (mergeGate);
1319
+ // (c) if the gate vanishes between phases (flood eviction / migrate), the
1320
+ // escalation aborts — a duplicate heals, never a hole.
1321
+ // Phase 1 — this store's gates lock (short): find/create/mutate the gate,
1322
+ // promotion, save. Returns the mutated gate (or an ephemeral gate that is
1323
+ // never persisted when the flood guard leaves no eviction candidate).
1324
+ const phase1 = await store.runLocked(async (): Promise<{ moved: Gate | null; ephemeral: Gate | null; promoted: boolean }> => {
1325
+ let promoted = false
1326
+ let ephemeral: Gate | null = null
865
1327
  const gates = await store.load(true)
866
1328
  let gate = gates.find((g) => g.key === input.key)
867
1329
  let fuzzyConsolidated = false
@@ -871,7 +1333,10 @@ export class Stores {
871
1333
  // Prefer the gate this session was already reminded about: the
872
1334
  // before-hook enforced from it, so the failure must land there too —
873
1335
  // otherwise the remind→block chain desyncs between the hooks.
874
- const fuzzyMatches = gates.filter((g) => g.tool === input.tool && fuzzySimilar(input.signature, g.signature))
1336
+ // Over-generic bash shapes never consolidate into concrete gates:
1337
+ // family noise must not inflate a specific call's evidence.
1338
+ const fuzzyAllowed = input.tool !== "bash" || hasResidualIdentity(input.signature)
1339
+ const fuzzyMatches = fuzzyAllowed ? gates.filter((g) => g.tool === input.tool && fuzzySimilar(input.signature, g.signature)) : []
875
1340
  gate = fuzzyMatches.find((g) => g.remindedSessions?.[input.sessionID] !== undefined) ?? fuzzyMatches[0]
876
1341
  if (gate !== undefined) fuzzyConsolidated = true
877
1342
  }
@@ -883,16 +1348,30 @@ export class Stores {
883
1348
  const candidate = gates[i]
884
1349
  if (candidate === undefined || candidate.status !== "watching") continue
885
1350
  const victim = victimIdx >= 0 ? gates[victimIdx] : undefined
886
- if (victim === undefined || candidate.count < victim.count || (candidate.count === victim.count && candidate.lastSeen < victim.lastSeen)) {
1351
+ if (victim === undefined) {
1352
+ victimIdx = i
1353
+ continue
1354
+ }
1355
+ // Feedback-demoted gates already proved unteachable — evict them
1356
+ // before evidence still trying to teach (under the old
1357
+ // lowest-count rule they were the STICKIEST residents: high
1358
+ // count, demoted, never enforcing).
1359
+ const candidateDemoted = candidate.feedbackDemoted === true
1360
+ const victimDemoted = victim.feedbackDemoted === true
1361
+ if (candidateDemoted !== victimDemoted) {
1362
+ if (candidateDemoted) victimIdx = i
1363
+ continue
1364
+ }
1365
+ if (candidate.count < victim.count || (candidate.count === victim.count && candidate.lastSeen < victim.lastSeen)) {
887
1366
  victimIdx = i
888
1367
  }
889
1368
  }
890
1369
  if (victimIdx < 0) {
891
1370
  // Every gate is enforced — do not create; degrade gracefully with
892
1371
  // an ephemeral gate that is never persisted.
893
- const ephemeral: Gate = {
1372
+ ephemeral = {
894
1373
  key: input.key,
895
- signature: scrubSecrets(input.signature),
1374
+ signature: sanitizeForStore(input.signature),
896
1375
  tool: input.tool,
897
1376
  status: "watching",
898
1377
  count: 1,
@@ -900,19 +1379,29 @@ export class Stores {
900
1379
  projects: input.projectDir !== "" ? [input.projectDir] : [],
901
1380
  firstSeen: now,
902
1381
  lastSeen: now,
903
- snippet: scrubSecrets(input.snippet),
1382
+ snippet: sanitizeForStore(input.snippet),
904
1383
  remindedCount: 0,
905
1384
  blockedCount: 0,
906
1385
  recurredAfterReminder: 0,
907
1386
  recurredAfterGate: 0,
1387
+ overrideCount: 0,
908
1388
  }
909
- return { gate: ephemeral, store, promoted: false, wentGlobal: false }
1389
+ return { moved: null, ephemeral, promoted: false }
910
1390
  }
1391
+ const evicted = gates[victimIdx]
911
1392
  gates.splice(victimIdx, 1)
1393
+ if (evicted !== undefined) {
1394
+ store.deferEvent({
1395
+ type: "expired",
1396
+ key: evicted.key,
1397
+ tool: evicted.tool,
1398
+ snippet: `flood guard evicted this watching gate to stay at ${MAX_GATES}`,
1399
+ })
1400
+ }
912
1401
  }
913
1402
  gate = {
914
1403
  key: input.key,
915
- signature: scrubSecrets(input.signature),
1404
+ signature: sanitizeForStore(input.signature),
916
1405
  tool: input.tool,
917
1406
  status: "watching",
918
1407
  count: 0,
@@ -920,11 +1409,12 @@ export class Stores {
920
1409
  projects: [],
921
1410
  firstSeen: now,
922
1411
  lastSeen: now,
923
- snippet: scrubSecrets(input.snippet),
1412
+ snippet: sanitizeForStore(input.snippet),
924
1413
  remindedCount: 0,
925
1414
  blockedCount: 0,
926
1415
  recurredAfterReminder: 0,
927
1416
  recurredAfterGate: 0,
1417
+ overrideCount: 0,
928
1418
  }
929
1419
  gates.push(gate)
930
1420
  }
@@ -939,15 +1429,27 @@ export class Stores {
939
1429
  gate.lastSeen = now
940
1430
  // Only an exact-key failure updates the evidence: a crafted near-duplicate
941
1431
  // must not overwrite a legitimate gate's snippet via fuzzy consolidation.
942
- if (!fuzzyConsolidated) gate.snippet = scrubSecrets(input.snippet)
1432
+ // Evidence monotonicity: a failure-shaped snippet is never displaced by a
1433
+ // success-shaped one (a pass summary must not push out the real error);
1434
+ // between two failure-shaped snippets the latest wins (freshness).
1435
+ if (!fuzzyConsolidated) {
1436
+ const snippet = sanitizeForStore(input.snippet)
1437
+ if (looksLikeFailure(snippet) || !looksLikeFailure(gate.snippet)) gate.snippet = snippet
1438
+ }
943
1439
  // A failure breaks any heal streak — the command is still broken.
944
1440
  gate.succeededAfterGate = 0
945
1441
 
946
- let promoted = false
947
1442
  const threshold = PROBE_TOOLS.has(input.tool) ? PROMOTE_COUNT_PROBE : PROMOTE_COUNT
1443
+ // Oscillation damping: a retired gate (healed/taught) keeps its lifetime
1444
+ // count/sessions, which already clear the promotion bar — so it would
1445
+ // re-promote on the VERY NEXT single failure (promote→heal→promote).
1446
+ // Require a full fresh bar of failures SINCE retirement instead.
1447
+ const effectiveCount = gate.retireBaseline !== undefined ? Math.max(0, gate.count - gate.retireBaseline.count) : gate.count
948
1448
  // Policy: non-diagnostic bash may hard-block; diagnostics promote to
949
- // remind-only (they never block — see canRemind). Everything else stays watching.
950
- if (gate.status === "watching" && gate.count >= threshold && gate.sessions.length >= PROMOTE_SESSIONS) {
1449
+ // remind-only (they never block — see canRemind). Everything else stays
1450
+ // watching. feedbackDemoted gates never re-promote mechanically: the
1451
+ // agent's behavior already voted against enforcement once.
1452
+ if (gate.status === "watching" && gate.feedbackDemoted !== true && effectiveCount >= threshold && gate.sessions.length >= PROMOTE_SESSIONS) {
951
1453
  if (canBlock(gate.tool, gate.signature)) {
952
1454
  gate.status = "blocking"
953
1455
  promoted = true
@@ -955,65 +1457,146 @@ export class Stores {
955
1457
  gate.status = "reminding"
956
1458
  promoted = true
957
1459
  }
1460
+ // A promoted gate always ships with SOME teaching text (mechanical
1461
+ // default, overridable) so it never sits "NOT TEACHING" awaiting a human.
1462
+ if (promoted && gate.correction === undefined) {
1463
+ gate.correction = suggestCorrection(gate.signature, gate.snippet)
1464
+ }
1465
+ // Fresh enforcement lifecycle: re-promotion (after heal/taught
1466
+ // retirement) must not inherit the previous round's counters — stale
1467
+ // cumulative zeros caused retire↔re-promote oscillation (a re-promoted
1468
+ // gate re-retired on its first reminder) and permanently locked out
1469
+ // taught retirement after any early recurrence. Session chains are
1470
+ // cleared too: a stale remindedSessions entry from the retiring round
1471
+ // would let the session skip its reminder with "one retry allowed".
1472
+ if (promoted) {
1473
+ gate.promotionCount = (gate.promotionCount ?? 0) + 1
1474
+ gate.remindedCount = 0
1475
+ gate.recurredAfterReminder = 0
1476
+ gate.recurredAfterGate = 0
1477
+ gate.overrideCount = 0
1478
+ gate.succeededAfterGate = 0
1479
+ delete gate.feedbackBaseline
1480
+ delete gate.reoffenseSessions
1481
+ delete gate.remindedSessions
1482
+ delete gate.failedSessions
1483
+ // The damping baseline is consumed by this promotion — the next
1484
+ // retirement will capture a fresh one.
1485
+ delete gate.retireBaseline
1486
+ }
958
1487
  }
959
1488
 
960
1489
  await store.save()
1490
+ return { moved: gate ?? null, ephemeral, promoted }
1491
+ })
1492
+ if (phase1.ephemeral !== null) return { gate: phase1.ephemeral, store, promoted: false, wentGlobal: false }
1493
+ if (phase1.moved === null) {
1494
+ // Unreachable: Phase 1 always yields a gate unless the ephemeral
1495
+ // early-return fired. Degrade to an ephemeral record rather than throw.
1496
+ return {
1497
+ gate: {
1498
+ key: input.key,
1499
+ signature: sanitizeForStore(input.signature),
1500
+ tool: input.tool,
1501
+ status: "watching",
1502
+ count: 1,
1503
+ sessions: [input.sessionID],
1504
+ projects: input.projectDir !== "" ? [input.projectDir] : [],
1505
+ firstSeen: now,
1506
+ lastSeen: now,
1507
+ snippet: sanitizeForStore(input.snippet),
1508
+ remindedCount: 0,
1509
+ blockedCount: 0,
1510
+ recurredAfterReminder: 0,
1511
+ recurredAfterGate: 0,
1512
+ overrideCount: 0,
1513
+ },
1514
+ store,
1515
+ promoted: false,
1516
+ wentGlobal: false,
1517
+ }
1518
+ }
1519
+ const movedGate = phase1.moved
1520
+ const promoted = phase1.promoted
961
1521
 
962
- // Cross-project evidence lives in the global index: gate.projects only
963
- // ever sees its own store's directory, so alone it can never reach two
964
- // projects. A pattern seen in enough distinct project dirs is an
965
- // agent-level habit, not a repo quirk — move it to the global store.
966
- // Keyed by the gate's OWN key (post fuzzy-consolidation), not the raw
967
- // failure key — otherwise consolidated failures index a key that has no
968
- // gate, orphaning the entry and starving the gate's escalation.
969
- // Lock order is always gates -> index and project -> global: no cycles.
970
- const moved = gate
971
- const indexProjects = await this.globalStore.runLockedIndex(async () => {
972
- const index = await this.globalStore.loadIndex(true)
973
- let entry = index.keys[moved.key]
974
- if (!entry) {
975
- entry = { projects: [], lastSeen: now }
976
- index.keys[moved.key] = entry
977
- }
978
- if (input.projectDir !== "" && !entry.projects.includes(input.projectDir)) {
979
- entry.projects.push(input.projectDir)
980
- if (entry.projects.length > MAX_PROJECTS) entry.projects = entry.projects.slice(-MAX_PROJECTS)
981
- }
982
- entry.lastSeen = now
983
- await this.globalStore.saveIndex()
984
- return entry.projects.length
985
- })
1522
+ // Phase 2 index lock (no gates lock held): cross-project evidence.
1523
+ // gate.projects only ever sees its own store's directory, so alone it can
1524
+ // never reach two projects. A pattern seen in enough distinct project dirs
1525
+ // is an agent-level habit, not a repo quirk — move it to the global store.
1526
+ // Keyed by the gate's OWN key (post fuzzy-consolidation), not the raw
1527
+ // failure key — otherwise consolidated failures index a key that has no
1528
+ // gate, orphaning the entry and starving the gate's escalation.
1529
+ let indexProjects = 0
1530
+ await this.globalStore.runLockedIndex(async () => {
1531
+ const index = await this.globalStore.loadIndex(true)
1532
+ let entry = index.keys[movedGate.key]
1533
+ // Index churn gate: the FIRST failure of a brand-new pattern (no entry
1534
+ // yet, seen once) carries no escalation value — skip the machine-wide
1535
+ // rewrite. Anything already indexed or recurring updates as before, so
1536
+ // cross-project escalation sees the same evidence minus one-off noise.
1537
+ if (entry === undefined && movedGate.count < 2) return
1538
+ if (!entry) {
1539
+ entry = { projects: [], lastSeen: now }
1540
+ index.keys[movedGate.key] = entry
1541
+ }
1542
+ if (input.projectDir !== "" && !entry.projects.includes(input.projectDir)) {
1543
+ entry.projects.push(input.projectDir)
1544
+ if (entry.projects.length > MAX_PROJECTS) entry.projects = entry.projects.slice(-MAX_PROJECTS)
1545
+ }
1546
+ entry.lastSeen = now
1547
+ await this.globalStore.saveIndex()
1548
+ // Escalation evidence counts only project dirs that still exist on
1549
+ // disk: a repo renamed/moved (common on Windows dev machines) is a
1550
+ // ghost — escalation must not rest on its strength. The index entry
1551
+ // keeps the ghost (evidence preserved; it may be a removable drive).
1552
+ indexProjects = entry.projects.filter((p) => existsSync(p)).length
1553
+ })
986
1554
 
987
- let wentGlobal = false
988
- // Repo-local verbs (npm/git/gradle/...) never escalate: their failures are
989
- // repo quirks, not agent habits escalating them would let a broken
990
- // `npm install` in one project block every other project.
991
- if (
992
- store !== this.globalStore &&
993
- this.projectStore &&
994
- indexProjects >= input.globalProjects &&
995
- !isRepoLocal(moved.signature)
996
- ) {
997
- // Global FIRST, then remove the local copy: a crash between the two
998
- // writes must leave a duplicate (healed by migrate), never a hole.
1555
+ // Phase 3 — escalation (only if warranted): three short locked phases.
1556
+ let wentGlobal = false
1557
+ // Repo-local verbs (npm/git/gradle/...) never escalate: their failures are
1558
+ // repo quirks, not agent habits escalating them would let a broken
1559
+ // `npm install` in one project block every other project.
1560
+ if (
1561
+ store !== this.globalStore &&
1562
+ this.projectStore &&
1563
+ indexProjects >= input.globalProjects &&
1564
+ !isRepoLocal(movedGate.signature)
1565
+ ) {
1566
+ // 3a project gates lock: copy the gate fresh. If it is gone, someone
1567
+ // else escalated/expired it — abort (a duplicate heals, never a hole).
1568
+ let gateCopy: Gate | null = null
1569
+ await store.runLocked(async () => {
1570
+ const fresh = (await store.load(true)).find((g) => g.key === movedGate.key)
1571
+ if (fresh !== undefined) gateCopy = JSON.parse(JSON.stringify(fresh)) as Gate
1572
+ })
1573
+ if (gateCopy !== null) {
1574
+ const copy = gateCopy
1575
+ // 3b — global gates lock FIRST: write the global copy before removing
1576
+ // the local one, so a crash between the two writes leaves a duplicate
1577
+ // (healed by migrate), never a hole.
999
1578
  await this.globalStore.runLocked(async () => {
1000
1579
  const globalGates = await this.globalStore.load(true)
1001
- const existing = globalGates.find((g) => g.key === moved.key)
1580
+ const existing = globalGates.find((g) => g.key === movedGate.key)
1002
1581
  if (existing) {
1003
- mergeGate(existing, moved)
1582
+ mergeGate(existing, copy)
1004
1583
  } else {
1005
- globalGates.push(moved)
1584
+ globalGates.push(copy)
1006
1585
  }
1007
1586
  await this.globalStore.save()
1008
1587
  })
1009
- const idx = gates.findIndex((g) => g.key === moved.key)
1010
- if (idx >= 0) gates.splice(idx, 1)
1011
- await store.save()
1588
+ // 3c project gates lock: remove the now-escalated local copy.
1589
+ await store.runLocked(async () => {
1590
+ const gates = await store.load(true)
1591
+ const idx = gates.findIndex((g) => g.key === movedGate.key)
1592
+ if (idx >= 0) gates.splice(idx, 1)
1593
+ await store.save()
1594
+ })
1012
1595
  wentGlobal = true
1013
1596
  }
1597
+ }
1014
1598
 
1015
- return { gate: moved, store, promoted, wentGlobal }
1016
- })
1599
+ return { gate: movedGate, store, promoted, wentGlobal }
1017
1600
  }
1018
1601
 
1019
1602
  /**
@@ -1022,27 +1605,68 @@ export class Stores {
1022
1605
  * watching so it stops reminding on a now-healthy command (the
1023
1606
  * `ruff check .` false-positive case). Only enforced (blocking/reminding)
1024
1607
  * gates heal; a failure resets the streak in recordFailure.
1608
+ *
1609
+ * A success ALSO clears the succeeding session from the remind→block chain.
1610
+ * Without this, a session that proved the fix (often via `dejavu:proceed`)
1611
+ * stayed permanently blocked and could only keep overriding — the override
1612
+ * count then demoted the very gate the agent had just vindicated. Success is
1613
+ * the proof; the chain for that session must reset.
1614
+ *
1615
+ * EXACT matches only: healing and chain-clearing are state mutations, and a
1616
+ * fuzzy-similar success is evidence about a DIFFERENT command — proxy
1617
+ * successes would heal a gate that still fails and unblock sessions that
1618
+ * never proved the gated call.
1025
1619
  */
1026
- async recordSuccess(input: { key: string; signature: string; tool: string }): Promise<void> {
1027
- const match = await this.findGate(input.key, input.signature)
1028
- if (match === null || match.gate.status === "watching") return
1029
- const store = match.store
1030
- const gateKey = match.gate.key
1620
+ async recordSuccess(input: { key: string; signature: string; tool: string; sessionID: string }): Promise<void> {
1621
+ // Exact matches only — healing and chain-clearing are state mutations,
1622
+ // and a fuzzy-similar success is evidence about a DIFFERENT command.
1623
+ // Exact-only also lets us skip findGate's fuzzy scan entirely: this runs
1624
+ // on EVERY successful bash call, and fuzzy matches are rejected anyway.
1625
+ let owner: GateStore | null = null
1626
+ for (const scope of this.scopes()) {
1627
+ await scope.load()
1628
+ if (scope.byKey(input.key) !== undefined) {
1629
+ owner = scope
1630
+ break
1631
+ }
1632
+ }
1633
+ if (owner === null) return
1634
+ const store = owner
1635
+ const gateKey = input.key
1636
+ let healedEvent: LogEvent | null = null
1031
1637
  await store.runLocked(async () => {
1032
1638
  const fresh = (await store.load(true)).find((g) => g.key === gateKey)
1033
1639
  if (fresh === undefined || fresh.status === "watching") return
1034
1640
  fresh.succeededAfterGate = (fresh.succeededAfterGate ?? 0) + 1
1641
+ if (fresh.remindedSessions !== undefined && fresh.remindedSessions[input.sessionID] !== undefined) {
1642
+ delete fresh.remindedSessions[input.sessionID]
1643
+ if (Object.keys(fresh.remindedSessions).length === 0) delete fresh.remindedSessions
1644
+ }
1645
+ if (fresh.failedSessions !== undefined && fresh.failedSessions[input.sessionID] !== undefined) {
1646
+ delete fresh.failedSessions[input.sessionID]
1647
+ if (Object.keys(fresh.failedSessions).length === 0) delete fresh.failedSessions
1648
+ }
1035
1649
  const healed = fresh.succeededAfterGate >= HEAL_SUCCESSES
1036
- if (healed) fresh.status = "watching"
1650
+ if (healed) {
1651
+ fresh.status = "watching"
1652
+ // Oscillation damping: capture the count at retirement so re-promotion
1653
+ // needs a full fresh bar of failures, not the very next single one.
1654
+ fresh.retireBaseline = { count: fresh.count }
1655
+ }
1037
1656
  await store.save()
1038
1657
  if (healed) {
1039
- await store.log({
1658
+ healedEvent = {
1040
1659
  type: "healed",
1041
1660
  key: fresh.key,
1042
1661
  tool: fresh.tool,
1043
1662
  snippet: `succeeded ${fresh.succeededAfterGate}x in a row after the gate — retired to watching`,
1044
- })
1663
+ }
1045
1664
  }
1046
1665
  })
1666
+ // Log OUTSIDE the gates lock (heals are rare but can land on a hot gate
1667
+ // while other windows wait — logging under the lock cascades contention).
1668
+ // Routed via logAll so the heal reaches the global log too (healed is
1669
+ // machine-memory-salient).
1670
+ if (healedEvent !== null) await this.logAll(healedEvent)
1047
1671
  }
1048
1672
  }