opencode-dejavu 2.1.0 → 2.2.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,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.2.0 — 2026-08-23
4
+
5
+ ### Added
6
+ - Interpreter one-liner fingerprinting: `python -c` / `node -e` / `bun -e` and friends get their code payload hashed (`<code:sha1-8>`) instead of flattened to `<str>` — distinct scripts no longer share one gate, the same script failing repeatedly still converges.
7
+ - Global cross-project pattern index (`index.json`): counts distinct project dirs per failure key and now drives global escalation — a gate's own `projects` array only ever sees its own store, so escalation was dead code before.
8
+ - `mergeGate`: evidence merge for escalation and dedupe; never demotes a `blocking` gate.
9
+ - `isNoiseError()`: aborted/cancelled tool executions ("Tool execution aborted") are infrastructure noise and are no longer counted as failures.
10
+ - Self-healing stores (`src/validate.ts` invariant layer): every gate read from disk crosses strict parse + mechanical repair; `GateStore.reconcile()` quarantines unparseable gates.json (bytes preserved as `.corrupt-<ts>`), merges duplicate keys, excises unparseable log lines to `log.jsonl.corrupt`; `Stores.reconcileAll()` reconciles the index (prunes orphans, rebuilds missing entries, escalates gates proven in 2+ projects) — runs at every init.
11
+ - `doctor.ts [--repair]` now checks the full invariant set (shape, duplicates, temporal order, nested-token corruption, blocking without evidence, index consistency, stale project copies, missed escalation, log integrity) and heals on demand.
12
+
13
+ ### Changed
14
+ - `canBlock()` rejects bare one-liner shapes (`-c <str>`); existing gates with them are auto-demoted by `migrate()`.
15
+ - Log appends and rotation run under their own lock with atomic writes — concurrent OpenCode windows no longer interleave broken JSONL lines.
16
+ - `migrate()` merges project-local copies of already-global keys into the global gate.
17
+ - `doctor.ts`: NOT-TEACHING/ANNOYING only flag gates that can actually block; non-blockable legacy gates no longer scream.
18
+
19
+ ### Fixed
20
+ - All-digit `<code:...>` fingerprints are no longer re-parameterized by the number rule (~2.3% of payloads collapsed into one key).
21
+ - Signatures with different `<code:...>` fingerprints can no longer fuzzy-merge (random hashes differing in exactly 3 chars passed the distance rule and merged unrelated one-liners).
22
+ - `doctor.ts` no longer crashes on corrupt log lines; it now reports them as a CORRUPT LOG LINES pathology instead.
23
+ - Init failures (corrupt store, failed migrate) are logged instead of swallowed — a plugin starting on broken state is now visible.
24
+
3
25
  ## 2.1.0 — 2026-08-22
4
26
 
5
27
  First public release.
package/README.md CHANGED
@@ -28,7 +28,7 @@ Design decisions (post-mortem of existing approaches):
28
28
  - **Remind first, block on repeat.** Pure blocking starts an arms race — the agent routes around gates (`npm` blocked → uses `pnpm`). A reminder with the correction teaches; the block is reserved for ignored reminders.
29
29
  - **Gate messages are teachers.** Every message carries `CORRECTION:` (what to do instead) and `EVIDENCE:` (N failures across M sessions), not just a prohibition.
30
30
  - **Mechanical pattern-keys only.** No LLM-based error classification in the hot path — the unreliable component doesn't do reliability work.
31
- - **Two scopes.** Repo-specific gotchas live in `<repo>/.opencode/dejavu/` (committable); patterns seen in 2+ project dirs are agent-level habits and move to `~/.config/opencode/dejavu/`.
31
+ - **Two scopes.** Repo-specific gotchas live in `<repo>/.opencode/dejavu/` (committable); patterns seen in 2+ project dirs are agent-level habits and move to `~/.config/opencode/dejavu/`. No single store can see all projects, so a global pattern index (`index.json`) counts distinct project dirs per key and drives the escalation.
32
32
  - **Gates rot — so they expire.** 60 days without recurrence and a gate is dropped. A gate firing 10+ times while the error stopped gets `review: true` for manual inspection.
33
33
  - **The metric is recurrence-after-gate.** Tracked per gate as `recurredAfterGate` — if gates don't reduce recurrence, the whole approach is wrong and you'll see it in the data.
34
34
 
@@ -61,18 +61,21 @@ Restart OpenCode. Gates appear automatically as failures recur — nothing to co
61
61
  ## Robustness & safety
62
62
 
63
63
  - **Blocking policy** — only `bash` commands that are NOT diagnostics may ever become blocking gates. File probes (read/edit/write/glob/grep) and diagnostics (tsc/eslint/pytest/gradle-test/flutter/curl/grep...) stay `watching` forever: measured, visible in reports, but never interrupting the agent. `canBlock()` in `src/patterns.ts` is the single source of truth.
64
+ - **One-liner identity** — for `python -c` / `node -e` / `bun -e` and friends the code payload IS the call, so it is fingerprinted (`<code:hash>`) instead of flattened to `<str>`: different scripts never share a gate, the same script failing repeatedly still converges. Legacy bare `-c <str>` shapes can never block.
64
65
  - **Secret scrubbing** — every signature and snippet passes `scrubSecrets()` (OpenAI/Anthropic/AWS/GitHub/Slack/Stripe/JWT/bearer/DB-conn-string/PEM patterns + `root@host`) before touching disk. Historical data is cleaned by `migrate()` at init or via `bun scripts/migrate.ts <dirs...>` (also scrubs logs).
65
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
+ - **Aborted ≠ failed** — cancelled/aborted tool executions ("Tool execution aborted") are infrastructure noise and are never counted as failures.
66
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).
67
- - **Concurrency** — gates.json mutations run under an exclusive lockfile; 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.
68
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).
69
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.
70
- - **Migration** — gates outside the blocking policy are demoted to `watching` automatically; nothing is deleted.
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).
73
+ - **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.
71
74
 
72
75
  ## Observability (debugging aids)
73
76
 
