opencode-telos 1.0.1 → 1.0.2

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.
Files changed (41) hide show
  1. package/dist/code-intelligence/ast/cache.d.ts.map +1 -1
  2. package/dist/code-intelligence/ast/cache.js +8 -5
  3. package/dist/code-intelligence/ast/cache.js.map +1 -1
  4. package/dist/opencode/hooks.d.ts.map +1 -1
  5. package/dist/opencode/hooks.js +0 -6
  6. package/dist/opencode/hooks.js.map +1 -1
  7. package/dist/opencode/tools.d.ts.map +1 -1
  8. package/dist/opencode/tools.js +9 -8
  9. package/dist/opencode/tools.js.map +1 -1
  10. package/dist/sdd/cache/atomic.d.ts +3 -0
  11. package/dist/sdd/cache/atomic.d.ts.map +1 -0
  12. package/dist/sdd/cache/atomic.js +35 -0
  13. package/dist/sdd/cache/atomic.js.map +1 -0
  14. package/dist/sdd/cache/fingerprint.d.ts +18 -0
  15. package/dist/sdd/cache/fingerprint.d.ts.map +1 -0
  16. package/dist/sdd/cache/fingerprint.js +99 -0
  17. package/dist/sdd/cache/fingerprint.js.map +1 -0
  18. package/dist/sdd/cache/manager.d.ts +24 -10
  19. package/dist/sdd/cache/manager.d.ts.map +1 -1
  20. package/dist/sdd/cache/manager.js +184 -33
  21. package/dist/sdd/cache/manager.js.map +1 -1
  22. package/dist/sdd/cache/snapshot-store.d.ts +2 -1
  23. package/dist/sdd/cache/snapshot-store.d.ts.map +1 -1
  24. package/dist/sdd/cache/snapshot-store.js +13 -6
  25. package/dist/sdd/cache/snapshot-store.js.map +1 -1
  26. package/dist/sdd/persistence/sqlite.d.ts.map +1 -1
  27. package/dist/sdd/persistence/sqlite.js +66 -41
  28. package/dist/sdd/persistence/sqlite.js.map +1 -1
  29. package/dist/sdd/persistence/yaml.d.ts.map +1 -1
  30. package/dist/sdd/persistence/yaml.js +33 -25
  31. package/dist/sdd/persistence/yaml.js.map +1 -1
  32. package/package.json +1 -1
  33. package/src/code-intelligence/ast/cache.ts +11 -6
  34. package/src/opencode/hooks.ts +0 -5
  35. package/src/opencode/tools.ts +9 -8
  36. package/src/sdd/cache/atomic.ts +30 -0
  37. package/src/sdd/cache/fingerprint.ts +98 -0
  38. package/src/sdd/cache/manager.ts +182 -39
  39. package/src/sdd/cache/snapshot-store.ts +14 -7
  40. package/src/sdd/persistence/sqlite.ts +50 -24
  41. package/src/sdd/persistence/yaml.ts +40 -26
@@ -71,6 +71,7 @@ import {
71
71
  createDriftWhitelistTool,
72
72
  } from "./router/tools-composite.js"
73
73
  import { createWorkflowTools } from "./workflows/tools-workflow.js"
74
+ import { graphFingerprint } from "../sdd/cache/fingerprint.js"
74
75
 
75
76
  // ── Validation Coverage Index (singleton per session) ──────────────
76
77
  const validationIndex = new ValidationIndex()
@@ -95,7 +96,7 @@ function getCachedToolResponse(directory: string, toolName: string, args: Record
95
96
  const repo = getRepo(directory)
96
97
  if (!repo.isInitialized()) return null
97
98
  const graph = repo.loadGraph()
98
- return cacheMgr.getToolResponse(toolName, args, graph.metadata.updated_at)
99
+ return cacheMgr.getToolResponse(toolName, args, graphFingerprint(graph))
99
100
  } catch {
100
101
  return null
101
102
  }
@@ -110,7 +111,7 @@ function setCachedToolResponse(directory: string, toolName: string, args: Record
110
111
  const repo = getRepo(directory)
111
112
  if (!repo.isInitialized()) return
112
113
  const graph = repo.loadGraph()
113
- cacheMgr.setToolResponse(toolName, args, response, graph.metadata.updated_at)
114
+ cacheMgr.setToolResponse(toolName, args, response, graphFingerprint(graph))
114
115
  } catch {}
115
116
  }
116
117
 
