opencode-dejavu 2.4.0 → 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 +10 -0
- package/index.ts +9 -5
- package/package.json +1 -1
- package/src/AGENTS.md +8 -5
- package/src/patterns.ts +54 -1
- package/src/store.ts +60 -33
- package/src/validate.ts +9 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
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
|
+
|
|
3
13
|
## 2.4.0 — 2026-08-24
|
|
4
14
|
|
|
5
15
|
### Added
|
package/index.ts
CHANGED
|
@@ -129,7 +129,7 @@ export const Dejavu: Plugin = async ({ directory, client }) => {
|
|
|
129
129
|
for (let i = 0; i < candidates.length; i++) {
|
|
130
130
|
const sig = candidates[i] ?? ""
|
|
131
131
|
const match = await stores.findGate(patternKey(sig), sig)
|
|
132
|
-
if (match && match.gate.status
|
|
132
|
+
if (match && match.gate.status !== "watching") {
|
|
133
133
|
found = { gate: match.gate, store: match.store, via: i > 0 && match.via === "exact" ? "segment" : match.via }
|
|
134
134
|
break
|
|
135
135
|
}
|
|
@@ -182,7 +182,9 @@ export const Dejavu: Plugin = async ({ directory, client }) => {
|
|
|
182
182
|
if (fresh === undefined) return // gate deleted between find and lock
|
|
183
183
|
|
|
184
184
|
// Repeat offense: reminded, retried, failed again -> hard block.
|
|
185
|
-
|
|
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) {
|
|
186
188
|
fresh.blockedCount += 1
|
|
187
189
|
if (fresh.blockedCount >= REVIEW_FIRES) fresh.review = true
|
|
188
190
|
await target.store.save()
|
|
@@ -302,13 +304,15 @@ export const Dejavu: Plugin = async ({ directory, client }) => {
|
|
|
302
304
|
let changed = false
|
|
303
305
|
// Metric: failure of an already-enforced pattern (the event that
|
|
304
306
|
// promoted the gate does not count — the gate did not exist yet).
|
|
305
|
-
if (fresh.status
|
|
307
|
+
if (fresh.status !== "watching" && !result.promoted) {
|
|
306
308
|
fresh.recurredAfterGate += 1
|
|
307
309
|
changed = true
|
|
308
310
|
await stores.logAll({ type: "recurred-after-gate", key: fresh.key, tool: input.tool, session, project: directory })
|
|
309
311
|
}
|
|
310
312
|
// Same-session repeat after a reminder -> escalate to hard block.
|
|
311
|
-
|
|
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) {
|
|
312
316
|
if (fresh.failedSessions === undefined) fresh.failedSessions = {}
|
|
313
317
|
fresh.failedSessions[session] = Date.now()
|
|
314
318
|
fresh.recurredAfterReminder += 1
|
|
@@ -410,7 +414,7 @@ export const Dejavu: Plugin = async ({ directory, client }) => {
|
|
|
410
414
|
|
|
411
415
|
"experimental.session.compacting": async (_input, output) => {
|
|
412
416
|
try {
|
|
413
|
-
const gates = await stores.
|
|
417
|
+
const gates = await stores.enforcedGates()
|
|
414
418
|
if (gates.length === 0) return
|
|
415
419
|
const lines = gates
|
|
416
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[]
|
|
@@ -199,7 +200,7 @@ export class GateStore {
|
|
|
199
200
|
/** hot-path caches: valid until LOAD_CACHE_TTL_MS / invalidated on mutation */
|
|
200
201
|
private cacheUntilMs = 0
|
|
201
202
|
private keyIndex: Map<string, Gate> | null = null
|
|
202
|
-
private
|
|
203
|
+
private enforcedCache: Gate[] | null = null
|
|
203
204
|
private index: IndexFile | null = null
|
|
204
205
|
private indexMtimeMs = 0
|
|
205
206
|
|
|
@@ -254,7 +255,7 @@ export class GateStore {
|
|
|
254
255
|
}
|
|
255
256
|
this.gates = gates
|
|
256
257
|
this.keyIndex = new Map(gates.map((g) => [g.key, g]))
|
|
257
|
-
this.
|
|
258
|
+
this.enforcedCache = gates.filter((g) => g.status !== "watching")
|
|
258
259
|
this.mtimeMs = info.mtimeMs
|
|
259
260
|
this.cacheUntilMs = Date.now() + LOAD_CACHE_TTL_MS
|
|
260
261
|
return this.gates
|
|
@@ -263,7 +264,7 @@ export class GateStore {
|
|
|
263
264
|
if (this.gates === null) {
|
|
264
265
|
this.gates = []
|
|
265
266
|
this.keyIndex = new Map()
|
|
266
|
-
this.
|
|
267
|
+
this.enforcedCache = []
|
|
267
268
|
}
|
|
268
269
|
this.cacheUntilMs = Date.now() + LOAD_CACHE_TTL_MS
|
|
269
270
|
return this.gates
|
|
@@ -278,12 +279,12 @@ export class GateStore {
|
|
|
278
279
|
return this.keyIndex.get(key)
|
|
279
280
|
}
|
|
280
281
|
|
|
281
|
-
/** Cached
|
|
282
|
-
|
|
283
|
-
if (this.
|
|
284
|
-
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")
|
|
285
286
|
}
|
|
286
|
-
return this.
|
|
287
|
+
return this.enforcedCache
|
|
287
288
|
}
|
|
288
289
|
|
|
289
290
|
async save(): Promise<void> {
|
|
@@ -297,7 +298,7 @@ export class GateStore {
|
|
|
297
298
|
// mtime refresh is best-effort
|
|
298
299
|
}
|
|
299
300
|
// We know the content we just wrote — refresh the TTL cache directly.
|
|
300
|
-
// (keyIndex/
|
|
301
|
+
// (keyIndex/enforcedCache hold references into this.gates, still valid.)
|
|
301
302
|
this.cacheUntilMs = Date.now() + LOAD_CACHE_TTL_MS
|
|
302
303
|
}
|
|
303
304
|
|
|
@@ -360,14 +361,14 @@ export class GateStore {
|
|
|
360
361
|
const gates = await this.load(true)
|
|
361
362
|
const now = Date.now()
|
|
362
363
|
const expired = gates.filter((g) => {
|
|
363
|
-
const ttl = g.status
|
|
364
|
+
const ttl = g.status !== "watching" || g.count >= PROMOTE_COUNT ? ttlDays : noiseTtlDays
|
|
364
365
|
return Date.parse(g.lastSeen) < now - ttl * DAY_MS
|
|
365
366
|
})
|
|
366
367
|
if (expired.length === 0) return []
|
|
367
368
|
const expiredKeys = new Set(expired.map((g) => g.key))
|
|
368
369
|
this.gates = gates.filter((g) => !expiredKeys.has(g.key))
|
|
369
370
|
this.keyIndex = null
|
|
370
|
-
this.
|
|
371
|
+
this.enforcedCache = null
|
|
371
372
|
await this.save()
|
|
372
373
|
return expired
|
|
373
374
|
}
|
|
@@ -379,7 +380,7 @@ export class GateStore {
|
|
|
379
380
|
if (removed.length > 0) {
|
|
380
381
|
this.gates = this.gates.filter((g) => !keys.has(g.key))
|
|
381
382
|
this.keyIndex = null
|
|
382
|
-
this.
|
|
383
|
+
this.enforcedCache = null
|
|
383
384
|
}
|
|
384
385
|
return removed
|
|
385
386
|
}
|
|
@@ -429,7 +430,7 @@ export class GateStore {
|
|
|
429
430
|
await unlink(ntPath(this.gatesPath))
|
|
430
431
|
this.gates = []
|
|
431
432
|
this.keyIndex = null
|
|
432
|
-
this.
|
|
433
|
+
this.enforcedCache = null
|
|
433
434
|
this.mtimeMs = 0
|
|
434
435
|
await this.save()
|
|
435
436
|
await this.log({ type: "quarantined", key: "gates.json", snippet: `unparseable gates file quarantined (scrubbed) to ${quarantine}` })
|
|
@@ -582,7 +583,7 @@ export class Stores {
|
|
|
582
583
|
if (signature.length > FUZZY_MAX_LEN) return null
|
|
583
584
|
let best: { gate: Gate; store: GateStore; score: number } | null = null
|
|
584
585
|
for (const store of this.scopes()) {
|
|
585
|
-
for (const gate of store.
|
|
586
|
+
for (const gate of store.enforcedOnly()) {
|
|
586
587
|
if (!fuzzySimilar(signature, gate.signature)) continue
|
|
587
588
|
const score = Math.abs(signature.length - gate.signature.length)
|
|
588
589
|
if (best === null || score < best.score) best = { gate, store, score }
|
|
@@ -591,12 +592,12 @@ export class Stores {
|
|
|
591
592
|
return best === null ? null : { gate: best.gate, store: best.store, via: "fuzzy" }
|
|
592
593
|
}
|
|
593
594
|
|
|
594
|
-
/** All currently enforced gates, project scope first, highest-count first. */
|
|
595
|
-
async
|
|
595
|
+
/** All currently enforced gates (blocking + reminding), project scope first, highest-count first. */
|
|
596
|
+
async enforcedGates(): Promise<Gate[]> {
|
|
596
597
|
const result: Gate[] = []
|
|
597
598
|
for (const store of this.scopes()) {
|
|
598
599
|
await store.load()
|
|
599
|
-
for (const gate of store.
|
|
600
|
+
for (const gate of store.enforcedOnly()) result.push(gate)
|
|
600
601
|
}
|
|
601
602
|
return result.sort((a, b) => b.count - a.count)
|
|
602
603
|
}
|
|
@@ -681,10 +682,27 @@ export class Stores {
|
|
|
681
682
|
const gates = await store.load(true)
|
|
682
683
|
let changed = false
|
|
683
684
|
for (const gate of gates) {
|
|
684
|
-
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)) {
|
|
685
692
|
gate.status = "watching"
|
|
686
693
|
changed = true
|
|
687
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
|
+
}
|
|
688
706
|
const signature = scrubSecrets(gate.signature)
|
|
689
707
|
const snippet = scrubSecrets(gate.snippet)
|
|
690
708
|
if (signature !== gate.signature) {
|
|
@@ -753,7 +771,7 @@ export class Stores {
|
|
|
753
771
|
const index = await this.globalStore.loadIndex()
|
|
754
772
|
const toEscalate = (await projectStore.load(true)).filter((g) => {
|
|
755
773
|
const entry = index.keys[g.key]
|
|
756
|
-
return entry !== undefined && entry.projects.length >= globalProjects
|
|
774
|
+
return entry !== undefined && entry.projects.length >= globalProjects && !isRepoLocal(g.signature)
|
|
757
775
|
})
|
|
758
776
|
if (toEscalate.length > 0) {
|
|
759
777
|
await projectStore.runLocked(async () => {
|
|
@@ -918,15 +936,16 @@ export class Stores {
|
|
|
918
936
|
|
|
919
937
|
let promoted = false
|
|
920
938
|
const threshold = PROBE_TOOLS.has(input.tool) ? PROMOTE_COUNT_PROBE : PROMOTE_COUNT
|
|
921
|
-
// Policy:
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
canBlock(gate.tool, gate.signature)
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
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
|
+
}
|
|
930
949
|
}
|
|
931
950
|
|
|
932
951
|
await store.save()
|
|
@@ -954,7 +973,15 @@ export class Stores {
|
|
|
954
973
|
})
|
|
955
974
|
|
|
956
975
|
let wentGlobal = false
|
|
957
|
-
|
|
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
|
+
) {
|
|
958
985
|
// Global FIRST, then remove the local copy: a crash between the two
|
|
959
986
|
// writes must leave a duplicate (healed by migrate), never a hole.
|
|
960
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
|
}
|