opencode-dejavu 2.1.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 +22 -0
- package/LICENSE +21 -0
- package/README.md +118 -0
- package/index.ts +412 -0
- package/package.json +39 -0
- package/src/AGENTS.md +37 -0
- package/src/patterns.ts +333 -0
- package/src/store.ts +449 -0
package/src/store.ts
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
import { appendFile, mkdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises"
|
|
2
|
+
import { dirname, join } from "node:path"
|
|
3
|
+
import { canBlock, fuzzySimilar, scrubSecrets } from "./patterns"
|
|
4
|
+
|
|
5
|
+
/** Bumped on behavior changes; stamped into init log events so stale sessions are visible. */
|
|
6
|
+
export const PLUGIN_VERSION = "2.1.0"
|
|
7
|
+
|
|
8
|
+
export interface Gate {
|
|
9
|
+
/** sha1 signature prefix — the pattern identity */
|
|
10
|
+
key: string
|
|
11
|
+
/** normalized call signature, e.g. "bash:npm install --legacy-peer-deps" */
|
|
12
|
+
signature: string
|
|
13
|
+
tool: string
|
|
14
|
+
/** watching = collecting evidence; blocking = gate is enforced */
|
|
15
|
+
status: "watching" | "blocking"
|
|
16
|
+
count: number
|
|
17
|
+
/** distinct session IDs where the failure was seen */
|
|
18
|
+
sessions: string[]
|
|
19
|
+
/** distinct project directories where the failure was seen */
|
|
20
|
+
projects: string[]
|
|
21
|
+
firstSeen: string
|
|
22
|
+
lastSeen: string
|
|
23
|
+
/** last observed error line, as evidence (secret-scrubbed) */
|
|
24
|
+
snippet: string
|
|
25
|
+
/** optional human/agent-written guidance shown in reminder/block messages */
|
|
26
|
+
correction?: string
|
|
27
|
+
remindedCount: number
|
|
28
|
+
blockedCount: number
|
|
29
|
+
recurredAfterReminder: number
|
|
30
|
+
/** the core health metric: failures of this pattern AFTER it became a gate */
|
|
31
|
+
recurredAfterGate: number
|
|
32
|
+
/** flagged for manual review when the gate fires often but errors stopped */
|
|
33
|
+
review?: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface GatesFile {
|
|
37
|
+
version: 1
|
|
38
|
+
gates: Gate[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type LogEventType =
|
|
42
|
+
| "detected"
|
|
43
|
+
| "promoted"
|
|
44
|
+
| "reminded"
|
|
45
|
+
| "retry-allowed"
|
|
46
|
+
| "blocked"
|
|
47
|
+
| "override"
|
|
48
|
+
| "expired"
|
|
49
|
+
| "recurred-after-gate"
|
|
50
|
+
| "init"
|
|
51
|
+
|
|
52
|
+
export interface LogEvent {
|
|
53
|
+
type: LogEventType
|
|
54
|
+
key: string
|
|
55
|
+
tool?: string
|
|
56
|
+
session?: string
|
|
57
|
+
project?: string
|
|
58
|
+
snippet?: string
|
|
59
|
+
/** which detection channel fired: metadata exit, bash text scan, or event stream */
|
|
60
|
+
channel?: "exit" | "text" | "event"
|
|
61
|
+
/** raw tool exit code when available */
|
|
62
|
+
exit?: number
|
|
63
|
+
/** how the gate matched the call */
|
|
64
|
+
via?: "exact" | "fuzzy" | "segment"
|
|
65
|
+
/** plugin version (init events) */
|
|
66
|
+
version?: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const MAX_SESSIONS = 50
|
|
70
|
+
const MAX_PROJECTS = 20
|
|
71
|
+
const LOG_ROTATE_BYTES = 512 * 1024
|
|
72
|
+
const LOG_ROTATE_KEEP_LINES = 1000
|
|
73
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
74
|
+
|
|
75
|
+
/** failures required before a pattern becomes an enforced gate */
|
|
76
|
+
export const PROMOTE_COUNT = 3
|
|
77
|
+
/** file-probe tools fail routinely during normal probing — higher bar, never block */
|
|
78
|
+
export const PROMOTE_COUNT_PROBE = 5
|
|
79
|
+
export const PROBE_TOOLS = new Set(["read", "glob", "grep", "write", "edit"])
|
|
80
|
+
/** distinct sessions required — same-session loops never promote */
|
|
81
|
+
export const PROMOTE_SESSIONS = 2
|
|
82
|
+
|
|
83
|
+
// --- Windows-safe fs helpers -------------------------------------------------
|
|
84
|
+
|
|
85
|
+
/** NT long-path prefix so deeply nested project dirs do not hit MAX_PATH. */
|
|
86
|
+
function ntPath(p: string): string {
|
|
87
|
+
if (process.platform !== "win32") return p
|
|
88
|
+
if (p.startsWith("\\\\?\\")) return p
|
|
89
|
+
return `\\\\?\\${p}`
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const RETRYABLE = new Set(["EPERM", "EACCES", "EBUSY"])
|
|
93
|
+
|
|
94
|
+
/** tmp + rename with exponential-backoff retry (Windows AV/indexer locks). */
|
|
95
|
+
async function atomicWrite(path: string, content: string): Promise<void> {
|
|
96
|
+
const tmp = `${path}.${process.pid}.tmp`
|
|
97
|
+
for (let attempt = 0; ; attempt++) {
|
|
98
|
+
try {
|
|
99
|
+
await writeFile(ntPath(tmp), content, "utf8")
|
|
100
|
+
await rename(ntPath(tmp), ntPath(path))
|
|
101
|
+
return
|
|
102
|
+
} catch (error) {
|
|
103
|
+
const code = (error as { code?: string }).code ?? ""
|
|
104
|
+
if (RETRYABLE.has(code) && attempt < 5) {
|
|
105
|
+
await new Promise((resolve) => setTimeout(resolve, 50 * 2 ** attempt))
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
await unlink(ntPath(tmp))
|
|
110
|
+
} catch {
|
|
111
|
+
// orphan tmp is harmless
|
|
112
|
+
}
|
|
113
|
+
throw error
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const LOCK_STALE_MS = 5000
|
|
119
|
+
const LOCK_WAIT_MS = 3000
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Exclusive lockfile ("wx" create) with stale-lock stealing and graceful
|
|
123
|
+
* degradation: if the lock cannot be acquired within LOCK_WAIT_MS the
|
|
124
|
+
* critical section runs unlocked rather than hanging the tool pipeline.
|
|
125
|
+
*/
|
|
126
|
+
async function withLock<T>(lockTarget: string, fn: () => Promise<T>): Promise<T> {
|
|
127
|
+
const lock = `${lockTarget}.lock`
|
|
128
|
+
await mkdir(ntPath(dirname(lock)), { recursive: true })
|
|
129
|
+
const started = Date.now()
|
|
130
|
+
for (;;) {
|
|
131
|
+
try {
|
|
132
|
+
await writeFile(ntPath(lock), String(process.pid), { flag: "wx" })
|
|
133
|
+
break
|
|
134
|
+
} catch (error) {
|
|
135
|
+
const code = (error as { code?: string }).code ?? ""
|
|
136
|
+
if (code !== "EEXIST") throw error
|
|
137
|
+
try {
|
|
138
|
+
const info = await stat(ntPath(lock))
|
|
139
|
+
if (Date.now() - info.mtimeMs > LOCK_STALE_MS) {
|
|
140
|
+
await unlink(ntPath(lock)).catch(() => {})
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
} catch {
|
|
144
|
+
continue // lock vanished between attempts
|
|
145
|
+
}
|
|
146
|
+
if (Date.now() - started > LOCK_WAIT_MS) break
|
|
147
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
return await fn()
|
|
152
|
+
} finally {
|
|
153
|
+
try {
|
|
154
|
+
await unlink(ntPath(lock))
|
|
155
|
+
} catch {
|
|
156
|
+
// best effort
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export class GateStore {
|
|
162
|
+
private gates: Gate[] | null = null
|
|
163
|
+
private mtimeMs = 0
|
|
164
|
+
|
|
165
|
+
constructor(public readonly dir: string) {}
|
|
166
|
+
|
|
167
|
+
private get gatesPath(): string {
|
|
168
|
+
return join(this.dir, "gates.json")
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private get logPath(): string {
|
|
172
|
+
return join(this.dir, "log.jsonl")
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Run a load→mutate→save section under the store's exclusive lock. */
|
|
176
|
+
async runLocked<T>(fn: () => Promise<T>): Promise<T> {
|
|
177
|
+
return withLock(this.gatesPath, fn)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** force=true bypasses the mtime cache (always used inside locks). */
|
|
181
|
+
async load(force = false): Promise<Gate[]> {
|
|
182
|
+
try {
|
|
183
|
+
const info = await stat(ntPath(this.gatesPath))
|
|
184
|
+
if (!force && this.gates !== null && info.mtimeMs === this.mtimeMs) {
|
|
185
|
+
return this.gates
|
|
186
|
+
}
|
|
187
|
+
const raw = await readFile(ntPath(this.gatesPath), "utf8")
|
|
188
|
+
const parsed = JSON.parse(raw) as Partial<GatesFile>
|
|
189
|
+
this.gates = Array.isArray(parsed.gates) ? parsed.gates : []
|
|
190
|
+
this.mtimeMs = info.mtimeMs
|
|
191
|
+
return this.gates
|
|
192
|
+
} catch {
|
|
193
|
+
// missing or unreadable gates.json — treat as an empty store
|
|
194
|
+
if (this.gates === null) this.gates = []
|
|
195
|
+
return this.gates
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async save(): Promise<void> {
|
|
200
|
+
if (this.gates === null) return
|
|
201
|
+
await mkdir(ntPath(this.dir), { recursive: true })
|
|
202
|
+
const payload: GatesFile = { version: 1, gates: this.gates }
|
|
203
|
+
await atomicWrite(this.gatesPath, `${JSON.stringify(payload, null, 2)}\n`)
|
|
204
|
+
try {
|
|
205
|
+
this.mtimeMs = (await stat(ntPath(this.gatesPath))).mtimeMs
|
|
206
|
+
} catch {
|
|
207
|
+
// mtime refresh is best-effort
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async log(event: LogEvent): Promise<void> {
|
|
212
|
+
await mkdir(ntPath(this.dir), { recursive: true })
|
|
213
|
+
const line = `${JSON.stringify({ ts: new Date().toISOString(), ...event })}\n`
|
|
214
|
+
await appendFile(ntPath(this.logPath), line, "utf8")
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Caller must hold the lock. */
|
|
218
|
+
async expire(ttlDays: number): Promise<Gate[]> {
|
|
219
|
+
const gates = await this.load(true)
|
|
220
|
+
const cutoff = Date.now() - ttlDays * DAY_MS
|
|
221
|
+
const expired = gates.filter((g) => Date.parse(g.lastSeen) < cutoff)
|
|
222
|
+
if (expired.length === 0) return []
|
|
223
|
+
this.gates = gates.filter((g) => Date.parse(g.lastSeen) >= cutoff)
|
|
224
|
+
await this.save()
|
|
225
|
+
return expired
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async rotateLog(): Promise<void> {
|
|
229
|
+
try {
|
|
230
|
+
const info = await stat(ntPath(this.logPath))
|
|
231
|
+
if (info.size < LOG_ROTATE_BYTES) return
|
|
232
|
+
const raw = await readFile(ntPath(this.logPath), "utf8")
|
|
233
|
+
const lines = raw.split("\n").filter((l) => l.trim() !== "")
|
|
234
|
+
const kept = lines.slice(-LOG_ROTATE_KEEP_LINES)
|
|
235
|
+
await writeFile(ntPath(this.logPath), `${kept.join("\n")}\n`, "utf8")
|
|
236
|
+
} catch {
|
|
237
|
+
// missing or unreadable log is fine
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Two-scope gate management: project-local gates live in the repo
|
|
244
|
+
* (`.opencode/dejavu/`), cross-project agent habits are promoted to the
|
|
245
|
+
* global store (`~/.config/opencode/dejavu/`).
|
|
246
|
+
*/
|
|
247
|
+
export class Stores {
|
|
248
|
+
constructor(
|
|
249
|
+
public readonly globalStore: GateStore,
|
|
250
|
+
public readonly projectStore: GateStore | null,
|
|
251
|
+
) {}
|
|
252
|
+
|
|
253
|
+
private scopes(): GateStore[] {
|
|
254
|
+
return this.projectStore ? [this.projectStore, this.globalStore] : [this.globalStore]
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** True if a pattern with this key exists in any scope (chain attribution). */
|
|
258
|
+
async hasKey(key: string): Promise<boolean> {
|
|
259
|
+
for (const store of this.scopes()) {
|
|
260
|
+
if ((await store.load()).some((g) => g.key === key)) return true
|
|
261
|
+
}
|
|
262
|
+
return false
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Exact key match first (any status), then fuzzy near-duplicate over blocking gates. */
|
|
266
|
+
async findGate(
|
|
267
|
+
key: string,
|
|
268
|
+
signature: string,
|
|
269
|
+
): Promise<{ gate: Gate; store: GateStore; via: "exact" | "fuzzy" } | null> {
|
|
270
|
+
for (const store of this.scopes()) {
|
|
271
|
+
const exact = (await store.load()).find((g) => g.key === key)
|
|
272
|
+
if (exact) return { gate: exact, store, via: "exact" }
|
|
273
|
+
}
|
|
274
|
+
let best: { gate: Gate; store: GateStore; score: number } | null = null
|
|
275
|
+
for (const store of this.scopes()) {
|
|
276
|
+
for (const gate of await store.load()) {
|
|
277
|
+
if (gate.status !== "blocking") continue
|
|
278
|
+
if (!fuzzySimilar(signature, gate.signature)) continue
|
|
279
|
+
const score = Math.abs(signature.length - gate.signature.length)
|
|
280
|
+
if (best === null || score < best.score) best = { gate, store, score }
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return best === null ? null : { gate: best.gate, store: best.store, via: "fuzzy" }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** All currently enforced gates, project scope first, highest-count first. */
|
|
287
|
+
async blockingGates(): Promise<Gate[]> {
|
|
288
|
+
const result: Gate[] = []
|
|
289
|
+
for (const store of this.scopes()) {
|
|
290
|
+
for (const gate of await store.load()) {
|
|
291
|
+
if (gate.status === "blocking") result.push(gate)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return result.sort((a, b) => b.count - a.count)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async logAll(event: LogEvent): Promise<void> {
|
|
298
|
+
for (const store of this.scopes()) {
|
|
299
|
+
await store.log(event)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async expireAll(ttlDays: number): Promise<void> {
|
|
304
|
+
for (const store of this.scopes()) {
|
|
305
|
+
await store.runLocked(async () => {
|
|
306
|
+
const expired = await store.expire(ttlDays)
|
|
307
|
+
for (const gate of expired) {
|
|
308
|
+
await store.log({ type: "expired", key: gate.key, tool: gate.tool })
|
|
309
|
+
}
|
|
310
|
+
})
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async rotateLogs(): Promise<void> {
|
|
315
|
+
for (const store of this.scopes()) {
|
|
316
|
+
await store.rotateLog()
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* One-time (idempotent) schema/behavior migration:
|
|
322
|
+
* - probe-tool gates never block (they were learned under the old policy)
|
|
323
|
+
* - signatures and snippets are secret-scrubbed (cleans historical leaks)
|
|
324
|
+
*/
|
|
325
|
+
async migrate(): Promise<void> {
|
|
326
|
+
for (const store of this.scopes()) {
|
|
327
|
+
await store.runLocked(async () => {
|
|
328
|
+
const gates = await store.load(true)
|
|
329
|
+
let changed = false
|
|
330
|
+
for (const gate of gates) {
|
|
331
|
+
if (!canBlock(gate.tool, gate.signature) && gate.status === "blocking") {
|
|
332
|
+
gate.status = "watching"
|
|
333
|
+
changed = true
|
|
334
|
+
}
|
|
335
|
+
const signature = scrubSecrets(gate.signature)
|
|
336
|
+
const snippet = scrubSecrets(gate.snippet)
|
|
337
|
+
if (signature !== gate.signature) {
|
|
338
|
+
gate.signature = signature
|
|
339
|
+
changed = true
|
|
340
|
+
}
|
|
341
|
+
if (snippet !== gate.snippet) {
|
|
342
|
+
gate.snippet = snippet
|
|
343
|
+
changed = true
|
|
344
|
+
}
|
|
345
|
+
if (gate.correction !== undefined) {
|
|
346
|
+
const correction = scrubSecrets(gate.correction)
|
|
347
|
+
if (correction !== gate.correction) {
|
|
348
|
+
gate.correction = correction
|
|
349
|
+
changed = true
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (changed) await store.save()
|
|
354
|
+
})
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async recordFailure(input: {
|
|
359
|
+
key: string
|
|
360
|
+
signature: string
|
|
361
|
+
tool: string
|
|
362
|
+
sessionID: string
|
|
363
|
+
projectDir: string
|
|
364
|
+
snippet: string
|
|
365
|
+
globalProjects: number
|
|
366
|
+
}): Promise<{ gate: Gate; store: GateStore; promoted: boolean; wentGlobal: boolean }> {
|
|
367
|
+
const now = new Date().toISOString()
|
|
368
|
+
// Route to the store that already knows this key (cheap unlocked peek).
|
|
369
|
+
let store = this.projectStore ?? this.globalStore
|
|
370
|
+
if (!(await store.load()).some((g) => g.key === input.key)) {
|
|
371
|
+
if ((await this.globalStore.load()).some((g) => g.key === input.key)) {
|
|
372
|
+
store = this.globalStore
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return store.runLocked(async () => {
|
|
377
|
+
const gates = await store.load(true)
|
|
378
|
+
let gate = gates.find((g) => g.key === input.key)
|
|
379
|
+
// Consolidation: same tool + near-duplicate signature merges into the
|
|
380
|
+
// existing pattern instead of fragmenting ("gradlew :x:compiletestjava").
|
|
381
|
+
if (!gate) {
|
|
382
|
+
gate = gates.find((g) => g.tool === input.tool && fuzzySimilar(input.signature, g.signature))
|
|
383
|
+
}
|
|
384
|
+
if (!gate) {
|
|
385
|
+
gate = {
|
|
386
|
+
key: input.key,
|
|
387
|
+
signature: scrubSecrets(input.signature),
|
|
388
|
+
tool: input.tool,
|
|
389
|
+
status: "watching",
|
|
390
|
+
count: 0,
|
|
391
|
+
sessions: [],
|
|
392
|
+
projects: [],
|
|
393
|
+
firstSeen: now,
|
|
394
|
+
lastSeen: now,
|
|
395
|
+
snippet: scrubSecrets(input.snippet),
|
|
396
|
+
remindedCount: 0,
|
|
397
|
+
blockedCount: 0,
|
|
398
|
+
recurredAfterReminder: 0,
|
|
399
|
+
recurredAfterGate: 0,
|
|
400
|
+
}
|
|
401
|
+
gates.push(gate)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
gate.count += 1
|
|
405
|
+
if (!gate.sessions.includes(input.sessionID)) gate.sessions.push(input.sessionID)
|
|
406
|
+
if (gate.sessions.length > MAX_SESSIONS) gate.sessions = gate.sessions.slice(-MAX_SESSIONS)
|
|
407
|
+
if (input.projectDir !== "" && !gate.projects.includes(input.projectDir)) {
|
|
408
|
+
gate.projects.push(input.projectDir)
|
|
409
|
+
if (gate.projects.length > MAX_PROJECTS) gate.projects = gate.projects.slice(-MAX_PROJECTS)
|
|
410
|
+
}
|
|
411
|
+
gate.lastSeen = now
|
|
412
|
+
gate.snippet = scrubSecrets(input.snippet)
|
|
413
|
+
|
|
414
|
+
let promoted = false
|
|
415
|
+
const threshold = PROBE_TOOLS.has(input.tool) ? PROMOTE_COUNT_PROBE : PROMOTE_COUNT
|
|
416
|
+
// Policy: only bash non-diagnostic commands may ever become gates.
|
|
417
|
+
if (
|
|
418
|
+
gate.status === "watching" &&
|
|
419
|
+
canBlock(gate.tool, gate.signature) &&
|
|
420
|
+
gate.count >= threshold &&
|
|
421
|
+
gate.sessions.length >= PROMOTE_SESSIONS
|
|
422
|
+
) {
|
|
423
|
+
gate.status = "blocking"
|
|
424
|
+
promoted = true
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
await store.save()
|
|
428
|
+
|
|
429
|
+
// Scope escalation: a pattern seen in enough distinct project directories
|
|
430
|
+
// is an agent-level habit, not a repo quirk — move it to the global store.
|
|
431
|
+
// Lock order is always project -> global, so no deadlock.
|
|
432
|
+
let wentGlobal = false
|
|
433
|
+
const moved = gate
|
|
434
|
+
if (store !== this.globalStore && this.projectStore && gate.projects.length >= input.globalProjects) {
|
|
435
|
+
const idx = gates.findIndex((g) => g.key === moved.key)
|
|
436
|
+
if (idx >= 0) gates.splice(idx, 1)
|
|
437
|
+
await store.save()
|
|
438
|
+
await this.globalStore.runLocked(async () => {
|
|
439
|
+
const globalGates = await this.globalStore.load(true)
|
|
440
|
+
if (!globalGates.some((g) => g.key === moved.key)) globalGates.push(moved)
|
|
441
|
+
await this.globalStore.save()
|
|
442
|
+
})
|
|
443
|
+
wentGlobal = true
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return { gate: moved, store, promoted, wentGlobal }
|
|
447
|
+
})
|
|
448
|
+
}
|
|
449
|
+
}
|