@@ -698,7 +699,7 @@ export function createSddTools(): Record<string, ToolDefinition> {
698
699
 
699
700
  // Check analysis cache
700
701
  const cacheMgr = getCacheManager(ctx.directory)
701
- const cached = cacheMgr.getAnalysisResult("validate", graph.metadata.updated_at ? new Date(graph.metadata.updated_at).getTime() : 0, graph.nodes.length)
702
+ const cached = cacheMgr.getAnalysisResult("validate", graphFingerprint(graph))
702
703
  if (cached) {
703
704
  recordTelemetry(ctx.directory, {
704
705
  name: "graph_validation",
@@ -727,7 +728,7 @@ export function createSddTools(): Record<string, ToolDefinition> {
727
728
  }
728
729
 
729
730
  // Cache the result
730
- cacheMgr.setAnalysisResult("validate", formatted, graph.metadata.updated_at ? new Date(graph.metadata.updated_at).getTime() : 0, graph.nodes.length)
731
+ cacheMgr.setAnalysisResult("validate", formatted, graphFingerprint(graph))
731
732
  recordTelemetry(ctx.directory, {
732
733
  name: "graph_validation",
733
734
  duration_ms: Date.now() - startedAt,
@@ -751,14 +752,14 @@ export function createSddTools(): Record<string, ToolDefinition> {
751
752
 
752
753
  // Check analysis cache
753
754
  const cacheMgr = getCacheManager(ctx.directory)
754
- const cached = cacheMgr.getAnalysisResult("drift", graph.metadata.updated_at ? new Date(graph.metadata.updated_at).getTime() : 0, graph.nodes.length)
755
+ const cached = cacheMgr.getAnalysisResult("drift", graphFingerprint(graph))
755
756
  if (cached) return cached as string
756
757
 
757
758
  const result = detectDrift(graph, ctx.directory)
758
759
  const formatted = formatDriftReport(result)
759
760
 
760
761
  // Cache the result
761
- cacheMgr.setAnalysisResult("drift", formatted, graph.metadata.updated_at ? new Date(graph.metadata.updated_at).getTime() : 0, graph.nodes.length)
762
+ cacheMgr.setAnalysisResult("drift", formatted, graphFingerprint(graph))
762
763
 
763
764
  return formatted
764
765
  },
@@ -1867,14 +1868,14 @@ export function createSddTools(): Record<string, ToolDefinition> {
1867
1868
 
1868
1869
  // Check analysis cache
1869
1870
  const cacheMgr = getCacheManager(ctx.directory)
1870
- const cached = cacheMgr.getAnalysisResult("quality", graph.metadata.updated_at ? new Date(graph.metadata.updated_at).getTime() : 0, graph.nodes.length)
1871
+ const cached = cacheMgr.getAnalysisResult("quality", graphFingerprint(graph))
1871
1872
  if (cached) return cached as string
1872
1873
 
1873
1874
  const report = calculateQualityScore(graph, ctx.directory)
1874
1875
  const formatted = formatQualityReport(report)
1875
1876
 
1876
1877
  // Cache the result
1877
- cacheMgr.setAnalysisResult("quality", formatted, graph.metadata.updated_at ? new Date(graph.metadata.updated_at).getTime() : 0, graph.nodes.length)
1878
+ cacheMgr.setAnalysisResult("quality", formatted, graphFingerprint(graph))
1878
1879
 
1879
1880
  return formatted
1880
1881
  },
@@ -0,0 +1,30 @@
1
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, renameSync, writeFileSync } from "fs"
2
+ import { dirname } from "path"
3
+
4
+ /** Write a cache artifact so readers see either the old complete file or the new complete file. */
5
+ export function atomicWriteFile(filePath: string, content: string): void {
6
+ const directory = dirname(filePath)
7
+ if (!existsSync(directory)) mkdirSync(directory, { recursive: true })
8
+
9
+ const temporaryPath = `${filePath}.tmp-${process.pid}-${Date.now()}`
10
+ let descriptor: number | undefined
11
+ try {
12
+ descriptor = openSync(temporaryPath, "w", 0o600)
13
+ writeFileSync(descriptor, content, "utf-8")
14
+ fsyncSync(descriptor)
15
+ closeSync(descriptor)
16
+ descriptor = undefined
17
+ renameSync(temporaryPath, filePath)
18
+ } finally {
19
+ if (descriptor !== undefined) {
20
+ try { closeSync(descriptor) } catch {}
21
+ }
22
+ try {
23
+ // A failed rename must not leave a misleading cache artifact behind.
24
+ if (existsSync(temporaryPath)) {
25
+ const { unlinkSync } = require("fs")
26
+ unlinkSync(temporaryPath)
27
+ }
28
+ } catch {}
29
+ }
30
+ }
@@ -0,0 +1,98 @@
1
+ import { createHash } from "crypto"
2
+ import { existsSync, readFileSync, readdirSync, statSync } from "fs"
3
+ import { join, relative } from "path"
4
+ import type { KnowledgeGraph } from "../domain/types.js"
5
+
6
+ const SOURCE_EXTENSIONS = new Set([
7
+ ".c", ".cc", ".cpp", ".cs", ".go", ".h", ".hpp", ".java", ".js", ".jsx",
8
+ ".json", ".kt", ".mjs", ".mts", ".py", ".rb", ".rs", ".svelte", ".swift",
9
+ ".ts", ".tsx", ".vue", ".yaml", ".yml",
10
+ ])
11
+
12
+ const IGNORED_DIRECTORIES = new Set([
13
+ ".git", ".sdd", ".opencode", "node_modules", "dist", "build", "coverage",
14
+ ".cache", ".next", ".turbo", "target", "vendor",
15
+ ])
16
+
17
+ export function stableSerialize(value: unknown): string {
18
+ if (value === null) return "null"
19
+ if (value === undefined) return "undefined"
20
+ if (typeof value !== "object") return JSON.stringify(value)
21
+ if (Array.isArray(value)) return `[${value.map(stableSerialize).join(",")}]`
22
+
23
+ const record = value as Record<string, unknown>
24
+ return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableSerialize(record[key])}`).join(",")}}`
25
+ }
26
+
27
+ export function sha256(value: string | Uint8Array): string {
28
+ return createHash("sha256").update(value).digest("hex")
29
+ }
30
+
31
+ /** Deterministic content fingerprint for the complete graph, independent of YAML/SQLite formatting. */
32
+ export function graphFingerprint(graph: KnowledgeGraph): string {
33
+ return sha256(stableSerialize({
34
+ version: graph.version,
35
+ project_id: graph.project_id,
36
+ metadata: graph.metadata,
37
+ nodes: [...graph.nodes].sort((a, b) => a.id.localeCompare(b.id)),
38
+ relationships: [...graph.relationships].sort((a, b) => a.id.localeCompare(b.id)),
39
+ }))
40
+ }
41
+
42
+ export function fileContentFingerprint(filePath: string): string {
43
+ try {
44
+ return sha256(readFileSync(filePath))
45
+ } catch {
46
+ return sha256(`missing:${filePath}`)
47
+ }
48
+ }
49
+
50
+ /** Fast identity for external-file detection; includes all files when several files are supplied. */
51
+ export function fileSignature(filePaths: string[]): string {
52
+ const parts = filePaths.map((filePath) => {
53
+ try {
54
+ const stat = statSync(filePath)
55
+ return `${filePath}:${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`
56
+ } catch {
57
+ return `${filePath}:missing`
58
+ }
59
+ })
60
+ return sha256(parts.sort().join("\n"))
61
+ }
62
+
63
+ export function configFingerprint(projectDir: string): string {
64
+ const path = join(projectDir, ".sdd", "config.json")
65
+ return existsSync(path) ? fileContentFingerprint(path) : sha256("missing-config")
66
+ }
67
+
68
+ function sourceFiles(projectDir: string): string[] {
69
+ const files: string[] = []
70
+ const visit = (directory: string): void => {
71
+ let entries
72
+ try { entries = readdirSync(directory, { withFileTypes: true }) } catch { return }
73
+ for (const entry of entries) {
74
+ if (entry.isSymbolicLink()) continue
75
+ const path = join(directory, entry.name)
76
+ if (entry.isDirectory()) {
77
+ if (!IGNORED_DIRECTORIES.has(entry.name)) visit(path)
78
+ continue
79
+ }
80
+ const extension = entry.name.slice(entry.name.lastIndexOf(".")).toLowerCase()
81
+ if (SOURCE_EXTENSIONS.has(extension)) files.push(path)
82
+ }
83
+ }
84
+ visit(projectDir)
85
+ return files.sort()
86
+ }
87
+
88
+ /** Content fingerprint for code-dependent analyses. The caller can cache this by fileSignature. */
89
+ export function sourceFingerprint(
90
+ projectDir: string,
91
+ previous?: { fingerprint: string; signature: string } | null,
92
+ ): { fingerprint: string; signature: string } {
93
+ const files = sourceFiles(projectDir)
94
+ const signature = fileSignature(files)
95
+ if (previous?.signature === signature) return previous
96
+ const content = files.map((path) => `${relative(projectDir, path)}:${fileContentFingerprint(path)}`).join("\n")
97
+ return { fingerprint: sha256(content), signature }
98
+ }
@@ -1,10 +1,12 @@
1
- import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from "fs"
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, statSync, openSync, fsyncSync, closeSync } from "fs"
2
2
  import { join, dirname } from "path"
3
3
  import { createHash } from "crypto"
4
4
  import { PerTypeGraphCache, IncrementalGraphHash } from "../graph/index.js"
5
5
  import type { AnyNode, NodeType, Relationship } from "../domain/types.js"
6
6
  import { getGraphSnapshotStore } from "./snapshot-store.js"
7
7
  import type { KnowledgeGraph } from "../domain/types.js"
8
+ import { atomicWriteFile } from "./atomic.js"
9
+ import { configFingerprint, graphFingerprint, sourceFingerprint } from "./fingerprint.js"
8
10
 
9
11
  // ── Cache Entry Types ────────────────────────────────────────────────
10
12
 
@@ -22,15 +24,18 @@ interface ToolCacheEntry {
22
24
  lastAccess: number
23
25
  toolName: string
24
26
  argsHash: string
25
- graphVersion: string
27
+ graphFingerprint: string
28
+ configFingerprint: string
29
+ sourceFingerprint?: string
26
30
  }
27
31
 
28
32
  interface AnalysisCacheEntry {
29
33
  result: unknown
30
34
  timestamp: number
31
35
  lastAccess: number
32
- graphVersion: number
33
- nodeCount: number
36
+ graphFingerprint: string
37
+ configFingerprint: string
38
+ sourceFingerprint?: string
34
39
  type: string
35
40
  }
36
41
 
@@ -38,13 +43,23 @@ interface PersistentCacheData {
38
43
  version: number
39
44
  toolResponses: Record<string, ToolCacheEntry>
40
45
  analysisResults: Record<string, AnalysisCacheEntry>
41
- graphSnapshot: {
46
+ graphSnapshot?: {
42
47
  nodeCount: number
43
48
  relationshipCount: number
44
49
  version: string
45
50
  lastModified: number
46
51
  } | null
47
- graphHash: string
52
+ graphHash?: string
53
+ configFingerprint?: string
54
+ }
55
+
56
+ interface InvalidationEvent {
57
+ id: string
58
+ timestamp: number
59
+ pid: number
60
+ nodeTypes: string[]
61
+ relTypes: string[]
62
+ full: boolean
48
63
  }
49
64
 
50
65
  // ── Granular Invalidation Tracking ───────────────────────────────────
@@ -63,6 +78,8 @@ const TOOL_RESPONSE_TTL = 5 * 60 * 1000 // 5 minutes
63
78
  const ANALYSIS_TTL = 3 * 60 * 1000 // 3 minutes
64
79
  const PERSISTENT_CACHE_FILE = ".sdd/cache.json"
65
80
  const INVALIDATION_FILE = ".sdd/cache-invalidated.json"
81
+ const INVALIDATION_JOURNAL_FILE = ".sdd/cache-events.jsonl"
82
+ const INVALIDATION_JOURNAL_MAX_BYTES = 1024 * 1024
66
83
 
67
84
  export class CacheManager {
68
85
  private projectDir: string
@@ -88,6 +105,9 @@ export class CacheManager {
88
105
 
89
106
  // Incremental graph hash
90
107
  private graphHash = new IncrementalGraphHash()
108
+ private sourceFingerprintCache: { signature: string; fingerprint: string } | null = null
109
+ private invalidationJournalOffset = 0
110
+ private invalidationEventCounter = 0
91
111
 
92
112
  // Statistics
93
113
  private stats = {
@@ -102,6 +122,7 @@ export class CacheManager {
102
122
  this.projectDir = projectDir
103
123
  this.loadInvalidationTracker()
104
124
  this.invalidationVersionOnWrite = this.invalidation.version
125
+ try { this.invalidationJournalOffset = statSync(join(projectDir, INVALIDATION_JOURNAL_FILE)).size } catch {}
105
126
  }
106
127
 
107
128
  // ── Tool Response Cache ───────────────────────────────────────────
@@ -111,7 +132,8 @@ export class CacheManager {
111
132
  * Checks: TTL (using lastAccess for freshness), graph version, invalidation status.
112
133
  * F: Uses lazy revalidation — checks version before clearing.
113
134
  */
114
- getToolResponse(toolName: string, args: Record<string, unknown>, graphVersion: string): string | null {
135
+ getToolResponse(toolName: string, args: Record<string, unknown>, currentGraphFingerprint: string): string | null {
136
+ this.refreshExternalInvalidation()
115
137
  const key = this.toolCacheKey(toolName, args)
116
138
  const entry = this.toolResponses.get(key)
117
139
 
@@ -128,6 +150,7 @@ export class CacheManager {
128
150
  this.stats.toolMisses++
129
151
  return null
130
152
  }
153
+ this.invalidationVersionOnWrite = this.invalidation.version
131
154
  }
132
155
 
133
156
  // B: Check TTL using lastAccess (not timestamp) — entries accessed recently survive restore
@@ -139,7 +162,14 @@ export class CacheManager {
139
162
  }
140
163
 
141
164
  // Check graph version consistency
142
- if (entry.graphVersion !== graphVersion) {
165
+ const currentConfigFingerprint = configFingerprint(this.projectDir)
166
+ if (entry.graphFingerprint !== currentGraphFingerprint || entry.configFingerprint !== currentConfigFingerprint) {
167
+ this.toolResponses.delete(key)
168
+ this.stats.toolMisses++
169
+ return null
170
+ }
171
+
172
+ if (this.toolNeedsSource(toolName) && entry.sourceFingerprint !== this.getSourceFingerprint()) {
143
173
  this.toolResponses.delete(key)
144
174
  this.stats.toolMisses++
145
175
  return null
@@ -154,7 +184,7 @@ export class CacheManager {
154
184
  /**
155
185
  * Cache a tool response.
156
186
  */
157
- setToolResponse(toolName: string, args: Record<string, unknown>, response: string, graphVersion: string): void {
187
+ setToolResponse(toolName: string, args: Record<string, unknown>, response: string, currentGraphFingerprint: string): void {
158
188
  const key = this.toolCacheKey(toolName, args)
159
189
  const now = Date.now()
160
190
  this.toolResponses.set(key, {
@@ -163,7 +193,9 @@ export class CacheManager {
163
193
  lastAccess: now,
164
194
  toolName,
165
195
  argsHash: this.toolCacheKey(toolName, args).split(':')[1] || '',
166
- graphVersion,
196
+ graphFingerprint: currentGraphFingerprint,
197
+ configFingerprint: configFingerprint(this.projectDir),
198
+ sourceFingerprint: this.toolNeedsSource(toolName) ? this.getSourceFingerprint() : undefined,
167
199
  })
168
200
 
169
201
  // Evict old entries if cache is too large
@@ -179,7 +211,8 @@ export class CacheManager {
179
211
  * Revalidates based on graph state.
180
212
  * F: Uses lazy revalidation.
181
213
  */
182
- getAnalysisResult(type: string, graphVersion: number, nodeCount: number): unknown | null {
214
+ getAnalysisResult(type: string, currentGraphFingerprint: string): unknown | null {
215
+ this.refreshExternalInvalidation()
183
216
  const key = `analysis:${type}`
184
217
  const entry = this.analysisResults.get(key)
185
218
 
@@ -196,6 +229,7 @@ export class CacheManager {
196
229
  this.stats.analysisMisses++
197
230
  return null
198
231
  }
232
+ this.invalidationVersionOnWrite = this.invalidation.version
199
233
  }
200
234
 
201
235
  // B: Check TTL using lastAccess
@@ -206,8 +240,14 @@ export class CacheManager {
206
240
  return null
207
241
  }
208
242
 
209
- // Revalidation: if graph changed since analysis, invalidate
210
- if (entry.graphVersion !== graphVersion || entry.nodeCount !== nodeCount) {
243
+ // Revalidation: graph and configuration content must match exactly.
244
+ if (entry.graphFingerprint !== currentGraphFingerprint || entry.configFingerprint !== configFingerprint(this.projectDir)) {
245
+ this.analysisResults.delete(key)
246
+ this.stats.analysisMisses++
247
+ return null
248
+ }
249
+
250
+ if (this.analysisNeedsSource(type) && entry.sourceFingerprint !== this.getSourceFingerprint()) {
211
251
  this.analysisResults.delete(key)
212
252
  this.stats.analysisMisses++
213
253
  return null
@@ -222,15 +262,16 @@ export class CacheManager {
222
262
  /**
223
263
  * Cache an analysis result.
224
264
  */
225
- setAnalysisResult(type: string, result: unknown, graphVersion: number, nodeCount: number): void {
265
+ setAnalysisResult(type: string, result: unknown, currentGraphFingerprint: string): void {
226
266
  const key = `analysis:${type}`
227
267
  const now = Date.now()
228
268
  this.analysisResults.set(key, {
229
269
  result,
230
270
  timestamp: now,
231
271
  lastAccess: now,
232
- graphVersion,
233
- nodeCount,
272
+ graphFingerprint: currentGraphFingerprint,
273
+ configFingerprint: configFingerprint(this.projectDir),
274
+ sourceFingerprint: this.analysisNeedsSource(type) ? this.getSourceFingerprint() : undefined,
234
275
  type,
235
276
  })
236
277
  }
@@ -246,6 +287,7 @@ export class CacheManager {
246
287
  }
247
288
  this.invalidation.version++
248
289
  this.saveInvalidationTracker()
290
+ this.appendInvalidationEvent({ nodeTypes: [], relTypes: [], full: false })
249
291
  }
250
292
 
251
293
  /**
@@ -257,6 +299,7 @@ export class CacheManager {
257
299
  }
258
300
  this.invalidation.version++
259
301
  this.saveInvalidationTracker()
302
+ this.appendInvalidationEvent({ nodeTypes: [], relTypes: types, full: false })
260
303
  }
261
304
 
262
305
  /**
@@ -274,6 +317,7 @@ export class CacheManager {
274
317
  this.invalidation.version++
275
318
  this.stats.invalidations++
276
319
  this.saveInvalidationTracker()
320
+ this.appendInvalidationEvent({ nodeTypes: [], relTypes: [], full: true })
277
321
  }
278
322
 
279
323
  /**
@@ -322,6 +366,7 @@ export class CacheManager {
322
366
  this.invalidation.dirtyTypes.add(nodeType)
323
367
  this.invalidation.version++
324
368
  this.saveInvalidationTracker()
369
+ this.appendInvalidationEvent({ nodeTypes: [nodeType], relTypes: [], full: false })
325
370
  }
326
371
 
327
372
  // ── Persistent Cache (Cross-Session) ──────────────────────────────
@@ -338,6 +383,10 @@ export class CacheManager {
338
383
  if (Date.now() - (data.timestamp || 0) > 60 * 60 * 1000) {
339
384
  return null
340
385
  }
386
+ if (data.version !== 2 || !data.toolResponses || !data.analysisResults ||
387
+ typeof data.toolResponses !== "object" || typeof data.analysisResults !== "object") {
388
+ return null
389
+ }
341
390
  return data
342
391
  } catch {
343
392
  return null
@@ -351,7 +400,7 @@ export class CacheManager {
351
400
  const path = join(this.projectDir, PERSISTENT_CACHE_FILE)
352
401
  const dir = dirname(path)
353
402
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
354
- writeFileSync(path, JSON.stringify({ ...data, timestamp: Date.now() }, null, 2))
403
+ atomicWriteFile(path, JSON.stringify({ ...data, timestamp: Date.now() }, null, 2))
355
404
  }
356
405
 
357
406
  /**
@@ -373,7 +422,9 @@ export class CacheManager {
373
422
  lastAccess: entry.lastAccess || entry.timestamp,
374
423
  toolName: entry.toolName,
375
424
  argsHash: entry.argsHash,
376
- graphVersion: entry.graphVersion,
425
+ graphFingerprint: entry.graphFingerprint,
426
+ configFingerprint: entry.configFingerprint || "",
427
+ sourceFingerprint: entry.sourceFingerprint,
377
428
  })
378
429
  restored++
379
430
  }
@@ -386,8 +437,9 @@ export class CacheManager {
386
437
  result: entry.result,
387
438
  timestamp: entry.timestamp,
388
439
  lastAccess: entry.lastAccess || entry.timestamp,
389
- graphVersion: entry.graphVersion,
390
- nodeCount: entry.nodeCount,
440
+ graphFingerprint: entry.graphFingerprint,
441
+ configFingerprint: entry.configFingerprint || "",
442
+ sourceFingerprint: entry.sourceFingerprint,
391
443
  type: key.replace("analysis:", ""),
392
444
  })
393
445
  restored++
@@ -409,7 +461,9 @@ export class CacheManager {
409
461
  lastAccess: entry.lastAccess,
410
462
  toolName: entry.toolName,
411
463
  argsHash: entry.argsHash,
412
- graphVersion: entry.graphVersion,
464
+ graphFingerprint: entry.graphFingerprint,
465
+ configFingerprint: entry.configFingerprint,
466
+ sourceFingerprint: entry.sourceFingerprint,
413
467
  }
414
468
  }
415
469
 
@@ -419,8 +473,9 @@ export class CacheManager {
419
473
  result: entry.result,
420
474
  timestamp: entry.timestamp,
421
475
  lastAccess: entry.lastAccess,
422
- graphVersion: entry.graphVersion,
423
- nodeCount: entry.nodeCount,
476
+ graphFingerprint: entry.graphFingerprint,
477
+ configFingerprint: entry.configFingerprint,
478
+ sourceFingerprint: entry.sourceFingerprint,
424
479
  type: key.replace("analysis:", ""),
425
480
  }
426
481
  }
@@ -429,8 +484,7 @@ export class CacheManager {
429
484
  version: 2,
430
485
  toolResponses,
431
486
  analysisResults,
432
- graphSnapshot: null,
433
- graphHash: this.graphHash.getHash(),
487
+ configFingerprint: configFingerprint(this.projectDir),
434
488
  })
435
489
  }
436
490
 
@@ -438,10 +492,10 @@ export class CacheManager {
438
492
  * Save graph snapshot to disk (G).
439
493
  * Called after graph mutations and on dispose.
440
494
  */
441
- saveGraphSnapshot(graph: KnowledgeGraph): void {
495
+ saveGraphSnapshot(graph: KnowledgeGraph, sourceSignature = ""): void {
442
496
  try {
443
497
  const store = getGraphSnapshotStore(this.projectDir)
444
- store.save(graph, this.graphHash.getHash())
498
+ store.save(graph, graphFingerprint(graph), sourceSignature)
445
499
  } catch {}
446
500
  }
447
501
 
@@ -449,7 +503,7 @@ export class CacheManager {
449
503
  * Load graph snapshot from disk (G).
450
504
  * Returns null if no valid snapshot exists.
451
505
  */
452
- loadGraphSnapshot(): { graph: KnowledgeGraph; graphHash: string } | null {
506
+ loadGraphSnapshot(): { graph: KnowledgeGraph; graphHash: string; sourceSignature: string } | null {
453
507
  try {
454
508
  const store = getGraphSnapshotStore(this.projectDir)
455
509
  return store.load()
@@ -475,6 +529,7 @@ export class CacheManager {
475
529
  * Uses file-based locking + PID liveness check for robust cross-process coordination.
476
530
  */
477
531
  checkCrossProcessInvalidation(graphPath: string): boolean {
532
+ if (this.refreshExternalInvalidation()) return true
478
533
  const lockPath = join(this.projectDir, ".sdd", ".cache-lock")
479
534
  try {
480
535
  if (existsSync(lockPath)) {
@@ -516,21 +571,26 @@ export class CacheManager {
516
571
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
517
572
 
518
573
  try {
519
- // Try to create lock file
520
574
  if (existsSync(lockPath)) {
521
575
  const existing = JSON.parse(readFileSync(lockPath, "utf-8"))
522
- // If lock is stale (>30s), override it
523
- if (Date.now() - existing.timestamp > 30000) {
524
- // Stale lock, ok to override
525
- } else if (existing.pid !== process.pid) {
526
- return false // Another process holds the lock
576
+ if (existing.pid === process.pid) return true
577
+ if (Date.now() - existing.timestamp <= 30000) return false
578
+ try {
579
+ if (existing.pid) process.kill(existing.pid, 0)
580
+ return false
581
+ } catch {
582
+ try { unlinkSync(lockPath) } catch { return false }
527
583
  }
528
584
  }
529
585
 
530
- writeFileSync(lockPath, JSON.stringify({
531
- pid: process.pid,
532
- timestamp: Date.now(),
533
- }))
586
+ // wx/O_EXCL makes acquisition atomic when two OpenCode processes write together.
587
+ const descriptor = openSync(lockPath, "wx", 0o600)
588
+ try {
589
+ writeFileSync(descriptor, JSON.stringify({ pid: process.pid, timestamp: Date.now() }), "utf-8")
590
+ fsyncSync(descriptor)
591
+ } finally {
592
+ closeSync(descriptor)
593
+ }
534
594
  return true
535
595
  } catch {
536
596
  return false
@@ -572,6 +632,7 @@ export class CacheManager {
572
632
  this.invalidation.version++
573
633
  this.stats.invalidations++
574
634
  this.saveInvalidationTracker()
635
+ this.appendInvalidationEvent({ nodeTypes: [], relTypes: [], full: true })
575
636
 
576
637
  // Clear persistent cache on disk
577
638
  let disk = false
@@ -620,6 +681,47 @@ export class CacheManager {
620
681
  this.invalidationVersionOnWrite = this.invalidation.version
621
682
  }
622
683
 
684
+ /** Refresh cache state from durable invalidation events written by another process. */
685
+ refreshExternalInvalidation(): boolean {
686
+ const journalPath = join(this.projectDir, INVALIDATION_JOURNAL_FILE)
687
+ if (!existsSync(journalPath)) return false
688
+
689
+ try {
690
+ const size = statSync(journalPath).size
691
+ if (size < this.invalidationJournalOffset) this.invalidationJournalOffset = 0
692
+ if (size === this.invalidationJournalOffset) return false
693
+
694
+ const content = readFileSync(journalPath)
695
+ const start = Math.min(this.invalidationJournalOffset, content.byteLength)
696
+ const recent = content.subarray(start).toString("utf-8")
697
+ const lastCompleteLine = recent.lastIndexOf("\n")
698
+ if (lastCompleteLine < 0) return false
699
+ this.invalidationJournalOffset = start + Buffer.byteLength(recent.slice(0, lastCompleteLine + 1))
700
+ let invalidated = false
701
+ for (const line of recent.slice(0, lastCompleteLine).split("\n")) {
702
+ if (!line.trim()) continue
703
+ let event: InvalidationEvent
704
+ try { event = JSON.parse(line) as InvalidationEvent } catch { invalidated = true; continue }
705
+ if (event.pid === process.pid) continue
706
+ invalidated = true
707
+ }
708
+ if (!invalidated) return false
709
+
710
+ this.toolResponses.clear()
711
+ this.analysisResults.clear()
712
+ this.graphCache.invalidateAll()
713
+ this.invalidation.version++
714
+ this.invalidationVersionOnWrite = this.invalidation.version
715
+ return true
716
+ } catch {
717
+ // A corrupt journal must never make the plugin unusable; force safe misses.
718
+ this.toolResponses.clear()
719
+ this.analysisResults.clear()
720
+ this.graphCache.invalidateAll()
721
+ return true
722
+ }
723
+ }
724
+
623
725
  // ── Per-Type Graph Cache ──────────────────────────────────────────
624
726
 
625
727
  /**
@@ -656,6 +758,7 @@ export class CacheManager {
656
758
  }
657
759
  this.invalidation.version++
658
760
  this.saveInvalidationTracker()
761
+ this.appendInvalidationEvent({ nodeTypes: types, relTypes: [], full: false })
659
762
  }
660
763
 
661
764
  /**
@@ -875,7 +978,7 @@ export class CacheManager {
875
978
  const path = join(this.projectDir, INVALIDATION_FILE)
876
979
  const dir = dirname(path)
877
980
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
878
- writeFileSync(path, JSON.stringify({
981
+ atomicWriteFile(path, JSON.stringify({
879
982
  lastFullInvalidation: this.invalidation.lastFullInvalidation,
880
983
  version: this.invalidation.version,
881
984
  dirtyTypes: [...this.invalidation.dirtyTypes],
@@ -883,6 +986,46 @@ export class CacheManager {
883
986
  dirtyRelTypes: [...this.invalidation.dirtyRelTypes],
884
987
  }))
885
988
  }
989
+
990
+ private appendInvalidationEvent(change: { nodeTypes: string[]; relTypes: string[]; full: boolean }): void {
991
+ const path = join(this.projectDir, INVALIDATION_JOURNAL_FILE)
992
+ const event: InvalidationEvent = {
993
+ id: `${Date.now()}-${process.pid}-${++this.invalidationEventCounter}`,
994
+ timestamp: Date.now(),
995
+ pid: process.pid,
996
+ ...change,
997
+ }
998
+ try {
999
+ const directory = dirname(path)
1000
+ if (!existsSync(directory)) mkdirSync(directory, { recursive: true })
1001
+ if (existsSync(path) && statSync(path).size > INVALIDATION_JOURNAL_MAX_BYTES) {
1002
+ atomicWriteFile(path, `${JSON.stringify(event)}\n`)
1003
+ } else {
1004
+ const descriptor = openSync(path, "a", 0o600)
1005
+ try {
1006
+ writeFileSync(descriptor, `${JSON.stringify(event)}\n`, "utf-8")
1007
+ fsyncSync(descriptor)
1008
+ } finally {
1009
+ closeSync(descriptor)
1010
+ }
1011
+ }
1012
+ this.invalidationJournalOffset = statSync(path).size
1013
+ } catch {}
1014
+ }
1015
+
1016
+ private toolNeedsSource(toolName: string): boolean {
1017
+ return toolName === "sdd.detect_drift" || toolName === "sdd.quality" || toolName === "sdd.coverage"
1018
+ }
1019
+
1020
+ private analysisNeedsSource(type: string): boolean {
1021
+ return type === "drift" || type === "quality" || type === "coverage"
1022
+ }
1023
+
1024
+ private getSourceFingerprint(): string {
1025
+ const current = sourceFingerprint(this.projectDir, this.sourceFingerprintCache)
1026
+ this.sourceFingerprintCache = current
1027
+ return current.fingerprint
1028
+ }
886
1029
  }
887
1030
 
888
1031
  // ── Singleton per project ────────────────────────────────────────────