opencode-dejavu 2.2.0 → 2.2.1

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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.2.1 — 2026-08-24
4
+
5
+ ### Fixed (adversarial-review round)
6
+ - Escalation order: the gate is written to the global store BEFORE being removed from the project store — a crash between the two writes leaves a duplicate (healed by migrate), never a hole.
7
+ - `dejavu:proceed` inside quoted strings no longer bypasses gates (`echo "dejavu:proceed" && gated-cmd` stays enforced); the marker is honored only outside quotes.
8
+ - Concurrent first-encounter race: calls dispatched in the same burst as a REMINDER (within 500ms) are reminded too instead of slipping through as a "retry".
9
+ - CRLF/CR commands normalize identically to LF; `splitChain` splits on CR — no more line-ending fragmentation.
10
+ - `normalizeCommand` is fully idempotent: quoted spans are parameterized BEFORE path rules (a `<str>` substitution inserts spaces that would expose an adjacent `/` to the path rule only on a second pass), fingerprint payloads are trimmed, and already-parameterized payloads are never re-fingerprinted.
11
+ - Interpreter flags glued to their payload (`node -e"code"`) fingerprint identically to the spaced form.
12
+ - Session state maps: inner key sets are capped — long sessions no longer grow unbounded.
13
+
14
+ ### Added
15
+ - Lock degradation (contention > 3s) emits a `degraded` log event — the only window where concurrent writes can lose updates is now visible.
16
+ - `test/property.ts` — property-based tests for the normalization pipeline (idempotency, no nested tokens, output bound, one-liner distinctness, marker neutrality, splitChain atomicity).
17
+ - `test/fuzz.ts` — seeded mutation fuzzer with a metamorphic oracle and case shrinking; both harnesses run in CI. The harnesses caught the idempotency, marker-neutrality, glued-flag and nested-token-detector bugs above before production did.
18
+
3
19
  ## 2.2.0 — 2026-08-23
4
20
 
5
21
  ### Added
package/README.md CHANGED
@@ -66,7 +66,7 @@ Restart OpenCode. Gates appear automatically as failures recur — nothing to co
66
66
  - **Intended non-zero exits** — exit 1 from diagnostics is NOT a failure (that is their normal "found nothing / found issues" outcome). Exit ≥ 2 always counts.
67
67
  - **Aborted ≠ failed** — cancelled/aborted tool executions ("Tool execution aborted") are infrastructure noise and are never counted as failures.
68
68
  - **File content is not command output** — text failure signatures are scanned for `bash` only; `read`/`edit`/`write` failures come exclusively from the event channel (a file containing "TypeError" is not a failure).
