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