opencode-dejavu 2.3.1 → 2.5.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 CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.5.0 — 2026-08-24
4
+
5
+ ### Changed (the three known gaps, closed)
6
+ - **Diagnostics now signal.** Recurring test/lint/build-check failures (`tsc`, `pytest`, `curl`, ...) previously got zero enforcement. New gate tier `reminding`: they promote and REMIND like any gate, but NEVER block — a new status alongside `watching`/`blocking`, enforced everywhere (findGate, compaction, migrate/repair, doctor, TTL).
7
+ - **Repo-local verbs never escalate.** npm/yarn/pnpm/bun/npx, git, gradle/maven, cargo/go/pip/poetry/uv, docker, make/cmake/bazel failures are repo quirks, not agent habits — they stay project-scoped forever (`isRepoLocal`), so a broken `npm install` in one project can no longer block another. Doctor's MISSED-ESCALATION skips them.
8
+ - **Flag-aware fuzzy matching.** Commands with disjoint flag sets never merge (`train --lr` vs `train --epochs`); subset additions still do (`train` vs `train -v`), so enforcement doesn't fragment across harmless variants while different operations stay separate.
9
+
10
+ ### Migration
11
+ - `migrate()` re-tiers legacy gates: over-blocking diagnostics → `reminding` (signal kept), and already-proven recurring diagnostics → `reminding` immediately (no waiting for the next failure).
12
+
13
+ ## 2.4.0 — 2026-08-24
14
+
15
+ ### Added
16
+ - Noise TTL: weak one-off patterns (below the promotion threshold, never enforced) expire after 7 days instead of 60 — memory is for recurring mistakes, not one-shot noise.
17
+ - Correction lifecycle signal: an expired gate that had a correction and zero recurrences after promotion logs `retired-healed` — the mechanical "the teaching worked"; doctor reports such gates as TEACHING.
18
+
19
+ ### Notes
20
+ - V2 plugin API migration awaits upstream: `tool.execute.error` (opencode issue #27900) is drafted but unmerged — the event-stream scan remains the file-tool failure channel until then.
21
+
3
22
  ## 2.3.1 — 2026-08-24
4
23
 
5
24
  ### Fixed (adversarial + security review round)
package/README.md CHANGED
@@ -112,11 +112,11 @@ bun run typecheck # tsc --noEmit (index.ts + src/**)
112
112
  bun test/smoke.ts # behavioral smoke test, no framework needed
113
113
  ```
114
114
 
115
- Tunables are named constants at the top of `index.ts` and `src/store.ts`: `PROMOTE_COUNT` (3), `PROMOTE_COUNT_PROBE` (5), `PROMOTE_SESSIONS` (2), `GLOBAL_PROJECTS` (2), `TTL_DAYS` (60), `REVIEW_FIRES` (10).
115
+ Tunables are named constants at the top of `index.ts` and `src/store.ts`: `PROMOTE_COUNT` (3), `PROMOTE_COUNT_PROBE` (5), `PROMOTE_SESSIONS` (2), `GLOBAL_PROJECTS` (2), `TTL_DAYS` (60), `NOISE_TTL_DAYS` (7), `REVIEW_FIRES` (10), `MAX_GATES` (2000).
116
116
 
117
117
  ## Roadmap
118
118
 
119
- - v2: recurrence-after-gate reporting command; V2 plugin API error hooks when stable
119
+ - v2: recurrence-after-gate reporting command; V2 plugin API error hooks `tool.execute.error` is drafted upstream (opencode issue #27900) but unmerged; the event-stream scan remains the file-tool failure channel until it lands
120
120
  - v3: auto-proposal of ast-grep rules for statically detectable patterns (repo-level CI gates)
121
121
 
122
122
  ## License
package/index.ts CHANGED
@@ -19,6 +19,8 @@ import { GateStore, Stores, type Gate, PLUGIN_VERSION } from "./src/store"
19
19
  const GLOBAL_PROJECTS = 2
20
20
  /** gates expire when the pattern has not recurred for this many days */
21
21
  const TTL_DAYS = 60
22
+ /** weak one-off patterns (below promotion threshold, never enforced) rot this fast */
23
+ const NOISE_TTL_DAYS = 7
22
24
  /** how often a long-lived process re-runs expiry */
23
25
  const TTL_INTERVAL_MS = 6 * 60 * 60 * 1000
24
26
  /** a gate firing this often without killing the error gets flagged for review */
@@ -91,7 +93,7 @@ export const Dejavu: Plugin = async ({ directory, client }) => {
91
93
  try {
92
94
  await stores.reconcileAll(GLOBAL_PROJECTS)
93
95
  await stores.migrate()
94
- await stores.expireAll(TTL_DAYS)
96
+ await stores.expireAll(TTL_DAYS, NOISE_TTL_DAYS)
95
97
  await stores.rotateLogs()
96
98
  await stores.logAll({ type: "init", key: "dejavu", version: PLUGIN_VERSION })
97
99
  await logClient("info", `dejavu initialized v${PLUGIN_VERSION}`)
@@ -104,7 +106,7 @@ export const Dejavu: Plugin = async ({ directory, client }) => {
104
106
  // Long-lived processes re-run expiry periodically.
105
107
  const ttlTimer = setInterval(() => {
106
108
  // expiry is best-effort; the timer keeps running regardless
107
- stores.expireAll(TTL_DAYS).catch(() => {})
109
+ stores.expireAll(TTL_DAYS, NOISE_TTL_DAYS).catch(() => {})
108
110
  }, TTL_INTERVAL_MS)
109
111
  ;(ttlTimer as { unref?: () => void }).unref?.()
110
112
 
@@ -127,7 +129,7 @@ export const Dejavu: Plugin = async ({ directory, client }) => {
127
129
  for (let i = 0; i < candidates.length; i++) {
128
130
  const sig = candidates[i] ?? ""
129
131
  const match = await stores.findGate(patternKey(sig), sig)
130
- if (match && match.gate.status === "blocking") {
132
+ if (match && match.gate.status !== "watching") {
131
133
  found = { gate: match.gate, store: match.store, via: i > 0 && match.via === "exact" ? "segment" : match.via }
132
134
  break
133
135
  }
@@ -180,7 +182,9 @@ export const Dejavu: Plugin = async ({ directory, client }) => {
180
182
  if (fresh === undefined) return // gate deleted between find and lock
181
183
 
182
184
  // Repeat offense: reminded, retried, failed again -> hard block.
183
- if (fresh.failedSessions !== undefined && fresh.failedSessions[session] !== undefined) {
185
+ // Remind-only gates (diagnostics) never reach this branch they
186
+ // never collect failedSessions (see the after-hook).
187
+ if (fresh.status === "blocking" && fresh.failedSessions !== undefined && fresh.failedSessions[session] !== undefined) {
184
188
  fresh.blockedCount += 1
185
189
  if (fresh.blockedCount >= REVIEW_FIRES) fresh.review = true
186
190
  await target.store.save()
@@ -300,13 +304,15 @@ export const Dejavu: Plugin = async ({ directory, client }) => {
300
304
  let changed = false
301
305
  // Metric: failure of an already-enforced pattern (the event that
302
306
  // promoted the gate does not count — the gate did not exist yet).
303
- if (fresh.status === "blocking" && !result.promoted) {
307
+ if (fresh.status !== "watching" && !result.promoted) {
304
308
  fresh.recurredAfterGate += 1
305
309
  changed = true
306
310
  await stores.logAll({ type: "recurred-after-gate", key: fresh.key, tool: input.tool, session, project: directory })
307
311
  }
308
312
  // Same-session repeat after a reminder -> escalate to hard block.
309
- if (fresh.remindedSessions?.[session] !== undefined) {
313
+ // Remind-only gates (diagnostics) never collect failedSessions:
314
+ // they signal but must not punish iterating on tests/linters.
315
+ if (fresh.status === "blocking" && fresh.remindedSessions?.[session] !== undefined) {
310
316
  if (fresh.failedSessions === undefined) fresh.failedSessions = {}
311
317
  fresh.failedSessions[session] = Date.now()
312
318
  fresh.recurredAfterReminder += 1
@@ -408,7 +414,7 @@ export const Dejavu: Plugin = async ({ directory, client }) => {
408
414
 
409
415
  "experimental.session.compacting": async (_input, output) => {
410
416
  try {
411
- const gates = await stores.blockingGates()
417
+ const gates = await stores.enforcedGates()
412
418
  if (gates.length === 0) return
413
419
  const lines = gates
414
420
  .slice(0, 20)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-dejavu",
3
- "version": "2.3.1",
3
+ "version": "2.5.0",
4
4
  "description": "Cross-session memory prosthesis for OpenCode: detects recurring tool-call failures and promotes them into enforced gates. Remind first, block on same-session repeat offense.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
package/src/AGENTS.md CHANGED
@@ -12,12 +12,13 @@ Two dependency-free modules: `patterns.ts` (pure functions — call identity, no
12
12
  | Interpreter one-liner identity | patterns.ts | `hashInterpreterPayload` — `-c`/`-e` code payload → `<code:hash>` |
13
13
  | Chain-bypass protection | patterns.ts | `splitChain` (quote/paren-aware) → `bashSegmentSignatures` |
14
14
  | Free-form error collapsing | patterns.ts | `parameterizeError` (event channel) vs `normalizeCommand` (bash) |
15
- | Near-duplicate merge | patterns.ts | `fuzzySimilar` = normalized `levenshtein` ≤ 0.3 |
15
+ | Near-duplicate merge | patterns.ts | `fuzzySimilar` = normalized `levenshtein` ≤ 0.3 + flag-subset rule |
16
16
  | Failure text scan | patterns.ts | `detectFailure` + `FAILURE_SIGNATURES` |
17
17
  | Noise filtering | patterns.ts | `isNoiseError` + `NOISE_ERRORS` (aborted/cancelled ≠ failed) |
18
- | Diagnostic/intended-exit logic | patterns.ts | `DIAGNOSTIC_VERBS`, `isIntendedNonzero`, `canBlock` |
18
+ | Diagnostic/intended-exit logic | patterns.ts | `DIAGNOSTIC_VERBS`, `isIntendedNonzero`, `canBlock`, `canRemind` |
19
+ | Escalation scope policy | patterns.ts | `isRepoLocal` + `REPO_LOCAL_VERBS` — repo-local verbs never escalate globally |
19
20
  | One scope (gates.json + index.json + log.jsonl) | store.ts | `GateStore` — `load`/`save`/`loadIndex`/`saveIndex`/`log`/`expire`/`extract`/`rotateLog`/`reconcile` |
20
- | Two-scope logic + promotion | store.ts | `Stores` — `findGate`/`recordFailure`/`migrate`/`blockingGates`/`reconcileAll`; `mergeGate` merges duplicate keys |
21
+ | Two-scope logic + promotion | store.ts | `Stores` — `findGate`/`recordFailure`/`migrate`/`enforcedGates`/`reconcileAll`; `mergeGate` merges duplicate keys |
21
22
  | Gate parse/repair boundary | validate.ts | `coerceGateShape` (strict parse), `repairGate` (mechanical coercion), `hasNestedTokens` (corruption fingerprint) |
22
23
  | fs safety | store.ts | `ntPath`, `atomicWrite`, `withLock` |
23
24
 
@@ -26,7 +27,9 @@ Two dependency-free modules: `patterns.ts` (pure functions — call identity, no
26
27
  - Rule order in `PARAM_RULES` matters: quoted strings first, specific tokens (uuid/sha/ip/url/date), generic numbers last — reordering fragments signatures
27
28
  - Rule order in `normalizeCommand` matters too: quoted strings are parameterized BEFORE path rules — a `<str>` substitution inserts spaces that would expose an adjacent `/` to the path rule on a second pass (idempotency); interpreter payload hashing runs while the payload is still raw
28
29
  - `scrubSecrets()` runs on every string before it touches disk; `recordFailure` re-scrubs defensively
29
- - `canBlock(tool, sig)` = bash && non-diagnostic && not a bare one-liner shape the ONLY path to `blocking`; probe tools use `PROMOTE_COUNT_PROBE` and never block
30
+ - Three enforcement tiers: `canBlock` (bash && non-diagnostic && not a bare one-liner shape) is the ONLY path to `blocking`; `canRemind` (diagnostic bash) is the only path to `reminding`; probe tools use `PROMOTE_COUNT_PROBE` and never leave `watching`
31
+ - Repo-local verbs (`isRepoLocal`) never escalate to the global store — their failures are repo quirks; both escalation paths (`recordFailure` + `reconcileAll`) and doctor's MISSED-ESCALATION honor this
32
+ - Fuzzy merging requires comparable flag sets (one a subset of the other) — disjoint switches are different operations and must never merge; subset additions still consolidate
30
33
  - `DIAGNOSTIC_VERBS` serves two callers (exit-1 allowlist + blocking policy) — one list, two uses; edit knowing both move
31
34
  - Lock order is always project → global, gates → index (see `recordFailure` escalation) — reversing deadlocks; the log lock is separate and leaf-level
32
35
  - Cross-project evidence lives ONLY in the global `index.json` — a gate's own `projects` array sees one store and never drives escalation alone
@@ -35,7 +38,7 @@ Two dependency-free modules: `patterns.ts` (pure functions — call identity, no
35
38
  - Fuzzy consolidation in `recordFailure` prefers the gate holding the session's reminded state (before/after hooks must stay in sync) and never overwrites the evidence snippet
36
39
  - Snippets and corrections are UNTRUSTED text re-injected into agent context — keep the data-label framing in messages, the 200-char correction bound, and scrub quarantine bytes
37
40
  - Inside `runLocked` always `load(true)`; unlocked `load()` peeks are routing hints only, never a basis for mutation
38
- - Hot-path reads use the 1s TTL cache + key index (`byKey`/`blockingOnly`); mutations inside locks use `load(true)`; `save()` refreshes the cache directly
41
+ - Hot-path reads use the 1s TTL cache + key index (`byKey`/`enforcedOnly`); mutations inside locks use `load(true)`; `save()` refreshes the cache directly
39
42
  - The remind→block chain is persisted ON THE GATE (`remindedSessions`/`failedSessions`) and enforced under the store lock — process memory holds nothing authoritative, so several windows and restarts share one escalation
40
43
  - Log appends and rotation take the log lock — every OpenCode window shares the global log; unlocked appends interleave into broken JSON
41
44
  - Every gate read from disk crosses `coerceGateShape` + `repairGate` in `load()` — enforcement never sees raw state; hopeless records are dropped, repairable ones coerced
package/src/patterns.ts CHANGED
@@ -187,6 +187,37 @@ export function canBlock(tool: string, signature: string): boolean {
187
187
  return !GENERIC_ONELINER_SHAPE.test(signature)
188
188
  }
189
189
 
190
+ /**
191
+ * Remind-only policy: diagnostic bash commands still surface a REMINDER when
192
+ * they recur (the old behavior gave them zero signal), but they NEVER block —
193
+ * blocking a test/lint the agent is iterating on punishes normal work.
194
+ * Generic one-liner shapes stay unenforced: they are too broad to remind on.
195
+ */
196
+ export function canRemind(tool: string, signature: string): boolean {
197
+ if (tool !== "bash") return false
198
+ if (GENERIC_ONELINER_SHAPE.test(signature)) return false
199
+ return isDiagnosticSignature(signature)
200
+ }
201
+
202
+ /**
203
+ * Repo-local verbs: their success depends on THIS repo's state (deps, lockfile,
204
+ * remote, build cache), not on agent behavior — so a failure is a repo quirk,
205
+ * not an agent habit, and must never escalate to the global store (an
206
+ * `npm install` that broke in project A would otherwise block project B).
207
+ */
208
+ const REPO_LOCAL_VERBS: RegExp[] = [
209
+ /\b(npm|yarn|pnpm|bun|npx)\b/i,
210
+ /\bgit\b/i,
211
+ /\b(gradlew|gradle|mvn|maven)\b/i,
212
+ /\b(cargo|go|pip3?|poetry|uv)\b/i,
213
+ /\bdocker(-compose)?\b/i,
214
+ /\b(make|cmake|bazel)\b/i,
215
+ ]
216
+
217
+ export function isRepoLocal(signature: string): boolean {
218
+ return REPO_LOCAL_VERBS.some((rule) => rule.test(signature))
219
+ }
220
+
190
221
  // --- Chain splitting ---------------------------------------------------------
191
222
 
192
223
  /**
@@ -338,6 +369,23 @@ export function levenshtein(a: string, b: string): number {
338
369
  /** Code fingerprints are IDENTITY, not data — they must match exactly. */
339
370
  const CODE_FINGERPRINTS = /<code:[0-9a-f]+>/g
340
371
 
372
+ /** Flag tokens ("-x", "--foo") are the operation's switches. Two commands with
373
+ * DISJOINT flag sets are different operations and must never fuzzy-merge
374
+ * ("train --lr <n>" vs "train --epochs <n>"). A subset IS allowed — extra
375
+ * switches on the same operation ("gradlew test --no-daemon") still belong to
376
+ * the same gate, otherwise enforcement fragments across harmless variants. */
377
+ function flagTokens(signature: string): string[] {
378
+ return signature
379
+ .split(/\s+/)
380
+ .filter((token) => token.startsWith("-"))
381
+ .sort()
382
+ }
383
+
384
+ function flagSubset(a: string[], b: string[]): boolean {
385
+ const set = new Set(b)
386
+ return a.every((token) => set.has(token))
387
+ }
388
+
341
389
  /** Signatures longer than this match exactly only: a 300-char normalized
342
390
  * command is already specific enough that "30% near" is meaningless, and
343
391
  * Levenshtein on long signatures is the hot-path cost cliff. */
@@ -350,7 +398,9 @@ export const FUZZY_MAX_LEN = 300
350
398
  * commands ("git push <str>" vs "git pull <str>" = distance 2) from merging.
351
399
  * Signatures carrying <code:...> fingerprints only match if the fingerprints
352
400
  * are identical — random hashes differing in 3 chars would otherwise pass the
353
- * distance rule and merge unrelated one-liners into one gate.
401
+ * distance rule and merge unrelated one-liners into one gate. Flag sets must
402
+ * also be comparable (one a subset of the other) — disjoint switches mean
403
+ * different operations.
354
404
  */
355
405
  export function fuzzySimilar(a: string, b: string): boolean {
356
406
  if (a === b) return true
@@ -359,6 +409,9 @@ export function fuzzySimilar(a: string, b: string): boolean {
359
409
  if (codesA !== null || codesB !== null) {
360
410
  if (codesA === null || codesB === null || codesA.join("\u0000") !== codesB.join("\u0000")) return false
361
411
  }
412
+ const flagsA = flagTokens(a)
413
+ const flagsB = flagTokens(b)
414
+ if (!flagSubset(flagsA, flagsB) && !flagSubset(flagsB, flagsA)) return false
362
415
  const maxLen = Math.max(a.length, b.length)
363
416
  if (maxLen === 0) return true
364
417
  if (maxLen > FUZZY_MAX_LEN) return false
package/src/store.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import { appendFile, mkdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises"
2
2
  import { dirname, join } from "node:path"
3
- import { canBlock, fuzzySimilar, FUZZY_MAX_LEN, scrubSecrets } from "./patterns"
3
+ import { canBlock, canRemind, fuzzySimilar, FUZZY_MAX_LEN, isRepoLocal, scrubSecrets } from "./patterns"
4
4
  import { coerceGateShape, repairGate } from "./validate"
5
5
 
6
6
  /** Bumped on behavior changes; stamped into init log events so stale sessions are visible. */
7
- export const PLUGIN_VERSION = "2.3.1"
7
+ export const PLUGIN_VERSION = "2.5.0"
8
8
 
9
9
  export interface Gate {
10
10
  /** sha1 signature prefix — the pattern identity */
@@ -12,8 +12,9 @@ export interface Gate {
12
12
  /** normalized call signature, e.g. "bash:npm install --legacy-peer-deps" */
13
13
  signature: string
14
14
  tool: string
15
- /** watching = collecting evidence; blocking = gate is enforced */
16
- status: "watching" | "blocking"
15
+ /** watching = collecting evidence; reminding = enforced as reminder only
16
+ * (diagnostics never block); blocking = reminder + hard block on repeat */
17
+ status: "watching" | "reminding" | "blocking"
17
18
  count: number
18
19
  /** distinct session IDs where the failure was seen */
19
20
  sessions: string[]
@@ -71,6 +72,7 @@ export type LogEventType =
71
72
  | "repaired"
72
73
  | "quarantined"
73
74
  | "degraded"
75
+ | "retired-healed"
74
76
 
75
77
  export interface LogEvent {
76
78
  type: LogEventType
@@ -198,7 +200,7 @@ export class GateStore {
198
200
  /** hot-path caches: valid until LOAD_CACHE_TTL_MS / invalidated on mutation */
199
201
  private cacheUntilMs = 0
200
202
  private keyIndex: Map<string, Gate> | null = null
201
- private blockingCache: Gate[] | null = null
203
+ private enforcedCache: Gate[] | null = null
202
204
  private index: IndexFile | null = null
203
205
  private indexMtimeMs = 0
204
206
 
@@ -253,7 +255,7 @@ export class GateStore {
253
255
  }
254
256
  this.gates = gates
255
257
  this.keyIndex = new Map(gates.map((g) => [g.key, g]))
256
- this.blockingCache = gates.filter((g) => g.status === "blocking")
258
+ this.enforcedCache = gates.filter((g) => g.status !== "watching")
257
259
  this.mtimeMs = info.mtimeMs
258
260
  this.cacheUntilMs = Date.now() + LOAD_CACHE_TTL_MS
259
261
  return this.gates
@@ -262,7 +264,7 @@ export class GateStore {
262
264
  if (this.gates === null) {
263
265
  this.gates = []
264
266
  this.keyIndex = new Map()
265
- this.blockingCache = []
267
+ this.enforcedCache = []
266
268
  }
267
269
  this.cacheUntilMs = Date.now() + LOAD_CACHE_TTL_MS
268
270
  return this.gates
@@ -277,12 +279,12 @@ export class GateStore {
277
279
  return this.keyIndex.get(key)
278
280
  }
279
281
 
280
- /** Cached blocking subset — the fuzzy scan iterates this, not all gates. */
281
- blockingOnly(): Gate[] {
282
- if (this.blockingCache === null) {
283
- this.blockingCache = (this.gates ?? []).filter((g) => g.status === "blocking")
282
+ /** Cached enforced subset (blocking + reminding) — the fuzzy scan iterates this, not all gates. */
283
+ enforcedOnly(): Gate[] {
284
+ if (this.enforcedCache === null) {
285
+ this.enforcedCache = (this.gates ?? []).filter((g) => g.status !== "watching")
284
286
  }
285
- return this.blockingCache
287
+ return this.enforcedCache
286
288
  }
287
289
 
288
290
  async save(): Promise<void> {
@@ -296,7 +298,7 @@ export class GateStore {
296
298
  // mtime refresh is best-effort
297
299
  }
298
300
  // We know the content we just wrote — refresh the TTL cache directly.
299
- // (keyIndex/blockingCache hold references into this.gates, still valid.)
301
+ // (keyIndex/enforcedCache hold references into this.gates, still valid.)
300
302
  this.cacheUntilMs = Date.now() + LOAD_CACHE_TTL_MS
301
303
  }
302
304
 
@@ -350,13 +352,23 @@ export class GateStore {
350
352
  })
351
353
  }
352
354
 
353
- /** Caller must hold the lock. */
354
- async expire(ttlDays: number): Promise<Gate[]> {
355
+ /**
356
+ * Caller must hold the lock. Weak one-off patterns (below the promotion
357
+ * threshold, never enforced) rot faster than proven ones — a pattern that
358
+ * never recurred enough to matter is noise, not memory.
359
+ */
360
+ async expire(ttlDays: number, noiseTtlDays: number): Promise<Gate[]> {
355
361
  const gates = await this.load(true)
356
- const cutoff = Date.now() - ttlDays * DAY_MS
357
- const expired = gates.filter((g) => Date.parse(g.lastSeen) < cutoff)
362
+ const now = Date.now()
363
+ const expired = gates.filter((g) => {
364
+ const ttl = g.status !== "watching" || g.count >= PROMOTE_COUNT ? ttlDays : noiseTtlDays
365
+ return Date.parse(g.lastSeen) < now - ttl * DAY_MS
366
+ })
358
367
  if (expired.length === 0) return []
359
- this.gates = gates.filter((g) => Date.parse(g.lastSeen) >= cutoff)
368
+ const expiredKeys = new Set(expired.map((g) => g.key))
369
+ this.gates = gates.filter((g) => !expiredKeys.has(g.key))
370
+ this.keyIndex = null
371
+ this.enforcedCache = null
360
372
  await this.save()
361
373
  return expired
362
374
  }
@@ -368,7 +380,7 @@ export class GateStore {
368
380
  if (removed.length > 0) {
369
381
  this.gates = this.gates.filter((g) => !keys.has(g.key))
370
382
  this.keyIndex = null
371
- this.blockingCache = null
383
+ this.enforcedCache = null
372
384
  }
373
385
  return removed
374
386
  }
@@ -418,7 +430,7 @@ export class GateStore {
418
430
  await unlink(ntPath(this.gatesPath))
419
431
  this.gates = []
420
432
  this.keyIndex = null
421
- this.blockingCache = null
433
+ this.enforcedCache = null
422
434
  this.mtimeMs = 0
423
435
  await this.save()
424
436
  await this.log({ type: "quarantined", key: "gates.json", snippet: `unparseable gates file quarantined (scrubbed) to ${quarantine}` })
@@ -571,7 +583,7 @@ export class Stores {
571
583
  if (signature.length > FUZZY_MAX_LEN) return null
572
584
  let best: { gate: Gate; store: GateStore; score: number } | null = null
573
585
  for (const store of this.scopes()) {
574
- for (const gate of store.blockingOnly()) {
586
+ for (const gate of store.enforcedOnly()) {
575
587
  if (!fuzzySimilar(signature, gate.signature)) continue
576
588
  const score = Math.abs(signature.length - gate.signature.length)
577
589
  if (best === null || score < best.score) best = { gate, store, score }
@@ -580,12 +592,12 @@ export class Stores {
580
592
  return best === null ? null : { gate: best.gate, store: best.store, via: "fuzzy" }
581
593
  }
582
594
 
583
- /** All currently enforced gates, project scope first, highest-count first. */
584
- async blockingGates(): Promise<Gate[]> {
595
+ /** All currently enforced gates (blocking + reminding), project scope first, highest-count first. */
596
+ async enforcedGates(): Promise<Gate[]> {
585
597
  const result: Gate[] = []
586
598
  for (const store of this.scopes()) {
587
599
  await store.load()
588
- for (const gate of store.blockingOnly()) result.push(gate)
600
+ for (const gate of store.enforcedOnly()) result.push(gate)
589
601
  }
590
602
  return result.sort((a, b) => b.count - a.count)
591
603
  }
@@ -596,12 +608,19 @@ export class Stores {
596
608
  }
597
609
  }
598
610
 
599
- async expireAll(ttlDays: number): Promise<void> {
611
+ async expireAll(ttlDays: number, noiseTtlDays: number): Promise<void> {
600
612
  for (const store of this.scopes()) {
601
613
  await store.runLocked(async () => {
602
- const expired = await store.expire(ttlDays)
614
+ const expired = await store.expire(ttlDays, noiseTtlDays)
603
615
  for (const gate of expired) {
604
- await store.log({ type: "expired", key: gate.key, tool: gate.tool })
616
+ // Correction lifecycle: a corrected gate that never recurred after
617
+ // promotion means the pattern died out — the mechanical signal that
618
+ // the teaching worked.
619
+ if (gate.correction !== undefined && gate.recurredAfterGate === 0) {
620
+ await store.log({ type: "retired-healed", key: gate.key, tool: gate.tool, snippet: gate.correction.slice(0, 200) })
621
+ } else {
622
+ await store.log({ type: "expired", key: gate.key, tool: gate.tool })
623
+ }
605
624
  }
606
625
  })
607
626
  }
@@ -663,10 +682,27 @@ export class Stores {
663
682
  const gates = await store.load(true)
664
683
  let changed = false
665
684
  for (const gate of gates) {
666
- if (!canBlock(gate.tool, gate.signature) && gate.status === "blocking") {
685
+ if (gate.status === "blocking" && !canBlock(gate.tool, gate.signature)) {
686
+ // Over-blocking learned under an older policy: keep the signal if
687
+ // the shape can at least remind (diagnostics), else drop to watching.
688
+ gate.status = canRemind(gate.tool, gate.signature) ? "reminding" : "watching"
689
+ changed = true
690
+ }
691
+ if (gate.status === "reminding" && !canRemind(gate.tool, gate.signature)) {
667
692
  gate.status = "watching"
668
693
  changed = true
669
694
  }
695
+ if (
696
+ gate.status === "watching" &&
697
+ canRemind(gate.tool, gate.signature) &&
698
+ gate.count >= PROMOTE_COUNT &&
699
+ gate.sessions.length >= PROMOTE_SESSIONS
700
+ ) {
701
+ // Recurring diagnostics already proven under the old policy start
702
+ // reminding immediately instead of waiting for the next failure.
703
+ gate.status = "reminding"
704
+ changed = true
705
+ }
670
706
  const signature = scrubSecrets(gate.signature)
671
707
  const snippet = scrubSecrets(gate.snippet)
672
708
  if (signature !== gate.signature) {
@@ -735,7 +771,7 @@ export class Stores {
735
771
  const index = await this.globalStore.loadIndex()
736
772
  const toEscalate = (await projectStore.load(true)).filter((g) => {
737
773
  const entry = index.keys[g.key]
738
- return entry !== undefined && entry.projects.length >= globalProjects
774
+ return entry !== undefined && entry.projects.length >= globalProjects && !isRepoLocal(g.signature)
739
775
  })
740
776
  if (toEscalate.length > 0) {
741
777
  await projectStore.runLocked(async () => {
@@ -900,15 +936,16 @@ export class Stores {
900
936
 
901
937
  let promoted = false
902
938
  const threshold = PROBE_TOOLS.has(input.tool) ? PROMOTE_COUNT_PROBE : PROMOTE_COUNT
903
- // Policy: only bash non-diagnostic commands may ever become gates.
904
- if (
905
- gate.status === "watching" &&
906
- canBlock(gate.tool, gate.signature) &&
907
- gate.count >= threshold &&
908
- gate.sessions.length >= PROMOTE_SESSIONS
909
- ) {
910
- gate.status = "blocking"
911
- promoted = true
939
+ // Policy: non-diagnostic bash may hard-block; diagnostics promote to
940
+ // remind-only (they never block — see canRemind). Everything else stays watching.
941
+ if (gate.status === "watching" && gate.count >= threshold && gate.sessions.length >= PROMOTE_SESSIONS) {
942
+ if (canBlock(gate.tool, gate.signature)) {
943
+ gate.status = "blocking"
944
+ promoted = true
945
+ } else if (canRemind(gate.tool, gate.signature)) {
946
+ gate.status = "reminding"
947
+ promoted = true
948
+ }
912
949
  }
913
950
 
914
951
  await store.save()
@@ -936,7 +973,15 @@ export class Stores {
936
973
  })
937
974
 
938
975
  let wentGlobal = false
939
- if (store !== this.globalStore && this.projectStore && indexProjects >= input.globalProjects) {
976
+ // Repo-local verbs (npm/git/gradle/...) never escalate: their failures are
977
+ // repo quirks, not agent habits — escalating them would let a broken
978
+ // `npm install` in one project block every other project.
979
+ if (
980
+ store !== this.globalStore &&
981
+ this.projectStore &&
982
+ indexProjects >= input.globalProjects &&
983
+ !isRepoLocal(moved.signature)
984
+ ) {
940
985
  // Global FIRST, then remove the local copy: a crash between the two
941
986
  // writes must leave a duplicate (healed by migrate), never a hole.
942
987
  await this.globalStore.runLocked(async () => {
package/src/validate.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * coerceGateShape + repairGate satisfies the data-model invariants.
6
6
  */
7
7
  import type { Gate } from "./store"
8
- import { canBlock, scrubSecrets } from "./patterns"
8
+ import { canBlock, canRemind, scrubSecrets } from "./patterns"
9
9
 
10
10
  /** sha1 prefix-12, the only key shape patternKey ever emits */
11
11
  const KEY_SHAPE = /^[0-9a-f]{12}$/
@@ -27,7 +27,7 @@ export function coerceGateShape(raw: unknown): Gate | null {
27
27
  if (typeof r.key !== "string" || !KEY_SHAPE.test(r.key)) return null
28
28
  if (typeof r.signature !== "string" || r.signature.trim() === "") return null
29
29
  if (typeof r.tool !== "string" || r.tool.trim() === "") return null
30
- if (r.status !== "watching" && r.status !== "blocking") return null
30
+ if (r.status !== "watching" && r.status !== "reminding" && r.status !== "blocking") return null
31
31
 
32
32
  const num = (v: unknown, fallback: number): number =>
33
33
  typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.floor(v) : fallback
@@ -157,9 +157,14 @@ export function repairGate(gate: Gate): boolean {
157
157
  }
158
158
  if (Object.keys(failed).length === 0) delete gate.failedSessions
159
159
  }
160
- // Policy is the single source of truth: a blocking gate that cannot block
161
- // is a leftover from an older policy and must be demoted.
160
+ // Policy is the single source of truth: an enforced gate that no longer
161
+ // qualifies is a leftover from an older policy and must be demoted
162
+ // blocking to reminding if the shape can still remind, else to watching.
162
163
  if (gate.status === "blocking" && !canBlock(gate.tool, gate.signature)) {
164
+ gate.status = canRemind(gate.tool, gate.signature) ? "reminding" : "watching"
165
+ changed = true
166
+ }
167
+ if (gate.status === "reminding" && !canRemind(gate.tool, gate.signature)) {
163
168
  gate.status = "watching"
164
169
  changed = true
165
170
  }