69
- - **Concurrency** — gates.json mutations run under an exclusive lockfile; log appends and rotation take their own lock (every OpenCode window shares the global log); writes are tmp+rename with EPERM/EACCES/EBUSY retry (Windows AV/indexer). NT long paths get the `\\?\` prefix.
69
+ - **Concurrency** — gates.json mutations run under an exclusive lockfile; log appends and rotation take their own lock (every OpenCode window shares the global log); writes are tmp+rename with EPERM/EACCES/EBUSY retry (Windows AV/indexer). NT long paths get the `\\?\` prefix. If a lock cannot be acquired within 3s the critical section degrades to unlocked (the tool pipeline must never hang) and emits a `degraded` log event — the only window where updates can be lost is visible.
70
70
  - **Near-duplicate consolidation** — new failures merge into existing patterns via normalized Levenshtein ≤ 0.3 with an absolute floor of 3 edits (replaces token Jaccard, which collapsed all `<str>` placeholders; the floor stops `git push` vs `git pull`-style merges).
71
71
  - **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.
72
72
  - **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).
package/index.ts CHANGED
@@ -25,6 +25,12 @@ const TTL_INTERVAL_MS = 6 * 60 * 60 * 1000
25
25
  const REVIEW_FIRES = 10
26
26
  /** per-session state maps are capped to bound memory in long-lived processes */
27
27
  const SESSION_MAP_CAP = 200
28
+ /** per-session key sets are capped too — one long session must not grow unbounded */
29
+ const SESSION_KEY_CAP = 500
30
+ /** a "retry" arriving this soon after a reminder was dispatched concurrently with it
31
+ * (same tool-call burst) and never saw the reminder — it gets reminded as well.
32
+ * A true agent retry needs a full model turn (≥1s in practice), so 500ms separates both. */
33
+ const REMINDER_RACE_WINDOW_MS = 500
28
34
  /** handled part IDs are capped FIFO-style */
29
35
  const HANDLED_CAP = 5000
30
36
  const HANDLED_KEEP = 2500
@@ -41,10 +47,15 @@ function addToSetMap(map: Map<string, Set<string>>, outer: string, inner: string
41
47
  map.set(outer, set)
42
48
  }
43
49
  set.add(inner)
50
+ while (set.size > SESSION_KEY_CAP) {
51
+ const oldest = set.values().next()
52
+ if (oldest.done) break
53
+ set.delete(oldest.value)
54
+ }
44
55
  }
45
56
 
46
57
  /** Drop oldest entries (Map preserves insertion order) to bound memory. */
47
- function capMap(map: Map<string, Set<string>>, cap: number): void {
58
+ function capMap<K, V>(map: Map<K, V>, cap: number): void {
48
59
  while (map.size > cap) {
49
60
  const oldest = map.keys().next()
50
61
  if (oldest.done) break
@@ -89,8 +100,8 @@ export const Dejavu: Plugin = async ({ directory, client }) => {
89
100
  : null
90
101
  const stores = new Stores(globalStore, projectStore)
91
102
 
92
- /** sessions in which a gate key was already reminded about */
93
- const reminded = new Map<string, Set<string>>()
103
+ /** sessions in which a gate key was already reminded about; value = remind time (race guard) */
104
+ const reminded = new Map<string, Map<string, number>>()
94
105
  /** sessions in which a reminded pattern failed again — next attempt is blocked */
95
106
  const failedAfterReminder = new Map<string, Set<string>>()
96
107
  /** callID -> signature fallback when the after-hook does not receive args */
@@ -169,7 +180,10 @@ export const Dejavu: Plugin = async ({ directory, client }) => {
169
180
  const session = typeof input.sessionID === "string" ? input.sessionID : "unknown"
170
181
 
171
182
  // Explicit escape hatch — checked only in the actionable text field,
172
- // with word boundaries, so unrelated args cannot bypass gates.
183
+ // with word boundaries, so unrelated args cannot bypass gates. Quoted
184
+ // spans are stripped first: `echo "dejavu:proceed" && gated-cmd` must
185
+ // NOT bypass the gate on the chained command — the marker is a
186
+ // comment-style annotation, not data.
173
187
  const commandText =
174
188
  typeof rawArgs.command === "string"
175
189
  ? rawArgs.command
@@ -178,7 +192,7 @@ export const Dejavu: Plugin = async ({ directory, client }) => {
178
192
  : typeof rawArgs.filePath === "string"
179
193
  ? rawArgs.filePath
180
194
  : ""
181
- if (/\bdejavu:proceed\b/.test(commandText)) {
195
+ if (/\bdejavu:proceed\b/.test(commandText.replace(/"[^"]*"|'[^']*'/g, " "))) {
182
196
  await stores.logAll({ type: "override", key: gate.key, tool: gate.tool, session, project: directory })
183
197
  return
184
198
  }
@@ -193,9 +207,16 @@ export const Dejavu: Plugin = async ({ directory, client }) => {
193
207
  }
194
208
 
195
209
  // First encounter this session -> remind (the call is aborted; agent may retry corrected).
196
- if (!reminded.get(session)?.has(gate.key)) {
197
- addToSetMap(reminded, session, gate.key)
198
- addToSetMap(reminded, session, patternKey(signature)) // exact key too: retry may fuzzy-match differently
210
+ // Race guard: calls dispatched in the same burst all arrive before the agent can
211
+ // have seen any reminder, so a "retry" within REMINDER_RACE_WINDOW_MS of the
212
+ // remind is itself a concurrent first encounter and gets reminded too.
213
+ const sessionReminded = reminded.get(session) ?? new Map<string, number>()
214
+ if (!reminded.has(session)) reminded.set(session, sessionReminded)
215
+ const remindedAt = sessionReminded.get(gate.key)
216
+ if (remindedAt === undefined || Date.now() - remindedAt < REMINDER_RACE_WINDOW_MS) {
217
+ sessionReminded.set(gate.key, Date.now())
218
+ sessionReminded.set(patternKey(signature), Date.now()) // exact key too: retry may fuzzy-match differently
219
+ capMap(sessionReminded, SESSION_KEY_CAP)
199
220
  capMap(reminded, SESSION_MAP_CAP)
200
221
  gate.remindedCount += 1
201
222
  await found.store.save()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-dejavu",
3
- "version": "2.2.0",
3
+ "version": "2.2.1",
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
@@ -24,11 +24,13 @@ Two dependency-free modules: `patterns.ts` (pure functions — call identity, no
24
24
  ## INVARIANTS (do not break)
25
25
 
26
26
  - Rule order in `PARAM_RULES` matters: quoted strings first, specific tokens (uuid/sha/ip/url/date), generic numbers last — reordering fragments signatures
27
+ - 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
27
28
  - `scrubSecrets()` runs on every string before it touches disk; `recordFailure` re-scrubs defensively
28
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
29
30
  - `DIAGNOSTIC_VERBS` serves two callers (exit-1 allowlist + blocking policy) — one list, two uses; edit knowing both move
30
31
  - Lock order is always project → global, gates → index (see `recordFailure` escalation) — reversing deadlocks; the log lock is separate and leaf-level
31
32
  - Cross-project evidence lives ONLY in the global `index.json` — a gate's own `projects` array sees one store and never drives escalation alone
33
+ - Escalation writes the global gate FIRST, then removes the project copy — a crash between the two writes must leave a duplicate (healed by migrate), never a hole
32
34
  - Inside `runLocked` always `load(true)`; unlocked `load()` peeks are routing hints only, never a basis for mutation
33
35
  - `GateStore.load` caches by mtime — after external edits the cache refreshes on next stat; `save()` refreshes it manually
34
36
  - Log appends and rotation take the log lock — every OpenCode window shares the global log; unlocked appends interleave into broken JSON
package/src/patterns.ts CHANGED
@@ -51,16 +51,24 @@ export function scrubSecrets(text: string): string {
51
51
  * Secrets are scrubbed before hashing so they neither persist nor fragment.
52
52
  */
53
53
  const INTERPRETER_ONELINER =
54
- /(?:^|[|;&(\n]\s*)(?:\S+[\\/])?(python3?|node|bun|deno|perl|ruby|pwsh|powershell)(?:\.exe)?(?:\s+-\w+)*\s+(-c|-e|--eval|-command)\s+/i
54
+ /(?:^|[|;&(\n]\s*)(?:\S+[\\/])?(python3?|node|bun|deno|perl|ruby|pwsh|powershell)(?:\.exe)?(?:\s+-\w+)*\s+(-c|-e|--eval|-command)\s*/i
55
55
 
56
56
  function hashInterpreterPayload(command: string): string {
57
57
  const match = INTERPRETER_ONELINER.exec(command)
58
58
  if (!match) return command
59
59
  const payload = command.slice(match.index + match[0].length)
60
60
  if (payload.trim() === "") return command
61
+ // Already fingerprinted (re-normalization) — keep the existing token so
62
+ // normalizeCommand stays idempotent.
63
+ if (/^<code:[0-9a-f]+>$/.test(payload.trim())) return command
64
+ // Already-parameterized placeholders are data, not code — never hash them
65
+ // (idempotency: a second pass must not fingerprint a <str>).
66
+ if (/^(?:<(?:str|path|n|hash|uuid|sha|md5|ip|url|email|date)>\s*)+$/.test(payload.trim())) return command
61
67
  // For whole (unchained) commands the payload runs to end of string; chain
62
68
  // segments are normalized separately, so segment keys stay exact.
63
- const fingerprint = createHash("sha1").update(scrubSecrets(payload)).digest("hex").slice(0, 8)
69
+ // Trim before hashing: trailing whitespace (e.g. a stripped override marker)
70
+ // is not part of the code's identity.
71
+ const fingerprint = createHash("sha1").update(scrubSecrets(payload.trim())).digest("hex").slice(0, 8)
64
72
  return `${command.slice(0, match.index + match[0].length)}<code:${fingerprint}>`
65
73
  }
66
74
 
@@ -70,11 +78,18 @@ function hashInterpreterPayload(command: string): string {
70
78
  * away so that "same failure, different instance" collapses into one pattern.
71
79
  */
72
80
  export function normalizeCommand(command: string): string {
73
- let s = command.replace(COMMENT_LINE, "$1").toLowerCase()
81
+ // CRLF/CR commands (Windows pastes, agent multi-line) normalize to LF —
82
+ // otherwise the same command fragments across line-ending styles.
83
+ let s = command.replace(/\r\n?/g, "\n")
84
+ s = s.replace(COMMENT_LINE, "$1").toLowerCase()
74
85
  s = hashInterpreterPayload(s)
86
+ // Quoted spans come out FIRST: they are data, and removing them before the
87
+ // path rules keeps normalization idempotent — a <str> replacement inserts
88
+ // spaces that would otherwise expose an adjacent "/" to the path rule only
89
+ // on a second pass.
90
+ s = s.replace(/"[^"]*"|'[^']*'/g, " <str> ")
75
91
  s = s.replace(/[a-z]:[\\/][^\s"']+/gi, " <path> ")
76
92
  s = s.replace(/(^|\s)\/[^\s"']+/g, "$1<path> ")
77
- s = s.replace(/"[^"]*"|'[^']*'/g, " <str> ")
78
93
  // lookbehind: never re-parameterize the <code:...> fingerprint hex
79
94
  s = s.replace(/(?<!<code:)\b[0-9a-f]{7,64}\b/gi, " <hash> ")
80
95
  s = s.replace(/(?<!<code:)\b\d[\d.]*\b/g, " <n> ")
@@ -213,7 +228,7 @@ export function splitChain(command: string): string[] {
213
228
  continue
214
229
  }
215
230
  if (depth === 0) {
216
- if (ch === ";" || ch === "\n") {
231
+ if (ch === ";" || ch === "\n" || ch === "\r") {
217
232
  flush()
218
233
  i += 1
219
234
  continue
package/src/store.ts CHANGED
@@ -4,7 +4,7 @@ import { canBlock, fuzzySimilar, 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.2.0"
7
+ export const PLUGIN_VERSION = "2.2.1"
8
8
 
9
9
  export interface Gate {
10
10
  /** sha1 signature prefix — the pattern identity */
@@ -62,6 +62,7 @@ export type LogEventType =
62
62
  | "init"
63
63
  | "repaired"
64
64
  | "quarantined"
65
+ | "degraded"
65
66
 
66
67
  export interface LogEvent {
67
68
  type: LogEventType
@@ -137,7 +138,7 @@ const LOCK_WAIT_MS = 3000
137
138
  * degradation: if the lock cannot be acquired within LOCK_WAIT_MS the
138
139
  * critical section runs unlocked rather than hanging the tool pipeline.
139
140
  */
140
- async function withLock<T>(lockTarget: string, fn: () => Promise<T>): Promise<T> {
141
+ async function withLock<T>(lockTarget: string, fn: () => Promise<T>, onDegrade?: () => void): Promise<T> {
141
142
  const lock = `${lockTarget}.lock`
142
143
  await mkdir(ntPath(dirname(lock)), { recursive: true })
143
144
  const started = Date.now()
@@ -157,7 +158,11 @@ async function withLock<T>(lockTarget: string, fn: () => Promise<T>): Promise<T>
157
158
  } catch {
158
159
  continue // lock vanished between attempts
159
160
  }
160
- if (Date.now() - started > LOCK_WAIT_MS) break
161
+ if (Date.now() - started > LOCK_WAIT_MS) {
162
+ // The only window where concurrent writes can lose updates — make it visible.
163
+ if (onDegrade) onDegrade()
164
+ break
165
+ }
161
166
  await new Promise((resolve) => setTimeout(resolve, 50))
162
167
  }
163
168
  }
@@ -194,7 +199,9 @@ export class GateStore {
194
199
 
195
200
  /** Run a load→mutate→save section under the store's exclusive lock. */
196
201
  async runLocked<T>(fn: () => Promise<T>): Promise<T> {
197
- return withLock(this.gatesPath, fn)
202
+ return withLock(this.gatesPath, fn, () => {
203
+ this.log({ type: "degraded", key: "gates.lock", snippet: `lock contention exceeded ${LOCK_WAIT_MS}ms; critical section ran unlocked` }).catch(() => {})
204
+ })
198
205
  }
199
206
 
200
207
  /**
@@ -273,7 +280,9 @@ export class GateStore {
273
280
 
274
281
  /** Run an index load→mutate→save section under the index's own lock. */
275
282
  async runLockedIndex<T>(fn: () => Promise<T>): Promise<T> {
276
- return withLock(this.indexPath, fn)
283
+ return withLock(this.indexPath, fn, () => {
284
+ this.log({ type: "degraded", key: "index.lock", snippet: `lock contention exceeded ${LOCK_WAIT_MS}ms; critical section ran unlocked` }).catch(() => {})
285
+ })
277
286
  }
278
287
 
279
288
  /**
@@ -776,9 +785,8 @@ export class Stores {
776
785
 
777
786
  let wentGlobal = false
778
787
  if (store !== this.globalStore && this.projectStore && indexProjects >= input.globalProjects) {
779
- const idx = gates.findIndex((g) => g.key === moved.key)
780
- if (idx >= 0) gates.splice(idx, 1)
781
- await store.save()
788
+ // Global FIRST, then remove the local copy: a crash between the two
789
+ // writes must leave a duplicate (healed by migrate), never a hole.
782
790
  await this.globalStore.runLocked(async () => {
783
791
  const globalGates = await this.globalStore.load(true)
784
792
  const existing = globalGates.find((g) => g.key === moved.key)
@@ -789,6 +797,9 @@ export class Stores {
789
797
  }
790
798
  await this.globalStore.save()
791
799
  })
800
+ const idx = gates.findIndex((g) => g.key === moved.key)
801
+ if (idx >= 0) gates.splice(idx, 1)
802
+ await store.save()
792
803
  wentGlobal = true
793
804
  }
794
805
 
package/src/validate.ts CHANGED
@@ -97,11 +97,11 @@ export function repairGate(gate: Gate): boolean {
97
97
  }
98
98
 
99
99
  /**
100
- * Corruption fingerprint of tokens re-parameterized inside other tokens
101
- * (e.g. `<code: <n> >` — a fingerprint eaten by the number rule). Such a
102
- * signature is stable under re-normalization, so only an explicit shape
103
- * check catches it.
100
+ * Corruption fingerprint of a placeholder re-parameterized inside another
101
+ * token (`<code: <n> >` — a fingerprint eaten by the number rule). Only the
102
+ * `<code:` token carries nested content, so the check is scoped to it —
103
+ * shell text like heredoc `<<eof:` must NOT trip the detector.
104
104
  */
105
105
  export function hasNestedTokens(signature: string): boolean {
106
- return /<[a-z]+:\s*</.test(signature)
106
+ return /<code:\s*</.test(signature)
107
107
  }