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 +22 -0
- package/README.md +10 -5
- package/index.ts +10 -3
- package/package.json +1 -1
- package/src/AGENTS.md +14 -4
- package/src/patterns.ts +66 -3
- package/src/store.ts +368 -19
- package/src/validate.ts +107 -0
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;
|
|
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
|
|
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,
|
|
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.
|
|
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)` =
|
|
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
|
-
|
|
57
|
-
s = s.replace(
|
|
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
|
-
|
|
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.
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/src/validate.ts
ADDED
|
@@ -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
|
+
}
|