opencode-dejavu 2.7.0 → 2.27.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/package.json CHANGED
@@ -1,12 +1,15 @@
1
1
  {
2
2
  "name": "opencode-dejavu",
3
- "version": "2.7.0",
3
+ "version": "2.27.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",
7
7
  "files": [
8
8
  "index.ts",
9
9
  "src",
10
+ "scripts",
11
+ "command",
12
+ "skills",
10
13
  "LICENSE",
11
14
  "README.md",
12
15
  "CHANGELOG.md"
@@ -28,7 +31,8 @@
28
31
  "homepage": "https://github.com/WhiteBite/opencode-dejavu",
29
32
  "bugs": "https://github.com/WhiteBite/opencode-dejavu/issues",
30
33
  "scripts": {
31
- "typecheck": "tsc --noEmit"
34
+ "typecheck": "tsc --noEmit",
35
+ "test": "bun test/smoke.ts"
32
36
  },
33
37
  "license": "MIT",
34
38
  "devDependencies": {
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Read-only summary of dejavu stores: statuses, tools, recurrence health,
3
+ * top patterns.
4
+ *
5
+ * Usage: bun scripts/analyze.ts [projectDir ...]
6
+ * without arguments, project stores are discovered from the global index
7
+ */
8
+ import { existsSync } from "node:fs"
9
+ import { homedir } from "node:os"
10
+ import { join } from "node:path"
11
+ import { GateStore } from "../src/store"
12
+
13
+ const globalDir = process.env.DEJAVU_HOME ?? join(homedir(), ".config", "opencode", "dejavu")
14
+ let projectArgs = process.argv.slice(2)
15
+ if (projectArgs.length === 0) {
16
+ const discovered = new Set<string>()
17
+ const index = await new GateStore(globalDir).loadIndex()
18
+ for (const entry of Object.values(index.keys)) {
19
+ if (!entry || !Array.isArray(entry.projects)) continue
20
+ for (const project of entry.projects) {
21
+ if (typeof project === "string" && existsSync(join(project, ".opencode", "dejavu"))) discovered.add(project)
22
+ }
23
+ }
24
+ projectArgs = [...discovered].sort()
25
+ }
26
+ const dirs = [globalDir, ...projectArgs.map((p) => join(p, ".opencode", "dejavu"))]
27
+
28
+ for (const dir of dirs) {
29
+ const store = new GateStore(dir)
30
+ const gates = await store.load()
31
+ console.log(`\n== ${dir}`)
32
+ if (gates.length === 0) {
33
+ console.log(" (empty)")
34
+ continue
35
+ }
36
+ const blocking = gates.filter((g) => g.status === "blocking")
37
+ const reminding = gates.filter((g) => g.status === "reminding")
38
+ const watching = gates.filter((g) => g.status === "watching")
39
+ const feedbackDemoted = gates.filter((g) => g.feedbackDemoted === true)
40
+ const byTool = new Map<string, number>()
41
+ for (const g of gates) byTool.set(g.tool, (byTool.get(g.tool) ?? 0) + 1)
42
+ console.log(
43
+ ` total ${gates.length} | blocking ${blocking.length} | reminding ${reminding.length} | watching ${watching.length}${feedbackDemoted.length > 0 ? ` | feedback-demoted ${feedbackDemoted.length}` : ""} | tools: ${[...byTool.entries()].map(([t, n]) => `${t}:${n}`).join(" ")}`,
44
+ )
45
+
46
+ const recurred = gates.filter((g) => g.recurredAfterGate > 0).sort((a, b) => b.recurredAfterGate - a.recurredAfterGate)
47
+ console.log(` gates with recurredAfterGate > 0: ${recurred.length}`)
48
+ for (const g of recurred.slice(0, 8)) {
49
+ console.log(
50
+ ` - recurred ${g.recurredAfterGate} | reminded ${g.remindedCount} | blocked ${g.blockedCount} | ${g.signature}`,
51
+ )
52
+ }
53
+
54
+ const top = [...gates].sort((a, b) => b.count - a.count).slice(0, 8)
55
+ console.log(" top by count:")
56
+ for (const g of top) {
57
+ console.log(` - ${g.count}x / ${g.sessions.length} sess [${g.status}] ${g.signature}`)
58
+ }
59
+ }
@@ -0,0 +1,505 @@
1
+ /**
2
+ * One-command pathology report for dejavu stores. Checks every invariant the
3
+ * data model implies, so debugging starts from facts, not guesses.
4
+ *
5
+ * Usage: bun scripts/doctor.ts [--repair] [projectDir ...]
6
+ * --repair heal first (reconcile + migrate), then report
7
+ */
8
+ import { existsSync } from "node:fs"
9
+ import { readFile, readdir, stat } from "node:fs/promises"
10
+ import { homedir } from "node:os"
11
+ import { join } from "node:path"
12
+ import { canBlock, canRemind, isRepoLocal, sanitizeForStore } from "../src/patterns"
13
+ import { DEMOTE_RECURRENCES, GateStore, GLOBAL_PROJECTS, MAX_GATES, NOISE_TTL_DAYS, Stores, PLUGIN_VERSION, PROMOTE_SESSIONS, TTL_DAYS, type Gate } from "../src/store"
14
+ import { coerceGateShape, hasNestedTokens } from "../src/validate"
15
+
16
+ const repair = process.argv.includes("--repair")
17
+ const globalDir = process.env.DEJAVU_HOME ?? join(homedir(), ".config", "opencode", "dejavu")
18
+
19
+ // Cross-store invariants need every scope visible. Without explicit args,
20
+ // discover project dirs from the global index — it is the only registry of
21
+ // which projects dejavu has seen. (Running global-only produced hundreds of
22
+ // false "index orphans": keys whose gates live in project stores.)
23
+ let projectDirs = process.argv.slice(2).filter((a) => a !== "--repair")
24
+ if (projectDirs.length === 0) {
25
+ const discovered = new Set<string>()
26
+ const index = await new GateStore(globalDir).loadIndex()
27
+ for (const entry of Object.values(index.keys)) {
28
+ if (!entry || !Array.isArray(entry.projects)) continue
29
+ for (const project of entry.projects) {
30
+ if (typeof project === "string" && existsSync(join(project, ".opencode", "dejavu"))) discovered.add(project)
31
+ }
32
+ }
33
+ projectDirs = [...discovered].sort()
34
+ if (projectDirs.length > 0) console.log(`discovered ${projectDirs.length} project store(s) from the global index`)
35
+ }
36
+
37
+ // --- repair pass (idempotent): heal structure, then policy ---
38
+ if (repair) {
39
+ const targets = projectDirs.length > 0 ? projectDirs : [""]
40
+ for (const dir of targets) {
41
+ const global = new GateStore(globalDir)
42
+ const project = dir === "" ? null : new GateStore(join(dir, ".opencode", "dejavu"))
43
+ const stores = new Stores(global, project)
44
+ await stores.reconcileAll()
45
+ await stores.migrate(true)
46
+ // Sweep expired gates too, otherwise the report shows gates that should
47
+ // already be gone (reconcile+migrate alone don't expire).
48
+ await stores.expireAll(TTL_DAYS, NOISE_TTL_DAYS)
49
+ // The script exits after this — flush deferred repair/quarantine/demotion
50
+ // events now, or they are silently lost ("every repair is logged" invariant).
51
+ await global.flushDeferred()
52
+ if (project) await project.flushDeferred()
53
+ console.log(`repaired: ${dir === "" ? "(global only)" : dir}`)
54
+ }
55
+ // True-orphan pruning is safe HERE and only here: doctor sees EVERY
56
+ // discovered scope at once, unlike a single plugin process — which must
57
+ // never prune (the gate may live in another project's store it cannot see).
58
+ // load() is non-force on purpose: load(true) could quarantine an
59
+ // unparseable file WITHOUT the store lock while the live plugin is running.
60
+ const knownKeys = new Set<string>()
61
+ for (const g of await new GateStore(globalDir).load()) knownKeys.add(g.key)
62
+ for (const dir of projectDirs) {
63
+ for (const g of await new GateStore(join(dir, ".opencode", "dejavu")).load()) knownKeys.add(g.key)
64
+ }
65
+ const idxStore = new GateStore(globalDir)
66
+ let pruned = 0
67
+ await idxStore.runLockedIndex(async () => {
68
+ const idx = await idxStore.loadIndex(true)
69
+ for (const key of Object.keys(idx.keys)) {
70
+ if (!knownKeys.has(key)) {
71
+ delete idx.keys[key]
72
+ pruned += 1
73
+ }
74
+ }
75
+ if (pruned > 0) await idxStore.saveIndex()
76
+ })
77
+ // Log OUTSIDE the index lock (the log lock is the most-contended lock).
78
+ if (pruned > 0) {
79
+ await idxStore.log({ type: "repaired", key: "index.json", snippet: `doctor pruned ${pruned} true-orphan key(s) across all visible scopes` })
80
+ console.log(`pruned ${pruned} true-orphan index key(s)`)
81
+ }
82
+ }
83
+
84
+ // --- report pass ---
85
+ interface Scope {
86
+ dir: string
87
+ isGlobal: boolean
88
+ gates: Gate[]
89
+ /** raw records from disk, for strict-parse checks */
90
+ records: unknown[]
91
+ rawState: "missing" | "unparseable" | "ok"
92
+ corruptLogLines: number
93
+ degradedEvents: number
94
+ floodEvictions: number
95
+ doubleCounts: number
96
+ quarantineBytes: number
97
+ quarantineFiles: number
98
+ /** keys promoted ≥2 AND resolved (healed/retired-*) ≥2 times — oscillation */
99
+ flappy: Array<{ key: string; promoted: number; resolved: number }>
100
+ /** version of the process that last SAVED gates.json — durable drift signal */
101
+ lastInitVersion: string | null
102
+ }
103
+
104
+ async function loadScope(dir: string, isGlobal: boolean): Promise<Scope> {
105
+ let rawState: Scope["rawState"] = "missing"
106
+ let records: unknown[] = []
107
+ let lastInitVersion: string | null = null
108
+ try {
109
+ const raw = await readFile(join(dir, "gates.json"), "utf8")
110
+ if (raw.trim() !== "") {
111
+ try {
112
+ const parsed = JSON.parse(raw) as { gates?: unknown; lastInitVersion?: unknown }
113
+ rawState = "ok"
114
+ records = Array.isArray(parsed.gates) ? parsed.gates : []
115
+ if (typeof parsed.lastInitVersion === "string") lastInitVersion = parsed.lastInitVersion
116
+ } catch {
117
+ rawState = "unparseable"
118
+ }
119
+ }
120
+ } catch {
121
+ rawState = "missing"
122
+ }
123
+ let corruptLogLines = 0
124
+ let degradedEvents = 0
125
+ let floodEvictions = 0
126
+ // Cross-channel double-count monitor: the same failure recorded by two
127
+ // channels (exit/text AND event) within a short window inflates counts and
128
+ // demotion math. Latent today (channels are disjoint by construction); if
129
+ // upstream ever emits both, this surfaces it.
130
+ const recentDetections = new Map<string, { ts: number; channel: string }>()
131
+ let doubleCounts = 0
132
+ // FLAPPY monitor: promote→heal/retire oscillation. `count`/`sessions` are
133
+ // lifetime-cumulative (not in the lifecycle-reset list), so a healed or
134
+ // retired gate re-promotes on the very next single failure — in flaky
135
+ // environments that is promote→heal→promote forever. Data-gathering only;
136
+ // damping is not justified until this report shows it matters.
137
+ const transitions = new Map<string, { promoted: number; resolved: number }>()
138
+ try {
139
+ const raw = await readFile(join(dir, "log.jsonl"), "utf8")
140
+ for (const line of raw.split("\n")) {
141
+ if (line.trim() === "") continue
142
+ if (line.includes('"type":"degraded"')) degradedEvents++
143
+ if (line.includes("flood guard evicted")) floodEvictions++
144
+ if (
145
+ line.includes('"type":"promoted"') ||
146
+ line.includes('"type":"healed"') ||
147
+ line.includes('"type":"retired-healed"') ||
148
+ line.includes('"type":"retired-taught"')
149
+ ) {
150
+ try {
151
+ const e = JSON.parse(line) as { type?: string; key?: string }
152
+ if (e.key !== undefined && typeof e.type === "string") {
153
+ const t = transitions.get(e.key) ?? { promoted: 0, resolved: 0 }
154
+ if (e.type === "promoted") t.promoted += 1
155
+ else t.resolved += 1
156
+ transitions.set(e.key, t)
157
+ }
158
+ } catch {
159
+ // counted as corrupt below if unparseable
160
+ }
161
+ }
162
+ if (line.includes('"type":"detected"') && line.includes('"channel"')) {
163
+ try {
164
+ const e = JSON.parse(line) as { ts?: string; key?: string; session?: string; channel?: string }
165
+ if (e.key !== undefined && e.channel !== undefined && e.ts !== undefined) {
166
+ const ts = Date.parse(e.ts)
167
+ const k = `${e.key}|${e.session ?? ""}`
168
+ const prev = recentDetections.get(k)
169
+ if (prev !== undefined && prev.channel !== e.channel && Math.abs(ts - prev.ts) <= 2000) {
170
+ doubleCounts++
171
+ } else {
172
+ recentDetections.set(k, { ts, channel: e.channel })
173
+ }
174
+ }
175
+ } catch {
176
+ // counted as corrupt below if unparseable
177
+ }
178
+ }
179
+ try {
180
+ JSON.parse(line)
181
+ } catch {
182
+ corruptLogLines++
183
+ }
184
+ }
185
+ } catch {
186
+ // no log yet
187
+ }
188
+ // Quarantine artifacts: append-only, never rotated — surface their size so
189
+ // repeated corruption (the pathology this system exists for) is visible.
190
+ let quarantineBytes = 0
191
+ let quarantineFiles = 0
192
+ try {
193
+ for (const name of await readdir(dir)) {
194
+ if (!name.includes(".corrupt")) continue
195
+ try {
196
+ quarantineBytes += (await stat(join(dir, name))).size
197
+ quarantineFiles++
198
+ } catch {
199
+ // vanished between readdir and stat
200
+ }
201
+ }
202
+ } catch {
203
+ // dir unreadable
204
+ }
205
+ const flappy = [...transitions.entries()]
206
+ .filter(([, t]) => t.promoted >= 2 && t.resolved >= 2)
207
+ .map(([key, t]) => ({ key, promoted: t.promoted, resolved: t.resolved }))
208
+ const store = new GateStore(dir)
209
+ return {
210
+ dir,
211
+ isGlobal,
212
+ gates: await store.load(),
213
+ records,
214
+ rawState,
215
+ corruptLogLines,
216
+ degradedEvents,
217
+ floodEvictions,
218
+ doubleCounts,
219
+ quarantineBytes,
220
+ quarantineFiles,
221
+ flappy,
222
+ lastInitVersion,
223
+ }
224
+ }
225
+
226
+ const scopes: Scope[] = [await loadScope(globalDir, true)]
227
+ for (const p of projectDirs) scopes.push(await loadScope(join(p, ".opencode", "dejavu"), false))
228
+
229
+ const allKeys = new Set(scopes.flatMap((s) => s.gates.map((g) => g.key)))
230
+ const keySignature = new Map(scopes.flatMap((s) => s.gates.map((g) => [g.key, g.signature] as const)))
231
+ const globalScope = scopes[0] as Scope
232
+ const globalKeys = new Set(globalScope.gates.map((g) => g.key))
233
+ const index = await new GateStore(globalDir).loadIndex()
234
+
235
+ let issues = 0
236
+ for (const scope of scopes) {
237
+ console.log(`\n== ${scope.dir}`)
238
+
239
+ if (scope.rawState === "unparseable") {
240
+ issues++
241
+ console.log(" UNPARSEABLE gates.json — run doctor --repair (file is quarantined, bytes kept)")
242
+ }
243
+ const badRecords = scope.records.filter((r) => coerceGateShape(r) === null).length
244
+ if (badRecords > 0) {
245
+ issues++
246
+ console.log(` BAD GATE RECORDS (${badRecords}) — fail strict parse; doctor --repair drops them`)
247
+ }
248
+
249
+ const gates = scope.gates
250
+ if (gates.length === 0 && scope.rawState !== "unparseable" && badRecords === 0) console.log(" (empty)")
251
+ if (gates.length > 0) {
252
+ console.log(` gates: ${gates.length}/${MAX_GATES}${gates.length >= MAX_GATES * 0.8 ? ` — NEAR CAPACITY (flood guard will start evicting watching gates)` : ""}`)
253
+ if (gates.length >= MAX_GATES * 0.8) issues++
254
+ }
255
+ if (scope.floodEvictions > 0) {
256
+ console.log(` note: FLOOD EVICTIONS (${scope.floodEvictions}) — watching gates evicted to stay at the cap (evidence lost)`)
257
+ }
258
+ if (scope.quarantineFiles > 0) {
259
+ console.log(` note: QUARANTINE ARTIFACTS (${scope.quarantineFiles} file(s), ${(scope.quarantineBytes / 1024).toFixed(1)} KB) — inspect, then safe to delete`)
260
+ }
261
+ if (scope.doubleCounts > 0) {
262
+ issues++
263
+ console.log(` CROSS-CHANNEL DOUBLE-COUNT (${scope.doubleCounts}) — the same failure recorded by two channels within 2s; upstream changed, dedup needed`)
264
+ }
265
+
266
+ if (scope.flappy.length > 0) {
267
+ console.log(` note: FLAPPY (${scope.flappy.length}) — promoted 2+ AND resolved 2+ times; promote→heal oscillation (data-gathering; damping not yet justified):`)
268
+ for (const f of scope.flappy.slice(0, 10)) console.log(` - promoted ${f.promoted} | resolved ${f.resolved} | ${keySignature.get(f.key) ?? f.key}`)
269
+ }
270
+
271
+ // FLAPPY escalation: the log-based FLAPPY above rots with rotation, but
272
+ // promotionCount is lifetime (never reset) — 3+ promotions means the gate
273
+ // retired and re-promoted at least twice. That is proven oscillation:
274
+ // report-only (no mechanical auto-demotion) — review, delete, or correct it.
275
+ const flappyLifetime = gates.filter((g) => (g.promotionCount ?? 0) >= 3)
276
+ if (flappyLifetime.length > 0) {
277
+ issues += flappyLifetime.length
278
+ console.log(` FLAPPY promotionCount>=3 (${flappyLifetime.length}) — promoted 3+ times over the gate's lifetime (promote→retire→promote oscillation); review, delete, or rewrite the correction:`)
279
+ for (const g of flappyLifetime.slice(0, 10)) console.log(` - promoted ${g.promotionCount}x | ${g.signature}`)
280
+ }
281
+
282
+ const seen = new Set<string>()
283
+ let dupes = 0
284
+ for (const g of gates) {
285
+ if (seen.has(g.key)) dupes++
286
+ seen.add(g.key)
287
+ }
288
+ if (dupes > 0) {
289
+ issues++
290
+ console.log(` DUPLICATE KEYS (${dupes}) — doctor --repair merges them`)
291
+ }
292
+
293
+ const inverted = gates.filter((g) => g.firstSeen > g.lastSeen)
294
+ if (inverted.length > 0) {
295
+ issues++
296
+ console.log(` TEMPORAL INVERSION firstSeen>lastSeen (${inverted.length}) — doctor --repair swaps them`)
297
+ }
298
+
299
+ const nested = gates.filter((g) => hasNestedTokens(g.signature))
300
+ if (nested.length > 0) {
301
+ issues += nested.length
302
+ console.log(` NESTED TOKEN corruption (${nested.length}) — a placeholder re-parameterized another token; delete these gates:`)
303
+ for (const g of nested.slice(0, 10)) console.log(` - ${g.signature}`)
304
+ }
305
+
306
+ const blockingNoEvidence = gates.filter((g) => g.status !== "watching" && g.sessions.length < PROMOTE_SESSIONS)
307
+ if (blockingNoEvidence.length > 0) {
308
+ issues += blockingNoEvidence.length
309
+ console.log(` ENFORCED WITHOUT EVIDENCE sessions<${PROMOTE_SESSIONS} (${blockingNoEvidence.length}) — promoted outside policy (hand-edited?):`)
310
+ for (const g of blockingNoEvidence.slice(0, 10)) console.log(` - ${g.signature}`)
311
+ }
312
+
313
+ const staleBlocking = gates.filter((g) => g.status === "blocking" && !canBlock(g.tool, g.signature))
314
+ if (staleBlocking.length > 0) {
315
+ issues += staleBlocking.length
316
+ console.log(` STALE BLOCKING outside policy (${staleBlocking.length}) — doctor --repair demotes:`)
317
+ for (const g of staleBlocking.slice(0, 10)) console.log(` - ${g.signature}`)
318
+ }
319
+
320
+ const staleReminding = gates.filter((g) => g.status === "reminding" && !canRemind(g.tool, g.signature))
321
+ if (staleReminding.length > 0) {
322
+ issues += staleReminding.length
323
+ console.log(` STALE REMINDING outside policy (${staleReminding.length}) — doctor --repair demotes to watching:`)
324
+ for (const g of staleReminding.slice(0, 10)) console.log(` - ${g.signature}`)
325
+ }
326
+
327
+ // Gates that could never teach — enforced gates whose error recurs despite
328
+ // enforcement. Relative to feedbackBaseline: a human re-enforcement gets a
329
+ // fresh grace window, stale pre-demotion recurrences must not re-flag it.
330
+ // Flag only blocking gates: a recurring reminding/diagnostic gate is normal iteration (tests fail while the agent works); a recurring BLOCKING gate means the correction isn't working. Watching gates already surrendered to feedback demotion.
331
+ const notTeaching = gates.filter(
332
+ (g) =>
333
+ g.status === "blocking" &&
334
+ g.recurredAfterGate - (g.feedbackBaseline?.recurred ?? 0) >= DEMOTE_RECURRENCES &&
335
+ canBlock(g.tool, g.signature),
336
+ )
337
+ if (notTeaching.length > 0) {
338
+ issues += notTeaching.length
339
+ console.log(` NOT TEACHING recurredAfterGate>=${DEMOTE_RECURRENCES} (${notTeaching.length}) — gate fires but error recurs; write a correction or delete:`)
340
+ for (const g of notTeaching.slice(0, 10)) console.log(` - recurred ${g.recurredAfterGate - (g.feedbackBaseline?.recurred ?? 0)} | ${g.signature}`)
341
+ }
342
+
343
+ const feedbackDemoted = gates.filter((g) => g.feedbackDemoted === true)
344
+ if (feedbackDemoted.length > 0) {
345
+ console.log(` note: FEEDBACK-DEMOTED (${feedbackDemoted.length}) — agent behavior (recurrences/overrides) retired these; re-enforce by setting status back to blocking/reminding AND clearing feedbackDemoted in gates.json`)
346
+ }
347
+
348
+ // False-positive votes: every override is the agent explicitly saying "this
349
+ // gate is wrong / friction, let me through" (dejavu:proceed). A gate that is
350
+ // STILL enforced while accumulating overrides is a live false positive — the
351
+ // strongest signal that dejavu is nagging on something that isn't a mistake.
352
+ const overridden = gates.filter((g) => (g.overrideCount ?? 0) > 0)
353
+ if (overridden.length > 0) {
354
+ const live = overridden.filter((g) => g.status !== "watching")
355
+ if (live.length > 0) issues += live.length
356
+ console.log(` OVERRIDDEN (${overridden.length}, of which ${live.length} still enforced) — agent voted these false-positive via dejavu:proceed; still-enforced ones are live friction:`)
357
+ for (const g of [...overridden].sort((a, b) => (b.overrideCount ?? 0) - (a.overrideCount ?? 0)).slice(0, 10))
358
+ console.log(` - override x${g.overrideCount} [${g.status}] ${g.signature}`)
359
+ }
360
+
361
+ // Positive signal: correction exists and the pattern never recurred after promotion
362
+ const teaching = gates.filter((g) => g.correction !== undefined && g.recurredAfterGate === 0 && g.count >= 3)
363
+ if (teaching.length > 0) {
364
+ console.log(` note: TEACHING (${teaching.length}) — corrected gates with zero recurrences after promotion`)
365
+ }
366
+
367
+ const annoying = gates.filter((g) => g.remindedCount >= 10 && (canBlock(g.tool, g.signature) || canRemind(g.tool, g.signature)))
368
+ if (annoying.length > 0) {
369
+ issues += annoying.length
370
+ console.log(` ANNOYING reminded>=10 (${annoying.length}):`)
371
+ for (const g of annoying.slice(0, 10)) console.log(` - reminded ${g.remindedCount} | ${g.signature}`)
372
+ }
373
+
374
+ // review:true is set mechanically (blocked >= REVIEW_FIRES) but consumed
375
+ // nowhere else — surface it, otherwise the flag is dead weight. Enforced
376
+ // gates only: on healed/demoted gates the flag is history, not a defect
377
+ // (nothing clears it, so it would flag forever).
378
+ const reviewFlagged = gates.filter((g) => g.review === true && g.status !== "watching")
379
+ if (reviewFlagged.length > 0) {
380
+ issues += reviewFlagged.length
381
+ console.log(` REVIEW-FLAGGED (${reviewFlagged.length}) — blocked repeatedly without killing the error; inspect and rewrite the correction:`)
382
+ for (const g of reviewFlagged.slice(0, 10)) console.log(` - blocked ${g.blockedCount} | ${g.signature}`)
383
+ }
384
+
385
+ // Correction-quality signal from the in-session metric: reminders the agent
386
+ // immediately re-offends against are not teaching — the correction is weak.
387
+ const remindersIgnored = gates.filter((g) => g.status !== "watching" && g.recurredAfterReminder >= 3)
388
+ if (remindersIgnored.length > 0) {
389
+ issues += remindersIgnored.length
390
+ console.log(` REMINDERS IGNORED recurredAfterReminder>=3 (${remindersIgnored.length}) — agents retry right after the reminder; the correction teaches nothing:`)
391
+ for (const g of remindersIgnored.slice(0, 10)) console.log(` - reoffended ${g.recurredAfterReminder}/${g.remindedCount} | ${g.signature}`)
392
+ }
393
+ const teachingWell = gates.filter((g) => g.remindedCount >= 3 && g.recurredAfterReminder === 0)
394
+ if (teachingWell.length > 0) {
395
+ console.log(` note: TEACHING-WELL (${teachingWell.length}) — reminded 3+ times, never reoffended in-session`)
396
+ }
397
+
398
+ const leaky = gates.filter(
399
+ (g) =>
400
+ sanitizeForStore(g.signature) !== g.signature ||
401
+ sanitizeForStore(g.snippet) !== g.snippet ||
402
+ (g.correction !== undefined && sanitizeForStore(g.correction) !== g.correction),
403
+ )
404
+ if (leaky.length > 0) {
405
+ issues += leaky.length
406
+ console.log(` UNSANITIZED ON DISK (${leaky.length}) — secrets or terminal control chars; doctor --repair sanitizes`)
407
+ }
408
+
409
+ if (!scope.isGlobal) {
410
+ const staleCopies = gates.filter((g) => globalKeys.has(g.key))
411
+ if (staleCopies.length > 0) {
412
+ issues += staleCopies.length
413
+ console.log(` STALE PROJECT COPIES (${staleCopies.length}) — key already global; doctor --repair merges into the global gate`)
414
+ }
415
+ }
416
+
417
+ if (scope.corruptLogLines > 0) {
418
+ issues++
419
+ console.log(` CORRUPT LOG LINES (${scope.corruptLogLines}) — doctor --repair excises them to log.jsonl.corrupt`)
420
+ }
421
+
422
+ if (scope.degradedEvents > 0) {
423
+ // Observability, not a defect: the degrade-to-unlocked design trades rare
424
+ // lost updates for never hanging the tool pipeline. Watch the trend — a
425
+ // growing count is the signal to revisit the storage backend.
426
+ console.log(` note: LOCK DEGRADATIONS (${scope.degradedEvents}) — contention exceeded the wait window; updates may have been lost there`)
427
+ }
428
+
429
+ // Version drift: gates.json's lastInitVersion (the version of the process
430
+ // that last SAVED) is the durable signal — log init events rotate away on a
431
+ // busy log. Fall back to the last init event for stores not yet saved by a
432
+ // versioned writer.
433
+ if (scope.lastInitVersion !== null) {
434
+ if (scope.lastInitVersion !== PLUGIN_VERSION) {
435
+ issues++
436
+ console.log(` VERSION DRIFT: last writer = ${scope.lastInitVersion}, current = ${PLUGIN_VERSION} — stale plugin sessions were writing here; restart OpenCode`)
437
+ } else {
438
+ console.log(` version ok (${scope.lastInitVersion})`)
439
+ }
440
+ } else {
441
+ try {
442
+ const raw = await readFile(join(scope.dir, "log.jsonl"), "utf8")
443
+ const inits = raw.split("\n").filter((l) => l.includes('"type":"init"'))
444
+ const last = inits[inits.length - 1]
445
+ let version = "none"
446
+ if (last) {
447
+ try {
448
+ version = (JSON.parse(last) as { version?: string }).version ?? "unknown"
449
+ } catch {
450
+ // a corrupt init line must not crash the diagnostic tool itself
451
+ version = "unknown"
452
+ }
453
+ }
454
+ if (version === "none") {
455
+ // Indeterminate, not drift: on a busy log the init event rotates away
456
+ // while a long-lived session keeps running — no init in the kept window
457
+ // says nothing about which version is writing.
458
+ console.log(" version indeterminate (no init event in the kept log window)")
459
+ } else if (version !== PLUGIN_VERSION) {
460
+ issues++
461
+ console.log(` VERSION DRIFT: last init in log = ${version}, current = ${PLUGIN_VERSION} — stale plugin sessions were writing here; restart OpenCode`)
462
+ } else {
463
+ console.log(` version ok (${version})`)
464
+ }
465
+ } catch {
466
+ console.log(" (no log yet)")
467
+ }
468
+ }
469
+ }
470
+
471
+ // --- cross-store invariants (need the full scope list) ---
472
+ console.log(`\n== ${globalDir} (cross-store)`)
473
+ let crossIssues = 0
474
+ const indexEntries = Object.entries(index.keys)
475
+
476
+ const orphans = indexEntries.filter(([key]) => !allKeys.has(key))
477
+ if (orphans.length > 0) {
478
+ crossIssues++
479
+ console.log(` INDEX ORPHANS (${orphans.length}) — keys with no gate in any scope; doctor --repair prunes`)
480
+ }
481
+
482
+ const missing = globalScope.gates.filter((g) => index.keys[g.key] === undefined)
483
+ if (missing.length > 0) {
484
+ crossIssues++
485
+ console.log(` INDEX MISSING (${missing.length}) — global gates without cross-project tracking; doctor --repair rebuilds`)
486
+ }
487
+
488
+ // GLOBAL_PROJECTS (store.ts tunable): 2+ LIVE project dirs = agent-level habit.
489
+ // Repo-local verbs (npm/git/gradle/...) are excluded by policy — their failures
490
+ // are repo quirks and must stay project-scoped, so they are not "missed".
491
+ // Ghost dirs (renamed/moved repos) don't count toward the threshold.
492
+ const missed = indexEntries.filter(([key, entry]) => {
493
+ if (entry.projects.filter((p) => existsSync(p)).length < GLOBAL_PROJECTS || globalKeys.has(key)) return false
494
+ const signature = keySignature.get(key)
495
+ return signature === undefined || !isRepoLocal(signature)
496
+ })
497
+ if (missed.length > 0) {
498
+ crossIssues += missed.length
499
+ console.log(` MISSED ESCALATION (${missed.length}) — index shows 2+ projects but the gate is not global; doctor --repair escalates:`)
500
+ for (const [key, entry] of missed.slice(0, 10)) console.log(` - ${key} projects=${entry.projects.length}`)
501
+ }
502
+ if (crossIssues === 0) console.log(" ok")
503
+ issues += crossIssues
504
+
505
+ console.log(`\n${issues === 0 ? "OK: no pathologies" : `ISSUES: ${issues}`}`)
@@ -0,0 +1,46 @@
1
+ #!/bin/sh
2
+ # commit-msg hygiene: header length, emoji, AI attribution.
3
+ # Matches trailer structure, not bare tool names - plain mentions pass.
4
+ # Activate once per clone: git config core.hooksPath scripts/githooks
5
+
6
+ MSG_FILE="$1"
7
+ [ -f "$MSG_FILE" ] || exit 0
8
+
9
+ fail() {
10
+ printf '\ncommit-msg rejected: %s\n%s\n' "$1" "$2"
11
+ exit 1
12
+ }
13
+
14
+ header=$(grep -v '^#' "$MSG_FILE" | grep -m 1 -v '^[[:space:]]*$')
15
+
16
+ if printf '%s\n' "$header" | grep -qE '^.{101,}$'; then
17
+ fail "header longer than 100 chars" \
18
+ "Keep the subject to one line; move detail into the body."
19
+ fi
20
+
21
+ # Emoji: UTF-8 byte prefixes of pictograph blocks (works on any grep, C-locale safe).
22
+ # U+1F000+ plane = all modern emoji; U+2600-27BF and U+2B40+ = symbol/dingbat blocks.
23
+ if grep -q "$(printf '\360\237')" "$MSG_FILE" \
24
+ || grep -q "$(printf '\342\230')" "$MSG_FILE" \
25
+ || grep -q "$(printf '\342\231')" "$MSG_FILE" \
26
+ || grep -q "$(printf '\342\232')" "$MSG_FILE" \
27
+ || grep -q "$(printf '\342\233')" "$MSG_FILE" \
28
+ || grep -q "$(printf '\342\234')" "$MSG_FILE" \
29
+ || grep -q "$(printf '\342\235')" "$MSG_FILE" \
30
+ || grep -q "$(printf '\342\236')" "$MSG_FILE" \
31
+ || grep -q "$(printf '\342\255')" "$MSG_FILE"; then
32
+ fail "commit message contains emoji" \
33
+ "No emoji in commit messages."
34
+ fi
35
+
36
+ if grep -qiE '^[[:space:]]*co-authored-by:[[:space:]].*(claude|codex|copilot|chatgpt|gemini|devin|anthropic|openai|cursor|qwen|noreply)' "$MSG_FILE"; then
37
+ fail "AI co-author trailer" \
38
+ "No AI attribution: remove the Co-authored-by line."
39
+ fi
40
+
41
+ if grep -qiE '(generated|created|written|authored|produced)[[:space:]]+(with|by|using)[[:space:]].*(claude|codex|copilot|chatgpt|gemini|devin|cursor|opencode|anthropic|openai)' "$MSG_FILE"; then
42
+ fail "AI generation footer" \
43
+ "No AI attribution: remove the 'Generated with ...' footer."
44
+ fi
45
+
46
+ exit 0