opencode-dejavu 2.7.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/CHANGELOG.md +361 -0
- package/README.md +25 -14
- package/command/dejavu.md +26 -0
- package/index.ts +415 -39
- package/package.json +6 -2
- package/scripts/analyze.ts +59 -0
- package/scripts/doctor.ts +505 -0
- package/scripts/githooks/commit-msg +46 -0
- package/scripts/migrate.ts +49 -0
- package/skills/dejavu/SKILL.md +48 -0
- package/src/AGENTS.md +56 -8
- package/src/patterns.ts +680 -36
- package/src/store.ts +794 -181
- package/src/validate.ts +58 -7
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, suggestCorrection } 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.
|
|
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>(
|
|
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
|
-
|
|
180
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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(
|
|
231
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
//
|
|
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(
|
|
346
|
-
this.
|
|
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
|
-
|
|
358
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
|
|
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
|
-
|
|
741
|
+
// quarantine failed — next reconcile retries; never destroy the file
|
|
489
742
|
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
|
|
496
|
-
good.push(line)
|
|
754
|
+
raw = await readFile(ntPath(this.logPath), "utf8")
|
|
497
755
|
} catch {
|
|
498
|
-
|
|
756
|
+
return // no log yet
|
|
499
757
|
}
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
|
|
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
|
|
514
|
-
|
|
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
|
-
|
|
614
|
-
|
|
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
|
-
|
|
979
|
+
store.deferEvent({ type: "retired-healed", key: gate.key, tool: gate.tool, snippet: gate.correction.slice(0, 200) })
|
|
628
980
|
} else {
|
|
629
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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 =
|
|
714
|
-
const 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,7 +1124,7 @@ export class Stores {
|
|
|
721
1124
|
changed = true
|
|
722
1125
|
}
|
|
723
1126
|
if (gate.correction !== undefined) {
|
|
724
|
-
const correction =
|
|
1127
|
+
const correction = sanitizeForStore(gate.correction)
|
|
725
1128
|
if (correction !== gate.correction) {
|
|
726
1129
|
gate.correction = correction
|
|
727
1130
|
changed = true
|
|
@@ -733,7 +1136,36 @@ export class Stores {
|
|
|
733
1136
|
gate.correction = suggestCorrection(gate.signature, gate.snippet)
|
|
734
1137
|
changed = true
|
|
735
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
|
+
}
|
|
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
|
+
}
|
|
736
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
|
|
737
1169
|
if (changed) await store.save()
|
|
738
1170
|
})
|
|
739
1171
|
}
|
|
@@ -771,7 +1203,7 @@ export class Stores {
|
|
|
771
1203
|
* Structural self-healing across both scopes plus index reconciliation.
|
|
772
1204
|
* Idempotent; runs at plugin init and via `doctor --repair`.
|
|
773
1205
|
*/
|
|
774
|
-
async reconcileAll(globalProjects =
|
|
1206
|
+
async reconcileAll(globalProjects = GLOBAL_PROJECTS): Promise<void> {
|
|
775
1207
|
for (const store of this.scopes()) {
|
|
776
1208
|
await store.reconcile()
|
|
777
1209
|
}
|
|
@@ -782,9 +1214,19 @@ export class Stores {
|
|
|
782
1214
|
const projectStore = this.projectStore
|
|
783
1215
|
if (projectStore) {
|
|
784
1216
|
const index = await this.globalStore.loadIndex()
|
|
785
|
-
|
|
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) => {
|
|
786
1222
|
const entry = index.keys[g.key]
|
|
787
|
-
|
|
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
|
+
)
|
|
788
1230
|
})
|
|
789
1231
|
if (toEscalate.length > 0) {
|
|
790
1232
|
await projectStore.runLocked(async () => {
|
|
@@ -811,42 +1253,37 @@ export class Stores {
|
|
|
811
1253
|
}
|
|
812
1254
|
}
|
|
813
1255
|
|
|
814
|
-
// The index must mirror reality: a
|
|
815
|
-
//
|
|
816
|
-
//
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
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
|
|
821
1267
|
await this.globalStore.runLockedIndex(async () => {
|
|
822
1268
|
const index = await this.globalStore.loadIndex(true)
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
// (its gates were snapshotted after this key was written) — only prune
|
|
828
|
-
// orphans that have been stale for a day.
|
|
829
|
-
if (entry && !knownKeys.has(key) && Date.now() - Date.parse(entry.lastSeen) > DAY_MS) {
|
|
830
|
-
delete index.keys[key]
|
|
831
|
-
pruned += 1
|
|
832
|
-
}
|
|
833
|
-
}
|
|
834
|
-
let rebuilt = 0
|
|
835
|
-
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()) {
|
|
836
1273
|
if (!index.keys[gate.key]) {
|
|
837
1274
|
index.keys[gate.key] = { projects: [...gate.projects], lastSeen: gate.lastSeen }
|
|
838
1275
|
rebuilt += 1
|
|
839
1276
|
}
|
|
840
1277
|
}
|
|
841
|
-
if (
|
|
842
|
-
await this.globalStore.saveIndex()
|
|
843
|
-
await this.globalStore.log({
|
|
844
|
-
type: "repaired",
|
|
845
|
-
key: "index.json",
|
|
846
|
-
snippet: `pruned ${pruned} orphan key(s), rebuilt ${rebuilt} missing entr(y/ies)`,
|
|
847
|
-
})
|
|
848
|
-
}
|
|
1278
|
+
if (rebuilt > 0) await this.globalStore.saveIndex()
|
|
849
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
|
+
}
|
|
850
1287
|
}
|
|
851
1288
|
|
|
852
1289
|
async recordFailure(input: {
|
|
@@ -861,13 +1298,32 @@ export class Stores {
|
|
|
861
1298
|
const now = new Date().toISOString()
|
|
862
1299
|
// Route to the store that already knows this key (cheap unlocked peek).
|
|
863
1300
|
let store = this.projectStore ?? this.globalStore
|
|
864
|
-
|
|
865
|
-
|
|
1301
|
+
await store.load()
|
|
1302
|
+
if (store.byKey(input.key) === undefined) {
|
|
1303
|
+
await this.globalStore.load()
|
|
1304
|
+
if (this.globalStore.byKey(input.key) !== undefined) {
|
|
866
1305
|
store = this.globalStore
|
|
867
1306
|
}
|
|
868
1307
|
}
|
|
869
1308
|
|
|
870
|
-
|
|
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
|
|
871
1327
|
const gates = await store.load(true)
|
|
872
1328
|
let gate = gates.find((g) => g.key === input.key)
|
|
873
1329
|
let fuzzyConsolidated = false
|
|
@@ -877,7 +1333,10 @@ export class Stores {
|
|
|
877
1333
|
// Prefer the gate this session was already reminded about: the
|
|
878
1334
|
// before-hook enforced from it, so the failure must land there too —
|
|
879
1335
|
// otherwise the remind→block chain desyncs between the hooks.
|
|
880
|
-
|
|
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)) : []
|
|
881
1340
|
gate = fuzzyMatches.find((g) => g.remindedSessions?.[input.sessionID] !== undefined) ?? fuzzyMatches[0]
|
|
882
1341
|
if (gate !== undefined) fuzzyConsolidated = true
|
|
883
1342
|
}
|
|
@@ -889,16 +1348,30 @@ export class Stores {
|
|
|
889
1348
|
const candidate = gates[i]
|
|
890
1349
|
if (candidate === undefined || candidate.status !== "watching") continue
|
|
891
1350
|
const victim = victimIdx >= 0 ? gates[victimIdx] : undefined
|
|
892
|
-
if (victim === undefined
|
|
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)) {
|
|
893
1366
|
victimIdx = i
|
|
894
1367
|
}
|
|
895
1368
|
}
|
|
896
1369
|
if (victimIdx < 0) {
|
|
897
1370
|
// Every gate is enforced — do not create; degrade gracefully with
|
|
898
1371
|
// an ephemeral gate that is never persisted.
|
|
899
|
-
|
|
1372
|
+
ephemeral = {
|
|
900
1373
|
key: input.key,
|
|
901
|
-
signature:
|
|
1374
|
+
signature: sanitizeForStore(input.signature),
|
|
902
1375
|
tool: input.tool,
|
|
903
1376
|
status: "watching",
|
|
904
1377
|
count: 1,
|
|
@@ -906,19 +1379,29 @@ export class Stores {
|
|
|
906
1379
|
projects: input.projectDir !== "" ? [input.projectDir] : [],
|
|
907
1380
|
firstSeen: now,
|
|
908
1381
|
lastSeen: now,
|
|
909
|
-
snippet:
|
|
1382
|
+
snippet: sanitizeForStore(input.snippet),
|
|
910
1383
|
remindedCount: 0,
|
|
911
1384
|
blockedCount: 0,
|
|
912
1385
|
recurredAfterReminder: 0,
|
|
913
1386
|
recurredAfterGate: 0,
|
|
1387
|
+
overrideCount: 0,
|
|
914
1388
|
}
|
|
915
|
-
return {
|
|
1389
|
+
return { moved: null, ephemeral, promoted: false }
|
|
916
1390
|
}
|
|
1391
|
+
const evicted = gates[victimIdx]
|
|
917
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
|
+
}
|
|
918
1401
|
}
|
|
919
1402
|
gate = {
|
|
920
1403
|
key: input.key,
|
|
921
|
-
signature:
|
|
1404
|
+
signature: sanitizeForStore(input.signature),
|
|
922
1405
|
tool: input.tool,
|
|
923
1406
|
status: "watching",
|
|
924
1407
|
count: 0,
|
|
@@ -926,11 +1409,12 @@ export class Stores {
|
|
|
926
1409
|
projects: [],
|
|
927
1410
|
firstSeen: now,
|
|
928
1411
|
lastSeen: now,
|
|
929
|
-
snippet:
|
|
1412
|
+
snippet: sanitizeForStore(input.snippet),
|
|
930
1413
|
remindedCount: 0,
|
|
931
1414
|
blockedCount: 0,
|
|
932
1415
|
recurredAfterReminder: 0,
|
|
933
1416
|
recurredAfterGate: 0,
|
|
1417
|
+
overrideCount: 0,
|
|
934
1418
|
}
|
|
935
1419
|
gates.push(gate)
|
|
936
1420
|
}
|
|
@@ -945,15 +1429,27 @@ export class Stores {
|
|
|
945
1429
|
gate.lastSeen = now
|
|
946
1430
|
// Only an exact-key failure updates the evidence: a crafted near-duplicate
|
|
947
1431
|
// must not overwrite a legitimate gate's snippet via fuzzy consolidation.
|
|
948
|
-
|
|
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
|
+
}
|
|
949
1439
|
// A failure breaks any heal streak — the command is still broken.
|
|
950
1440
|
gate.succeededAfterGate = 0
|
|
951
1441
|
|
|
952
|
-
let promoted = false
|
|
953
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
|
|
954
1448
|
// Policy: non-diagnostic bash may hard-block; diagnostics promote to
|
|
955
|
-
// remind-only (they never block — see canRemind). Everything else stays
|
|
956
|
-
|
|
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) {
|
|
957
1453
|
if (canBlock(gate.tool, gate.signature)) {
|
|
958
1454
|
gate.status = "blocking"
|
|
959
1455
|
promoted = true
|
|
@@ -966,65 +1462,141 @@ export class Stores {
|
|
|
966
1462
|
if (promoted && gate.correction === undefined) {
|
|
967
1463
|
gate.correction = suggestCorrection(gate.signature, gate.snippet)
|
|
968
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
|
+
}
|
|
969
1487
|
}
|
|
970
1488
|
|
|
971
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
|
|
972
1521
|
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
const
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
}
|
|
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
|
+
})
|
|
997
1554
|
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
)
|
|
1008
|
-
|
|
1009
|
-
|
|
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.
|
|
1010
1578
|
await this.globalStore.runLocked(async () => {
|
|
1011
1579
|
const globalGates = await this.globalStore.load(true)
|
|
1012
|
-
const existing = globalGates.find((g) => g.key ===
|
|
1580
|
+
const existing = globalGates.find((g) => g.key === movedGate.key)
|
|
1013
1581
|
if (existing) {
|
|
1014
|
-
mergeGate(existing,
|
|
1582
|
+
mergeGate(existing, copy)
|
|
1015
1583
|
} else {
|
|
1016
|
-
globalGates.push(
|
|
1584
|
+
globalGates.push(copy)
|
|
1017
1585
|
}
|
|
1018
1586
|
await this.globalStore.save()
|
|
1019
1587
|
})
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
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
|
+
})
|
|
1023
1595
|
wentGlobal = true
|
|
1024
1596
|
}
|
|
1597
|
+
}
|
|
1025
1598
|
|
|
1026
|
-
|
|
1027
|
-
})
|
|
1599
|
+
return { gate: movedGate, store, promoted, wentGlobal }
|
|
1028
1600
|
}
|
|
1029
1601
|
|
|
1030
1602
|
/**
|
|
@@ -1033,27 +1605,68 @@ export class Stores {
|
|
|
1033
1605
|
* watching so it stops reminding on a now-healthy command (the
|
|
1034
1606
|
* `ruff check .` false-positive case). Only enforced (blocking/reminding)
|
|
1035
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.
|
|
1036
1619
|
*/
|
|
1037
|
-
async recordSuccess(input: { key: string; signature: string; tool: string }): Promise<void> {
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
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
|
|
1042
1637
|
await store.runLocked(async () => {
|
|
1043
1638
|
const fresh = (await store.load(true)).find((g) => g.key === gateKey)
|
|
1044
1639
|
if (fresh === undefined || fresh.status === "watching") return
|
|
1045
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
|
+
}
|
|
1046
1649
|
const healed = fresh.succeededAfterGate >= HEAL_SUCCESSES
|
|
1047
|
-
if (healed)
|
|
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
|
+
}
|
|
1048
1656
|
await store.save()
|
|
1049
1657
|
if (healed) {
|
|
1050
|
-
|
|
1658
|
+
healedEvent = {
|
|
1051
1659
|
type: "healed",
|
|
1052
1660
|
key: fresh.key,
|
|
1053
1661
|
tool: fresh.tool,
|
|
1054
1662
|
snippet: `succeeded ${fresh.succeededAfterGate}x in a row after the gate — retired to watching`,
|
|
1055
|
-
}
|
|
1663
|
+
}
|
|
1056
1664
|
}
|
|
1057
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)
|
|
1058
1671
|
}
|
|
1059
1672
|
}
|