opencode-dejavu 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.6.0 — 2026-08-26
4
+
5
+ ### Added (only well-grounded triggers)
6
+ - **Gates heal.** dejavu previously only saw failures, so a gate kept reminding even after the underlying command was fixed (the `ruff check .` false positive). Now a SUCCESS matching an enforced gate increments `succeededAfterGate`; after `HEAL_SUCCESSES` (3) consecutive successes the gate retires to `watching` and logs `healed`, so fixed commands stop triggering. A failure resets the streak.
7
+
8
+ ## 2.5.1 — 2026-08-26
9
+
10
+ ### Fixed
11
+ - Cross-project index + forensic log now key on the gate's OWN key (post fuzzy-consolidation), not the raw failure key. Before, a failure that fuzzy-merged into an existing gate indexed a key with no gate — orphaning the entry and silently starving that gate's cross-project escalation (the "INDEX ORPHANS" you'd see in doctor).
12
+
3
13
  ## 2.5.0 — 2026-08-24
4
14
 
5
15
  ### Changed (the three known gaps, closed)
package/README.md CHANGED
@@ -72,6 +72,7 @@ Restart OpenCode. Gates appear automatically as failures recur — nothing to co
72
72
  - **Bounded memory** — per-session maps are capped (200 sessions) and freed on `session.deleted`; handled part IDs evict FIFO; TTL expiry re-runs every 6 h in long-lived processes.
73
73
  - **Migration** — gates outside the blocking policy are demoted to `watching` automatically; project copies of already-global gates are merged into the global gate (evidence is consolidated, never deleted).
74
74
  - **Self-healing** — every init reconciles the stores: an unparseable `gates.json` is quarantined (bytes preserved as `gates.json.corrupt-<ts>`), gate records are strictly parsed and mechanically repaired (inverted dates swapped, duplicate keys merged, secrets re-scrubbed, stale blocking demoted), unparseable log lines are excised to `log.jsonl.corrupt`, and the cross-project index is reconciled. Every repair is logged as a `repaired`/`quarantined` event.
75
+ - **Gates heal, not just accumulate** — dejavu sees successes too: a SUCCESS matching an enforced gate grows `succeededAfterGate`, and after 3 in a row the gate retires to `watching` (logged `healed`), so a command you fixed stops triggering reminders. A failure resets the streak. This kills the "ruff check passed 10 times but dejavu still reminds" false positive.
75
76
 
76
77
  ## Observability (debugging aids)
77
78
 
@@ -112,7 +113,7 @@ bun run typecheck # tsc --noEmit (index.ts + src/**)
112
113
  bun test/smoke.ts # behavioral smoke test, no framework needed
113
114
  ```
114
115
 
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
+ 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), `HEAL_SUCCESSES` (3).
116
117
 
117
118
  ## Roadmap
118
119
 
package/index.ts CHANGED
@@ -236,8 +236,6 @@ export const Dejavu: Plugin = async ({ directory, client }) => {
236
236
  // grep/pytest/linters: exit 1 is often the INTENDED outcome, not a mistake.
237
237
  const intended = exitCode === 1 && isIntendedNonzero(rawCommand, 1)
238
238
  const failed = exitCode !== null ? exitCode !== 0 && !intended : detection.matched
239
- if (!failed) return
240
- const snippet = scrubSecrets(detection.matched ? detection.snippet : `exit code ${exitCode}`)
241
239
 
242
240
  const args = scrubbedArgs(((input as { args?: unknown }).args ?? {}) as Record<string, unknown>)
243
241
  let signature = callSignature(input.tool, args)
@@ -247,9 +245,9 @@ export const Dejavu: Plugin = async ({ directory, client }) => {
247
245
  }
248
246
  if (!signature) return
249
247
 
250
- // Attribution: if a segment of a failed chain matches an already-known
251
- // pattern, record the failure under that segment's key — the chain
252
- // wrapper changes every time, the recurring part does not.
248
+ // Attribution: if a segment of the chain matches an already-known
249
+ // pattern, attribute to that segment's key — the chain wrapper changes
250
+ // every time, the recurring part does not.
253
251
  let recordSignature = signature
254
252
  if (input.tool === "bash" && typeof args.command === "string") {
255
253
  for (const segSig of bashSegmentSignatures(args.command)) {
@@ -259,10 +257,19 @@ export const Dejavu: Plugin = async ({ directory, client }) => {
259
257
  }
260
258
  }
261
259
  }
262
-
263
260
  const key = patternKey(recordSignature)
264
261
  const session = typeof input.sessionID === "string" ? input.sessionID : "unknown"
265
262
 
263
+ // A SUCCESS matching an enforced gate is evidence the command got fixed —
264
+ // track the streak so healed commands stop reminding (only bash gates
265
+ // enforce, so only bash successes can heal).
266
+ if (!failed) {
267
+ if (isBash) await stores.recordSuccess({ key, signature: recordSignature, tool: input.tool })
268
+ return
269
+ }
270
+
271
+ const snippet = scrubSecrets(detection.matched ? detection.snippet : `exit code ${exitCode}`)
272
+
266
273
  const result = await stores.recordFailure({
267
274
  key,
268
275
  signature: recordSignature,
@@ -275,7 +282,7 @@ export const Dejavu: Plugin = async ({ directory, client }) => {
275
282
 
276
283
  await stores.logAll({
277
284
  type: "detected",
278
- key,
285
+ key: result.gate.key,
279
286
  tool: input.tool,
280
287
  session,
281
288
  project: directory,
@@ -285,7 +292,7 @@ export const Dejavu: Plugin = async ({ directory, client }) => {
285
292
  })
286
293
 
287
294
  if (result.promoted) {
288
- await stores.logAll({ type: "promoted", key, tool: input.tool, session, project: directory })
295
+ await stores.logAll({ type: "promoted", key: result.gate.key, tool: input.tool, session, project: directory })
289
296
  await logClient(
290
297
  "info",
291
298
  `dejavu: gate promoted — "${result.gate.signature}" (${result.gate.count}x, ${result.gate.sessions.length} sessions)`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-dejavu",
3
- "version": "2.5.0",
3
+ "version": "2.6.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
@@ -40,6 +40,7 @@ Two dependency-free modules: `patterns.ts` (pure functions — call identity, no
40
40
  - Inside `runLocked` always `load(true)`; unlocked `load()` peeks are routing hints only, never a basis for mutation
41
41
  - Hot-path reads use the 1s TTL cache + key index (`byKey`/`enforcedOnly`); mutations inside locks use `load(true)`; `save()` refreshes the cache directly
42
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
43
+ - Successes heal: `recordSuccess` grows `succeededAfterGate` on an enforced gate; at `HEAL_SUCCESSES` (3) it retires to `watching` and logs `healed`. A failure resets the streak in `recordFailure`. Only bash successes heal (only bash gates enforce)
43
44
  - Log appends and rotation take the log lock — every OpenCode window shares the global log; unlocked appends interleave into broken JSON
44
45
  - Every gate read from disk crosses `coerceGateShape` + `repairGate` in `load()` — enforcement never sees raw state; hopeless records are dropped, repairable ones coerced
45
46
  - Quarantine preserves bytes: unparseable files are renamed to `*.corrupt-*`, never deleted; every repair emits a `repaired`/`quarantined` log event
package/src/store.ts CHANGED
@@ -4,7 +4,7 @@ import { canBlock, canRemind, fuzzySimilar, FUZZY_MAX_LEN, isRepoLocal, scrubSec
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.5.0"
7
+ export const PLUGIN_VERSION = "2.6.0"
8
8
 
9
9
  export interface Gate {
10
10
  /** sha1 signature prefix — the pattern identity */
@@ -31,6 +31,9 @@ export interface Gate {
31
31
  recurredAfterReminder: number
32
32
  /** the core health metric: failures of this pattern AFTER it became a gate */
33
33
  recurredAfterGate: number
34
+ /** consecutive successes after the gate was enforced — reaching HEAL_SUCCESSES
35
+ * retires the gate to watching (the underlying command got fixed) */
36
+ succeededAfterGate?: number
34
37
  /** flagged for manual review when the gate fires often but errors stopped */
35
38
  review?: boolean
36
39
  /** sessions currently reminded about this gate: sessionID -> remind time (ms).
@@ -73,6 +76,7 @@ export type LogEventType =
73
76
  | "quarantined"
74
77
  | "degraded"
75
78
  | "retired-healed"
79
+ | "healed"
76
80
 
77
81
  export interface LogEvent {
78
82
  type: LogEventType
@@ -108,6 +112,9 @@ export const PROMOTE_COUNT_PROBE = 5
108
112
  export const PROBE_TOOLS = new Set(["read", "glob", "grep", "write", "edit"])
109
113
  /** distinct sessions required — same-session loops never promote */
110
114
  export const PROMOTE_SESSIONS = 2
115
+ /** consecutive successes after a gate that retire it — the command is fixed,
116
+ * so the gate must stop reminding (the ruff-check-false-positive case) */
117
+ export const HEAL_SUCCESSES = 3
111
118
  /** store size bound: flooding with unique failures must not bloat gates.json
112
119
  * or slow the fuzzy scan — the weakest watching gate is evicted past this */
113
120
  export const MAX_GATES = 2000
@@ -933,6 +940,8 @@ export class Stores {
933
940
  // Only an exact-key failure updates the evidence: a crafted near-duplicate
934
941
  // must not overwrite a legitimate gate's snippet via fuzzy consolidation.
935
942
  if (!fuzzyConsolidated) gate.snippet = scrubSecrets(input.snippet)
943
+ // A failure breaks any heal streak — the command is still broken.
944
+ gate.succeededAfterGate = 0
936
945
 
937
946
  let promoted = false
938
947
  const threshold = PROBE_TOOLS.has(input.tool) ? PROMOTE_COUNT_PROBE : PROMOTE_COUNT
@@ -954,14 +963,17 @@ export class Stores {
954
963
  // ever sees its own store's directory, so alone it can never reach two
955
964
  // projects. A pattern seen in enough distinct project dirs is an
956
965
  // agent-level habit, not a repo quirk — move it to the global store.
966
+ // Keyed by the gate's OWN key (post fuzzy-consolidation), not the raw
967
+ // failure key — otherwise consolidated failures index a key that has no
968
+ // gate, orphaning the entry and starving the gate's escalation.
957
969
  // Lock order is always gates -> index and project -> global: no cycles.
958
970
  const moved = gate
959
971
  const indexProjects = await this.globalStore.runLockedIndex(async () => {
960
972
  const index = await this.globalStore.loadIndex(true)
961
- let entry = index.keys[input.key]
973
+ let entry = index.keys[moved.key]
962
974
  if (!entry) {
963
975
  entry = { projects: [], lastSeen: now }
964
- index.keys[input.key] = entry
976
+ index.keys[moved.key] = entry
965
977
  }
966
978
  if (input.projectDir !== "" && !entry.projects.includes(input.projectDir)) {
967
979
  entry.projects.push(input.projectDir)
@@ -1003,4 +1015,34 @@ export class Stores {
1003
1015
  return { gate: moved, store, promoted, wentGlobal }
1004
1016
  })
1005
1017
  }
1018
+
1019
+ /**
1020
+ * A SUCCESS matching an enforced gate is evidence the underlying command got
1021
+ * fixed. Track a streak; once it reaches HEAL_SUCCESSES the gate retires to
1022
+ * watching so it stops reminding on a now-healthy command (the
1023
+ * `ruff check .` false-positive case). Only enforced (blocking/reminding)
1024
+ * gates heal; a failure resets the streak in recordFailure.
1025
+ */
1026
+ async recordSuccess(input: { key: string; signature: string; tool: string }): Promise<void> {
1027
+ const match = await this.findGate(input.key, input.signature)
1028
+ if (match === null || match.gate.status === "watching") return
1029
+ const store = match.store
1030
+ const gateKey = match.gate.key
1031
+ await store.runLocked(async () => {
1032
+ const fresh = (await store.load(true)).find((g) => g.key === gateKey)
1033
+ if (fresh === undefined || fresh.status === "watching") return
1034
+ fresh.succeededAfterGate = (fresh.succeededAfterGate ?? 0) + 1
1035
+ const healed = fresh.succeededAfterGate >= HEAL_SUCCESSES
1036
+ if (healed) fresh.status = "watching"
1037
+ await store.save()
1038
+ if (healed) {
1039
+ await store.log({
1040
+ type: "healed",
1041
+ key: fresh.key,
1042
+ tool: fresh.tool,
1043
+ snippet: `succeeded ${fresh.succeededAfterGate}x in a row after the gate — retired to watching`,
1044
+ })
1045
+ }
1046
+ })
1047
+ }
1006
1048
  }
package/src/validate.ts CHANGED
@@ -52,6 +52,9 @@ export function coerceGateShape(raw: unknown): Gate | null {
52
52
  recurredAfterReminder: num(r.recurredAfterReminder, 0),
53
53
  recurredAfterGate: num(r.recurredAfterGate, 0),
54
54
  }
55
+ if (typeof r.succeededAfterGate === "number" && Number.isFinite(r.succeededAfterGate) && r.succeededAfterGate > 0) {
56
+ gate.succeededAfterGate = Math.floor(r.succeededAfterGate)
57
+ }
55
58
  if (typeof r.correction === "string") gate.correction = r.correction
56
59
  if (r.review === true) gate.review = true
57
60
  if (r.remindedSessions !== null && typeof r.remindedSessions === "object" && !Array.isArray(r.remindedSessions)) {