74
77
  - Every `log.jsonl` gets an `init` event with `PLUGIN_VERSION`; `detected` events carry `channel` (`exit`/`text`/`event`) and the raw exit code; `reminded`/`blocked` carry `via` (`exact`/`fuzzy`/`segment`). Stale plugin sessions are therefore visible in the data.
75
- - `bun scripts/doctor.ts [projectDirs...]` — one-command pathology report: blocking gates outside policy, not-teaching gates (recurredAfterGate 3), annoying gates (reminded 10), secrets on disk, version drift.
78
+ - `bun scripts/doctor.ts [--repair] [projectDirs...]` — one-command report over every invariant the data model implies: gate shape, duplicate keys, temporal order, nested-token corruption, blocking without evidence, policy violations, index↔gates consistency, stale project copies, missed escalation, log integrity, secrets, version drift. `--repair` heals first (idempotent), then reports.
76
79
  - `bun scripts/analyze.ts [projectDirs...]` — store summary: statuses, tools, top patterns.
77
80
  - `/dejavu` command (installed globally) runs doctor first, then reports.
78
81
 
@@ -93,8 +96,10 @@ Not covered (by design, v1): semantically-equivalent-but-syntactically-different
93
96
  | File | Contents |
94
97
  |---|---|
95
98
  | `~/.config/opencode/dejavu/gates.json` | global gates (agent habits) |
99
+ | `~/.config/opencode/dejavu/index.json` | cross-project pattern index: which project dirs each failure key was seen in (escalation evidence) |
96
100
  | `<repo>/.opencode/dejavu/gates.json` | project gates (repo gotchas) |
97
- | `*/dejavu/log.jsonl` | every event: detected, promoted, reminded, blocked, override, expired, recurred-after-gate |
101
+ | `*/dejavu/log.jsonl` | every event: detected, promoted, reminded, blocked, override, expired, recurred-after-gate, repaired, quarantined |
102
+ | `*/dejavu/*.corrupt*` | quarantined corruption (unparseable gates.json, excised log lines) — bytes preserved for forensics; safe to delete after inspection |
98
103
 
99
104
  Both are human-editable. Removing a gate object disables it. Editing `correction` improves what the agent is told.
100
105
 
package/index.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  callSignature,
7
7
  detectFailure,
8
8
  isIntendedNonzero,
9
+ isNoiseError,
9
10
  parameterizeError,
10
11
  patternKey,
11
12
  scrubSecrets,
@@ -105,15 +106,19 @@ export const Dejavu: Plugin = async ({ directory, client }) => {
105
106
  }
106
107
  }
107
108
 
108
- // Init: migrate old data, expire stale gates, rotate logs, warm the caches.
109
+ // Init: heal structural damage, migrate old data, expire stale gates,
110
+ // rotate logs, warm the caches.
109
111
  try {
112
+ await stores.reconcileAll(GLOBAL_PROJECTS)
110
113
  await stores.migrate()
111
114
  await stores.expireAll(TTL_DAYS)
112
115
  await stores.rotateLogs()
113
116
  await stores.logAll({ type: "init", key: "dejavu", version: PLUGIN_VERSION })
114
117
  await logClient("info", `dejavu initialized v${PLUGIN_VERSION}`)
115
- } catch {
116
- // init failures must not prevent hook registration
118
+ } catch (error) {
119
+ // init failures must not prevent hook registration — but must be visible,
120
+ // otherwise a corrupted store silently starts the plugin with no gates
121
+ await logClient("error", `dejavu init failed: ${error instanceof Error ? error.message : String(error)}`)
117
122
  }
118
123
 
119
124
  // Long-lived processes re-run expiry periodically.
