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 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 === "blocking") {
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
- if (fresh.failedSessions !== undefined && fresh.failedSessions[session] !== undefined) {
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 === "blocking" && !result.promoted) {
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
- if (fresh.remindedSessions?.[session] !== undefined) {
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.blockingGates()
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.4.0",
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`/`blockingGates`/`reconcileAll`; `mergeGate` merges duplicate keys |
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(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
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`/`blockingOnly`); mutations inside locks use `load(true)`; `save()` refreshes the cache directly
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.4.0"
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; blocking = gate is enforced */
16
- status: "watching" | "blocking"
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 blockingCache: Gate[] | null = null
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.blockingCache = gates.filter((g) => g.status === "blocking")
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.blockingCache = []
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 blocking subset — the fuzzy scan iterates this, not all gates. */
282
- blockingOnly(): Gate[] {
283
- if (this.blockingCache === null) {
284
- this.blockingCache = (this.gates ?? []).filter((g) => g.status === "blocking")
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.blockingCache
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/blockingCache hold references into this.gates, still valid.)
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 === "blocking" || g.count >= PROMOTE_COUNT ? ttlDays : noiseTtlDays
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.blockingCache = null
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.blockingCache = null
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.blockingCache = null
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.blockingOnly()) {
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 blockingGates(): Promise<Gate[]> {
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.blockingOnly()) result.push(gate)
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) && gate.status === "blocking") {
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: only bash non-diagnostic commands may ever become gates.
922
- if (
923
- gate.status === "watching" &&
924
- canBlock(gate.tool, gate.signature) &&
925
- gate.count >= threshold &&
926
- gate.sessions.length >= PROMOTE_SESSIONS
927
- ) {
928
- gate.status = "blocking"
929
- promoted = true
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
- if (store !== this.globalStore && this.projectStore && indexProjects >= input.globalProjects) {
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: a blocking gate that cannot block
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
  }