@@ -347,6 +352,8 @@ export const Dejavu: Plugin = async ({ directory, client }) => {
347
352
  // Never count our own gate signals as failures — a thrown REMINDER/BLOCK
348
353
  // comes back through this channel as a tool error.
349
354
  if (errorText.includes("[dejavu]")) return
355
+ // Aborted/cancelled executions are infrastructure noise, not mistakes.
356
+ if (isNoiseError(errorText)) return
350
357
  const session = typeof p.sessionID === "string" ? p.sessionID : "unknown"
351
358
 
352
359
  // Prefer the real call signature from the tool input — it keeps the gate
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-dejavu",
3
- "version": "2.1.0",
3
+ "version": "2.2.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
@@ -9,29 +9,39 @@ Two dependency-free modules: `patterns.ts` (pure functions — call identity, no
9
9
  | Task | File | Symbols |
10
10
  |------|------|---------|
11
11
  | Call identity / gate keys | patterns.ts | `callSignature` → `normalizeCommand`/`normalizeFilePath` → `patternKey` |
12
+ | Interpreter one-liner identity | patterns.ts | `hashInterpreterPayload` — `-c`/`-e` code payload → `<code:hash>` |
12
13
  | Chain-bypass protection | patterns.ts | `splitChain` (quote/paren-aware) → `bashSegmentSignatures` |
13
14
  | Free-form error collapsing | patterns.ts | `parameterizeError` (event channel) vs `normalizeCommand` (bash) |
14
15
  | Near-duplicate merge | patterns.ts | `fuzzySimilar` = normalized `levenshtein` ≤ 0.3 |
15
16
  | Failure text scan | patterns.ts | `detectFailure` + `FAILURE_SIGNATURES` |
17
+ | Noise filtering | patterns.ts | `isNoiseError` + `NOISE_ERRORS` (aborted/cancelled ≠ failed) |
16
18
  | Diagnostic/intended-exit logic | patterns.ts | `DIAGNOSTIC_VERBS`, `isIntendedNonzero`, `canBlock` |
17
- | One scope (gates.json + log.jsonl) | store.ts | `GateStore` — `load`/`save`/`log`/`expire`/`rotateLog` |
18
- | Two-scope logic + promotion | store.ts | `Stores` — `findGate`/`recordFailure`/`migrate`/`blockingGates` |
19
+ | 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
+ | Gate parse/repair boundary | validate.ts | `coerceGateShape` (strict parse), `repairGate` (mechanical coercion), `hasNestedTokens` (corruption fingerprint) |
19
22
  | fs safety | store.ts | `ntPath`, `atomicWrite`, `withLock` |
20
23
 
21
24
  ## INVARIANTS (do not break)
22
25
 
23
26
  - Rule order in `PARAM_RULES` matters: quoted strings first, specific tokens (uuid/sha/ip/url/date), generic numbers last — reordering fragments signatures
24
27
  - `scrubSecrets()` runs on every string before it touches disk; `recordFailure` re-scrubs defensively
25
- - `canBlock(tool, sig)` = `tool === "bash" && !diagnostic` — the ONLY path to `blocking`; probe tools use `PROMOTE_COUNT_PROBE` and never block
28
+ - `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
26
29
  - `DIAGNOSTIC_VERBS` serves two callers (exit-1 allowlist + blocking policy) — one list, two uses; edit knowing both move
27
- - Lock order is always project → global (see `recordFailure` escalation) — reversing deadlocks
30
+ - Lock order is always project → global, gates → index (see `recordFailure` escalation) — reversing deadlocks; the log lock is separate and leaf-level
31
+ - Cross-project evidence lives ONLY in the global `index.json` — a gate's own `projects` array sees one store and never drives escalation alone
28
32
  - Inside `runLocked` always `load(true)`; unlocked `load()` peeks are routing hints only, never a basis for mutation
29
33
  - `GateStore.load` caches by mtime — after external edits the cache refreshes on next stat; `save()` refreshes it manually
34
+ - Log appends and rotation take the log lock — every OpenCode window shares the global log; unlocked appends interleave into broken JSON
35
+ - Every gate read from disk crosses `coerceGateShape` + `repairGate` in `load()` — enforcement never sees raw state; hopeless records are dropped, repairable ones coerced
36
+ - Quarantine preserves bytes: unparseable files are renamed to `*.corrupt-*`, never deleted; every repair emits a `repaired`/`quarantined` log event
30
37
  - Fuzzy matching is Levenshtein-based on purpose: token Jaccard collapsed all `<str>` placeholders into one bucket; ratio ≤ 0.3 PLUS absolute distance ≥ 3 (verb-level-different commands must never merge)
31
38
 
32
39
  ## ANTI-PATTERNS
33
40
 
34
41
  - Do NOT add a tool to `callSignature` without deciding its class: `PROBE_TOOLS` (higher bar, never blocks) or bash-class
35
42
  - Do NOT widen `FAILURE_SIGNATURES` to cover file-tool output — that text is file content; extend the event channel instead
43
+ - Do NOT flatten interpreter one-liner payloads to `<str>` — `hashInterpreterPayload` must run before string parameterization; a bare `-c <str>` gate would block the whole command family
44
+ - Do NOT count aborted/cancelled executions as failures — filter with `isNoiseError()` at the event channel
45
+ - Do NOT widen `coerceGateShape` drop rules casually — records it deems hopeless are silently dropped on every load; a bad rule silently empties stores
36
46
  - Do NOT write gates.json directly — always `runLocked` + `save()` (atomicWrite); logs are append-only via `log()`
37
47
  - Do NOT let `withLock` throw on contention — it degrades to unlocked after `LOCK_WAIT_MS` by design (pipeline must not hang)
package/src/patterns.ts CHANGED
@@ -43,6 +43,27 @@ export function scrubSecrets(text: string): string {
43
43
 
44
44
  // --- Normalization -----------------------------------------------------------
45
45
 
46
+ /**
47
+ * Interpreter one-liners: the quoted argument IS the program. Parameterizing
48
+ * it to <str> collapsed every script into one key — "python -c <str>" ended up
49
+ * blocking ALL python -c calls after three unrelated failures. Fingerprint the
50
+ * payload instead: same code = same key, different code = different key.
51
+ * Secrets are scrubbed before hashing so they neither persist nor fragment.
52
+ */
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
55
+
56
+ function hashInterpreterPayload(command: string): string {
57
+ const match = INTERPRETER_ONELINER.exec(command)
58
+ if (!match) return command
59
+ const payload = command.slice(match.index + match[0].length)
60
+ if (payload.trim() === "") return command
61
+ // For whole (unchained) commands the payload runs to end of string; chain
62
+ // segments are normalized separately, so segment keys stay exact.
63
+ const fingerprint = createHash("sha1").update(scrubSecrets(payload)).digest("hex").slice(0, 8)
64
+ return `${command.slice(0, match.index + match[0].length)}<code:${fingerprint}>`
65
+ }
66
+
46
67
  /**
47
68
  * Normalize a bash command into a stable signature.
48
69
  * Paths, numbers, quoted strings, hashes and agent comments are abstracted
@@ -50,11 +71,13 @@ export function scrubSecrets(text: string): string {
50
71
  */
51
72
  export function normalizeCommand(command: string): string {
52
73
  let s = command.replace(COMMENT_LINE, "$1").toLowerCase()
74
+ s = hashInterpreterPayload(s)
53
75
  s = s.replace(/[a-z]:[\\/][^\s"']+/gi, " <path> ")
54
76
  s = s.replace(/(^|\s)\/[^\s"']+/g, "$1<path> ")
55
77
  s = s.replace(/"[^"]*"|'[^']*'/g, " <str> ")
56
- s = s.replace(/\b[0-9a-f]{7,64}\b/gi, " <hash> ")
57
- s = s.replace(/\b\d[\d.]*\b/g, " <n> ")
78
+ // lookbehind: never re-parameterize the <code:...> fingerprint hex
79
+ s = s.replace(/(?<!<code:)\b[0-9a-f]{7,64}\b/gi, " <hash> ")
80
+ s = s.replace(/(?<!<code:)\b\d[\d.]*\b/g, " <n> ")
58
81
  s = s.replace(/\s+/g, " ").trim()
59
82
  return s
60
83
  }
@@ -123,6 +146,14 @@ export function isIntendedNonzero(command: string, exitCode: number): boolean {
123
146
  return exitCode === 1 && isDiagnosticText(command)
124
147
  }
125
148
 
149
+ /**
150
+ * A code flag whose payload was entirely parameterized away (`-c <str>`)
151
+ * carries no identity — blocking that shape blocks the whole command family.
152
+ * New one-liners get <code:...> fingerprints in normalizeCommand; this guard
153
+ * keeps legacy pre-fingerprint gates (and lookalikes) from ever blocking.
154
+ */
155
+ const GENERIC_ONELINER_SHAPE = /(^|\s)(-c|-e|--eval|-command)\s+(?:@\s+)?<str>(?:\s+@)?\s*$/i
156
+
126
157
  /**
127
158
  * Blocking policy: only bash commands that are NOT diagnostics may ever
128
159
  * become enforced gates. File probes and diagnostic queries are measured
@@ -130,7 +161,9 @@ export function isIntendedNonzero(command: string, exitCode: number): boolean {
130
161
  * punishes normal work.
131
162
  */
132
163
  export function canBlock(tool: string, signature: string): boolean {
133
- return tool === "bash" && !isDiagnosticSignature(signature)
164
+ if (tool !== "bash") return false
165
+ if (isDiagnosticSignature(signature)) return false
166
+ return !GENERIC_ONELINER_SHAPE.test(signature)
134
167
  }
135
168
 
136
169
  // --- Chain splitting ---------------------------------------------------------
@@ -281,14 +314,25 @@ export function levenshtein(a: string, b: string): number {
281
314
  return prev[n] ?? 0
282
315
  }
283
316
 
317
+ /** Code fingerprints are IDENTITY, not data — they must match exactly. */
318
+ const CODE_FINGERPRINTS = /<code:[0-9a-f]+>/g
319
+
284
320
  /**
285
321
  * Near-duplicate match: normalized edit distance <= 30% AND absolute distance
286
322
  * >= 3. Unlike token-set Jaccard, this does not collapse commands that merely
287
323
  * share placeholder tokens; the absolute floor stops verb-level-different
288
324
  * commands ("git push <str>" vs "git pull <str>" = distance 2) from merging.
325
+ * Signatures carrying <code:...> fingerprints only match if the fingerprints
326
+ * are identical — random hashes differing in 3 chars would otherwise pass the
327
+ * distance rule and merge unrelated one-liners into one gate.
289
328
  */
290
329
  export function fuzzySimilar(a: string, b: string): boolean {
291
330
  if (a === b) return true
331
+ const codesA = a.match(CODE_FINGERPRINTS)
332
+ const codesB = b.match(CODE_FINGERPRINTS)
333
+ if (codesA !== null || codesB !== null) {
334
+ if (codesA === null || codesB === null || codesA.join("\u0000") !== codesB.join("\u0000")) return false
335
+ }
292
336
  const maxLen = Math.max(a.length, b.length)
293
337
  if (maxLen === 0) return true
294
338
  const distance = levenshtein(a, b)
@@ -331,3 +375,22 @@ export function detectFailure(outputText: string): FailureDetection {
331
375
  }
332
376
  return { matched: false, snippet: "" }
333
377
  }
378
+
379
+ // --- Noise filtering ----------------------------------------------------------
380
+
381
+ /**
382
+ * Infrastructure noise, not agent mistakes: aborted/cancelled executions
383
+ * (user hit stop, background task reaped) teach nothing and fragmented the
384
+ * store with unactionable patterns. Aborted != failed.
385
+ */
386
+ const NOISE_ERRORS: RegExp[] = [
387
+ /tool execution aborted/i,
388
+ /execution was aborted/i,
389
+ /\baborted by user\b/i,
390
+ /\bcancelled by user\b/i,
391
+ /\bcanceled by user\b/i,
392
+ ]
393
+
394
+ export function isNoiseError(errorText: string): boolean {
395
+ return NOISE_ERRORS.some((rule) => rule.test(errorText))
396
+ }
package/src/store.ts CHANGED
@@ -1,9 +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
3
  import { canBlock, fuzzySimilar, scrubSecrets } from "./patterns"
4
+ import { coerceGateShape, repairGate } from "./validate"
4
5
 
5
6
  /** Bumped on behavior changes; stamped into init log events so stale sessions are visible. */
6
- export const PLUGIN_VERSION = "2.1.0"
7
+ export const PLUGIN_VERSION = "2.2.0"
7
8
 
8
9
  export interface Gate {
9
10
  /** sha1 signature prefix — the pattern identity */
@@ -38,6 +39,17 @@ interface GatesFile {
38
39
  gates: Gate[]
39
40
  }
40
41
 
42
+ /** Cross-project pattern index: which project dirs have seen each key. */
43
+ interface IndexEntry {
44
+ projects: string[]
45
+ lastSeen: string
46
+ }
47
+
48
+ interface IndexFile {
49
+ version: 1
50
+ keys: Record<string, IndexEntry>
51
+ }
52
+
41
53
  export type LogEventType =
42
54
  | "detected"
43
55
  | "promoted"
@@ -48,6 +60,8 @@ export type LogEventType =
48
60
  | "expired"
49
61
  | "recurred-after-gate"
50
62
  | "init"
63
+ | "repaired"
64
+ | "quarantined"
51
65
 
52
66
  export interface LogEvent {
53
67
  type: LogEventType
@@ -161,6 +175,8 @@ async function withLock<T>(lockTarget: string, fn: () => Promise<T>): Promise<T>
161
175
  export class GateStore {
162
176
  private gates: Gate[] | null = null
163
177
  private mtimeMs = 0
178
+ private index: IndexFile | null = null
179
+ private indexMtimeMs = 0
164
180
 
165
181
  constructor(public readonly dir: string) {}
166
182
 
@@ -172,12 +188,20 @@ export class GateStore {
172
188
  return join(this.dir, "log.jsonl")
173
189
  }
174
190
 
191
+ private get indexPath(): string {
192
+ return join(this.dir, "index.json")
193
+ }
194
+
175
195
  /** Run a load→mutate→save section under the store's exclusive lock. */
176
196
  async runLocked<T>(fn: () => Promise<T>): Promise<T> {
177
197
  return withLock(this.gatesPath, fn)
178
198
  }
179
199
 
180
- /** force=true bypasses the mtime cache (always used inside locks). */
200
+ /**
201
+ * force=true bypasses the mtime cache (always used inside locks).
202
+ * Every record crosses the validation boundary: hopeless records are
203
+ * dropped, repairable ones coerced — enforcement never sees raw state.
204
+ */
181
205
  async load(force = false): Promise<Gate[]> {
182
206
  try {
183
207
  const info = await stat(ntPath(this.gatesPath))
@@ -186,7 +210,15 @@ export class GateStore {
186
210
  }
187
211
  const raw = await readFile(ntPath(this.gatesPath), "utf8")
188
212
  const parsed = JSON.parse(raw) as Partial<GatesFile>
189
- this.gates = Array.isArray(parsed.gates) ? parsed.gates : []
213
+ const records = Array.isArray(parsed.gates) ? parsed.gates : []
214
+ const gates: Gate[] = []
215
+ for (const record of records) {
216
+ const gate = coerceGateShape(record)
217
+ if (gate === null) continue
218
+ repairGate(gate)
219
+ gates.push(gate)
220
+ }
221
+ this.gates = gates
190
222
  this.mtimeMs = info.mtimeMs
191
223
  return this.gates
192
224
  } catch {
@@ -208,10 +240,52 @@ export class GateStore {
208
240
  }
209
241
  }
210
242
 
211
- async log(event: LogEvent): Promise<void> {
243
+ /** Cross-project pattern index; meaningful only on the global store. */
244
+ async loadIndex(force = false): Promise<IndexFile> {
245
+ try {
246
+ const info = await stat(ntPath(this.indexPath))
247
+ if (!force && this.index !== null && info.mtimeMs === this.indexMtimeMs) {
248
+ return this.index
249
+ }
250
+ const raw = await readFile(ntPath(this.indexPath), "utf8")
251
+ const parsed = JSON.parse(raw) as Partial<IndexFile>
252
+ const keys = parsed.keys
253
+ this.index = { version: 1, keys: keys !== null && typeof keys === "object" ? keys : {} }
254
+ this.indexMtimeMs = info.mtimeMs
255
+ return this.index
256
+ } catch {
257
+ // missing or unreadable index — treat as empty
258
+ if (this.index === null) this.index = { version: 1, keys: {} }
259
+ return this.index
260
+ }
261
+ }
262
+
263
+ async saveIndex(): Promise<void> {
264
+ if (this.index === null) return
212
265
  await mkdir(ntPath(this.dir), { recursive: true })
213
- const line = `${JSON.stringify({ ts: new Date().toISOString(), ...event })}\n`
214
- await appendFile(ntPath(this.logPath), line, "utf8")
266
+ await atomicWrite(this.indexPath, `${JSON.stringify(this.index, null, 2)}\n`)
267
+ try {
268
+ this.indexMtimeMs = (await stat(ntPath(this.indexPath))).mtimeMs
269
+ } catch {
270
+ // mtime refresh is best-effort
271
+ }
272
+ }
273
+
274
+ /** Run an index load→mutate→save section under the index's own lock. */
275
+ async runLockedIndex<T>(fn: () => Promise<T>): Promise<T> {
276
+ return withLock(this.indexPath, fn)
277
+ }
278
+
279
+ /**
280
+ * Append under the log lock: every OpenCode window shares the global log,
281
+ * and unlocked concurrent appends interleave into broken JSON lines.
282
+ */
283
+ async log(event: LogEvent): Promise<void> {
284
+ await withLock(this.logPath, async () => {
285
+ await mkdir(ntPath(this.dir), { recursive: true })
286
+ const line = `${JSON.stringify({ ts: new Date().toISOString(), ...event })}\n`
287
+ await appendFile(ntPath(this.logPath), line, "utf8")
288
+ })
215
289
  }
216
290
 
217
291
  /** Caller must hold the lock. */
@@ -225,20 +299,151 @@ export class GateStore {
225
299
  return expired
226
300
  }
227
301
 
302
+ /** Remove gates by key; caller must hold the lock. Returns the removed gates. */
303
+ extract(keys: Set<string>): Gate[] {
304
+ if (this.gates === null) return []
305
+ const removed = this.gates.filter((g) => keys.has(g.key))
306
+ if (removed.length > 0) this.gates = this.gates.filter((g) => !keys.has(g.key))
307
+ return removed
308
+ }
309
+
228
310
  async rotateLog(): Promise<void> {
311
+ await withLock(this.logPath, async () => {
312
+ try {
313
+ const info = await stat(ntPath(this.logPath))
314
+ if (info.size < LOG_ROTATE_BYTES) return
315
+ const raw = await readFile(ntPath(this.logPath), "utf8")
316
+ const lines = raw.split("\n").filter((l) => l.trim() !== "")
317
+ const kept = lines.slice(-LOG_ROTATE_KEEP_LINES)
318
+ await atomicWrite(this.logPath, `${kept.join("\n")}\n`)
319
+ } catch {
320
+ // missing or unreadable log is fine
321
+ }
322
+ })
323
+ }
324
+
325
+ /**
326
+ * Structural self-healing (idempotent): unparseable gates.json is
327
+ * quarantined with its bytes preserved; parseable records are coerced,
328
+ * repaired and deduped; unparseable log lines are excised to
329
+ * log.jsonl.corrupt. Every repair is logged — healing must be visible.
330
+ */
331
+ async reconcile(): Promise<void> {
332
+ await withLock(this.gatesPath, async () => {
333
+ let raw: string | null = null
334
+ try {
335
+ raw = await readFile(ntPath(this.gatesPath), "utf8")
336
+ } catch {
337
+ // no gates file yet — nothing structural to heal
338
+ }
339
+ if (raw !== null && raw.trim() !== "") {
340
+ let parsed: Partial<GatesFile> | null = null
341
+ try {
342
+ parsed = JSON.parse(raw) as Partial<GatesFile>
343
+ } catch {
344
+ parsed = null
345
+ }
346
+ if (parsed === null || typeof parsed !== "object" || !Array.isArray(parsed.gates)) {
347
+ // SQLite-style quarantine: move aside, keep the bytes, start clean.
348
+ const quarantine = `${this.gatesPath}.corrupt-${Date.now()}`
349
+ try {
350
+ await rename(ntPath(this.gatesPath), ntPath(quarantine))
351
+ this.gates = []
352
+ this.mtimeMs = 0
353
+ await this.save()
354
+ await this.log({ type: "quarantined", key: "gates.json", snippet: `unparseable gates file moved to ${quarantine}` })
355
+ } catch {
356
+ // rename failed — next reconcile retries; never destroy the file
357
+ }
358
+ } else {
359
+ let dropped = 0
360
+ let repaired = 0
361
+ const byKey = new Map<string, Gate>()
362
+ for (const record of parsed.gates) {
363
+ const gate = coerceGateShape(record)
364
+ if (gate === null) {
365
+ dropped += 1
366
+ continue
367
+ }
368
+ if (repairGate(gate)) repaired += 1
369
+ const existing = byKey.get(gate.key)
370
+ if (existing) {
371
+ mergeGate(existing, gate)
372
+ } else {
373
+ byKey.set(gate.key, gate)
374
+ }
375
+ }
376
+ const merged = parsed.gates.length - dropped - byKey.size
377
+ this.gates = [...byKey.values()]
378
+ this.mtimeMs = 0
379
+ await this.save()
380
+ if (dropped > 0 || repaired > 0 || merged > 0) {
381
+ await this.log({
382
+ type: "repaired",
383
+ key: "gates.json",
384
+ snippet: `dropped ${dropped} hopeless record(s), repaired ${repaired}, merged ${merged} duplicate key(s)`,
385
+ })
386
+ }
387
+ }
388
+ }
389
+ await this.exciseCorruptLogLines()
390
+ })
391
+ }
392
+
393
+ /** Move unparseable JSONL lines to log.jsonl.corrupt; good lines stay. */
394
+ private async exciseCorruptLogLines(): Promise<void> {
395
+ let raw: string
229
396
  try {
230
- const info = await stat(ntPath(this.logPath))
231
- if (info.size < LOG_ROTATE_BYTES) return
232
- const raw = await readFile(ntPath(this.logPath), "utf8")
233
- const lines = raw.split("\n").filter((l) => l.trim() !== "")
234
- const kept = lines.slice(-LOG_ROTATE_KEEP_LINES)
235
- await writeFile(ntPath(this.logPath), `${kept.join("\n")}\n`, "utf8")
397
+ raw = await readFile(ntPath(this.logPath), "utf8")
236
398
  } catch {
237
- // missing or unreadable log is fine
399
+ return // no log yet
238
400
  }
401
+ const good: string[] = []
402
+ const bad: string[] = []
403
+ for (const line of raw.split("\n")) {
404
+ if (line.trim() === "") continue
405
+ try {
406
+ JSON.parse(line)
407
+ good.push(line)
408
+ } catch {
409
+ bad.push(line)
410
+ }
411
+ }
412
+ if (bad.length === 0) return
413
+ await withLock(this.logPath, async () => {
414
+ await appendFile(ntPath(`${this.logPath}.corrupt`), `${bad.join("\n")}\n`, "utf8")
415
+ await atomicWrite(this.logPath, good.length > 0 ? `${good.join("\n")}\n` : "")
416
+ })
417
+ await this.log({ type: "repaired", key: "log.jsonl", snippet: `excised ${bad.length} corrupt line(s) to log.jsonl.corrupt` })
239
418
  }
240
419
  }
241
420
 
421
+ /** Merge a gate's accumulated evidence into an existing gate with the same key. */
422
+ function mergeGate(target: Gate, source: Gate): void {
423
+ // blocking is the stronger state — a merge must never demote an enforced gate
424
+ if (source.status === "blocking") target.status = "blocking"
425
+ target.count += source.count
426
+ for (const session of source.sessions) {
427
+ if (!target.sessions.includes(session)) target.sessions.push(session)
428
+ }
429
+ if (target.sessions.length > MAX_SESSIONS) target.sessions = target.sessions.slice(-MAX_SESSIONS)
430
+ for (const project of source.projects) {
431
+ if (!target.projects.includes(project)) target.projects.push(project)
432
+ }
433
+ if (target.projects.length > MAX_PROJECTS) target.projects = target.projects.slice(-MAX_PROJECTS)
434
+ if (source.firstSeen < target.firstSeen) target.firstSeen = source.firstSeen
435
+ if (source.lastSeen > target.lastSeen) {
436
+ target.lastSeen = source.lastSeen
437
+ target.snippet = source.snippet
438
+ }
439
+ target.remindedCount += source.remindedCount
440
+ target.blockedCount += source.blockedCount
441
+ target.recurredAfterReminder += source.recurredAfterReminder
442
+ target.recurredAfterGate += source.recurredAfterGate
443
+ if (target.correction === undefined && source.correction !== undefined) target.correction = source.correction
444
+ if (source.review === true) target.review = true
445
+ }
446
+
242
447
  /**
243
448
  * Two-scope gate management: project-local gates live in the repo
244
449
  * (`.opencode/dejavu/`), cross-project agent habits are promoted to the
@@ -309,6 +514,20 @@ export class Stores {
309
514
  }
310
515
  })
311
516
  }
517
+ // The cross-project index rots on the same schedule as the gates.
518
+ await this.globalStore.runLockedIndex(async () => {
519
+ const index = await this.globalStore.loadIndex(true)
520
+ const cutoff = Date.now() - ttlDays * DAY_MS
521
+ let changed = false
522
+ for (const key of Object.keys(index.keys)) {
523
+ const entry = index.keys[key]
524
+ if (entry && Date.parse(entry.lastSeen) < cutoff) {
525
+ delete index.keys[key]
526
+ changed = true
527
+ }
528
+ }
529
+ if (changed) await this.globalStore.saveIndex()
530
+ })
312
531
  }
313
532
 
314
533
  async rotateLogs(): Promise<void> {
@@ -321,6 +540,7 @@ export class Stores {
321
540
  * One-time (idempotent) schema/behavior migration:
322
541
  * - probe-tool gates never block (they were learned under the old policy)
323
542
  * - signatures and snippets are secret-scrubbed (cleans historical leaks)
543
+ * - project copies of already-global keys merge into the global gate
324
544
  */
325
545
  async migrate(): Promise<void> {
326
546
  for (const store of this.scopes()) {
@@ -353,6 +573,112 @@ export class Stores {
353
573
  if (changed) await store.save()
354
574
  })
355
575
  }
576
+
577
+ // A key that reached the global store is global everywhere: merge any
578
+ // leftover project-local copy into the global gate so evidence does not
579
+ // fragment across scopes (stale local copies kept enforcing from the old
580
+ // scope while the global gate starved).
581
+ const projectStore = this.projectStore
582
+ if (projectStore) {
583
+ await projectStore.runLocked(async () => {
584
+ const projGates = await projectStore.load(true)
585
+ const globalKeys = new Set((await this.globalStore.load()).map((g) => g.key))
586
+ const dupes = projGates.filter((g) => globalKeys.has(g.key))
587
+ if (dupes.length === 0) return
588
+ await this.globalStore.runLocked(async () => {
589
+ const globalGates = await this.globalStore.load(true)
590
+ for (const dupe of dupes) {
591
+ const target = globalGates.find((g) => g.key === dupe.key)
592
+ if (target) {
593
+ mergeGate(target, dupe)
594
+ } else {
595
+ globalGates.push(dupe)
596
+ }
597
+ }
598
+ await this.globalStore.save()
599
+ })
600
+ projectStore.extract(new Set(dupes.map((g) => g.key)))
601
+ await projectStore.save()
602
+ })
603
+ }
604
+ }
605
+
606
+ /**
607
+ * Structural self-healing across both scopes plus index reconciliation.
608
+ * Idempotent; runs at plugin init and via `doctor --repair`.
609
+ */
610
+ async reconcileAll(globalProjects = 2): Promise<void> {
611
+ for (const store of this.scopes()) {
612
+ await store.reconcile()
613
+ }
614
+
615
+ // Index-driven escalation healing: a key proven in enough project dirs
616
+ // belongs in the global store even if recordFailure never moved it
617
+ // (racing instances, or stores that predate the index).
618
+ const projectStore = this.projectStore
619
+ if (projectStore) {
620
+ const index = await this.globalStore.loadIndex()
621
+ const toEscalate = (await projectStore.load(true)).filter((g) => {
622
+ const entry = index.keys[g.key]
623
+ return entry !== undefined && entry.projects.length >= globalProjects
624
+ })
625
+ if (toEscalate.length > 0) {
626
+ await projectStore.runLocked(async () => {
627
+ await this.globalStore.runLocked(async () => {
628
+ const globalGates = await this.globalStore.load(true)
629
+ for (const gate of toEscalate) {
630
+ const target = globalGates.find((g) => g.key === gate.key)
631
+ if (target) {
632
+ mergeGate(target, gate)
633
+ } else {
634
+ globalGates.push(gate)
635
+ }
636
+ }
637
+ await this.globalStore.save()
638
+ })
639
+ projectStore.extract(new Set(toEscalate.map((g) => g.key)))
640
+ await projectStore.save()
641
+ })
642
+ await this.globalStore.log({
643
+ type: "repaired",
644
+ key: "index.json",
645
+ snippet: `escalated ${toEscalate.length} gate(s) proven in ${globalProjects}+ project dirs`,
646
+ })
647
+ }
648
+ }
649
+
650
+ // The index must mirror reality: a key absent from every scope is an
651
+ // orphan (its gate expired or was deleted); a global gate missing from
652
+ // the index loses cross-project history. Heal both directions.
653
+ const knownKeys = new Set<string>()
654
+ for (const store of this.scopes()) {
655
+ for (const gate of await store.load(true)) knownKeys.add(gate.key)
656
+ }
657
+ await this.globalStore.runLockedIndex(async () => {
658
+ const index = await this.globalStore.loadIndex(true)
659
+ let pruned = 0
660
+ for (const key of Object.keys(index.keys)) {
661
+ if (!knownKeys.has(key)) {
662
+ delete index.keys[key]
663
+ pruned += 1
664
+ }
665
+ }
666
+ let rebuilt = 0
667
+ for (const gate of await this.globalStore.load(true)) {
668
+ if (!index.keys[gate.key]) {
669
+ index.keys[gate.key] = { projects: [...gate.projects], lastSeen: gate.lastSeen }
670
+ rebuilt += 1
671
+ }
672
+ }
673
+ if (pruned > 0 || rebuilt > 0) {
674
+ await this.globalStore.saveIndex()
675
+ await this.globalStore.log({
676
+ type: "repaired",
677
+ key: "index.json",
678
+ snippet: `pruned ${pruned} orphan key(s), rebuilt ${rebuilt} missing entr(y/ies)`,
679
+ })
680
+ }
681
+ })
356
682
  }
357
683
 
358
684
  async recordFailure(input: {
@@ -426,18 +752,41 @@ export class Stores {
426
752
 
427
753
  await store.save()
428
754
 
429
- // Scope escalation: a pattern seen in enough distinct project directories
430
- // is an agent-level habit, not a repo quirk — move it to the global store.
431
- // Lock order is always project -> global, so no deadlock.
432
- let wentGlobal = false
755
+ // Cross-project evidence lives in the global index: gate.projects only
756
+ // ever sees its own store's directory, so alone it can never reach two
757
+ // projects. A pattern seen in enough distinct project dirs is an
758
+ // agent-level habit, not a repo quirk — move it to the global store.
759
+ // Lock order is always gates -> index and project -> global: no cycles.
433
760
  const moved = gate
434
- if (store !== this.globalStore && this.projectStore && gate.projects.length >= input.globalProjects) {
761
+ const indexProjects = await this.globalStore.runLockedIndex(async () => {
762
+ const index = await this.globalStore.loadIndex(true)
763
+ let entry = index.keys[input.key]
764
+ if (!entry) {
765
+ entry = { projects: [], lastSeen: now }
766
+ index.keys[input.key] = entry
767
+ }
768
+ if (input.projectDir !== "" && !entry.projects.includes(input.projectDir)) {
769
+ entry.projects.push(input.projectDir)
770
+ if (entry.projects.length > MAX_PROJECTS) entry.projects = entry.projects.slice(-MAX_PROJECTS)
771
+ }
772
+ entry.lastSeen = now
773
+ await this.globalStore.saveIndex()
774
+ return entry.projects.length
775
+ })
776
+
777
+ let wentGlobal = false
778
+ if (store !== this.globalStore && this.projectStore && indexProjects >= input.globalProjects) {
435
779
  const idx = gates.findIndex((g) => g.key === moved.key)
436
780
  if (idx >= 0) gates.splice(idx, 1)
437
781
  await store.save()
438
782
  await this.globalStore.runLocked(async () => {
439
783
  const globalGates = await this.globalStore.load(true)
440
- if (!globalGates.some((g) => g.key === moved.key)) globalGates.push(moved)
784
+ const existing = globalGates.find((g) => g.key === moved.key)
785
+ if (existing) {
786
+ mergeGate(existing, moved)
787
+ } else {
788
+ globalGates.push(moved)
789
+ }
441
790
  await this.globalStore.save()
442
791
  })
443
792
  wentGlobal = true
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Invariant layer: strict parsing and repair of persisted gate state.
3
+ * Pure functions — no I/O. Used at the persistence boundary (store reconcile)
4
+ * and by diagnostics (doctor). Parse, don't validate: what survives
5
+ * coerceGateShape + repairGate satisfies the data-model invariants.
6
+ */
7
+ import type { Gate } from "./store"
8
+ import { canBlock, scrubSecrets } from "./patterns"
9
+
10
+ /** sha1 prefix-12, the only key shape patternKey ever emits */
11
+ const KEY_SHAPE = /^[0-9a-f]{12}$/
12
+ /** detection truncates snippets at 200 chars on ingest */
13
+ const SNIPPET_MAX = 200
14
+
15
+ /**
16
+ * Structural parse of one persisted gate object. Returns a well-shaped Gate
17
+ * or null when the record is hopeless (missing identity fields, unknown
18
+ * status) — hopeless records are dropped, not guessed at.
19
+ */
20
+ export function coerceGateShape(raw: unknown): Gate | null {
21
+ if (typeof raw !== "object" || raw === null) return null
22
+ const r = raw as Record<string, unknown>
23
+ if (typeof r.key !== "string" || !KEY_SHAPE.test(r.key)) return null
24
+ if (typeof r.signature !== "string" || r.signature.trim() === "") return null
25
+ if (typeof r.tool !== "string" || r.tool.trim() === "") return null
26
+ if (r.status !== "watching" && r.status !== "blocking") return null
27
+
28
+ const num = (v: unknown, fallback: number): number =>
29
+ typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.floor(v) : fallback
30
+ const strings = (v: unknown): string[] =>
31
+ Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []
32
+ const str = (v: unknown, fallback: string): string => (typeof v === "string" ? v : fallback)
33
+ const now = new Date().toISOString()
34
+
35
+ const gate: Gate = {
36
+ key: r.key,
37
+ signature: r.signature,
38
+ tool: r.tool,
39
+ status: r.status,
40
+ count: num(r.count, 0),
41
+ sessions: strings(r.sessions),
42
+ projects: strings(r.projects),
43
+ firstSeen: str(r.firstSeen, now),
44
+ lastSeen: str(r.lastSeen, now),
45
+ snippet: str(r.snippet, ""),
46
+ remindedCount: num(r.remindedCount, 0),
47
+ blockedCount: num(r.blockedCount, 0),
48
+ recurredAfterReminder: num(r.recurredAfterReminder, 0),
49
+ recurredAfterGate: num(r.recurredAfterGate, 0),
50
+ }
51
+ if (typeof r.correction === "string") gate.correction = r.correction
52
+ if (r.review === true) gate.review = true
53
+ return gate
54
+ }
55
+
56
+ /**
57
+ * In-place coercion of everything mechanically repairable. Returns true when
58
+ * anything changed. What it cannot repair (identity fields, hopeless shapes)
59
+ * is rejected earlier by coerceGateShape.
60
+ */
61
+ export function repairGate(gate: Gate): boolean {
62
+ let changed = false
63
+ if (gate.firstSeen > gate.lastSeen) {
64
+ const swap = gate.firstSeen
65
+ gate.firstSeen = gate.lastSeen
66
+ gate.lastSeen = swap
67
+ changed = true
68
+ }
69
+ if (gate.snippet.length > SNIPPET_MAX) {
70
+ gate.snippet = gate.snippet.slice(0, SNIPPET_MAX)
71
+ changed = true
72
+ }
73
+ const signature = scrubSecrets(gate.signature)
74
+ if (signature !== gate.signature) {
75
+ gate.signature = signature
76
+ changed = true
77
+ }
78
+ const snippet = scrubSecrets(gate.snippet)
79
+ if (snippet !== gate.snippet) {
80
+ gate.snippet = snippet
81
+ changed = true
82
+ }
83
+ if (gate.correction !== undefined) {
84
+ const correction = scrubSecrets(gate.correction)
85
+ if (correction !== gate.correction) {
86
+ gate.correction = correction
87
+ changed = true
88
+ }
89
+ }
90
+ // Policy is the single source of truth: a blocking gate that cannot block
91
+ // is a leftover from an older policy and must be demoted.
92
+ if (gate.status === "blocking" && !canBlock(gate.tool, gate.signature)) {
93
+ gate.status = "watching"
94
+ changed = true
95
+ }
96
+ return changed
97
+ }
98
+
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.
104
+ */
105
+ export function hasNestedTokens(signature: string): boolean {
106
+ return /<[a-z]+:\s*</.test(signature)
107
+ }