diff --git a/packages/codegraph/scripts/remediate-attention-backlog.sh b/packages/codegraph/scripts/remediate-attention-backlog.sh
new file mode 100644
index 0000000..1a71444
--- /dev/null
+++ b/packages/codegraph/scripts/remediate-attention-backlog.sh
@@ -0,0 +1,117 @@
+#!/usr/bin/env bash
+#
+# Synapse — one-time remediation of the "Needs attention" backlog
+# (the historical 308 pending reviews / 30 decaying memories on production).
+#
+# Policy: AUTO-APPROVE IN-PLACE (chosen by the operator). It drains the
+# auto-generated review backlog without manual triage:
+# - pending-review memories -> active, staleness reset
+# - all active memories with ssv >= 5 -> staleness reset (clean slate; the
+# kept auto-enqueue restarts its 15-cycle clock)
+# - pending skill_proposals -> dismissed (auto-generated nudges)
+# - pending design_system_scans -> dismissed (auto-generated)
+# - decay metadata clock -> reset to now
+#
+# IMPORTANT — run order:
+# 1. Deploy the new code FIRST so migration 034 (dedup indexes) has run and the
+# new approve/decay logic is live. Otherwise the backlog will simply refill.
+# 2. Then run this script on the box that holds the production DB (Coolify container).
+#
+# It refuses to run without a backup and prints before/after counts.
+#
+# Usage:
+# ./remediate-attention-backlog.sh [/path/to/graph.db]
+# Default DB path: /data/.codegraph/graph.db (Synapse production layout)
+
+set -euo pipefail
+
+DB="${1:-/data/.codegraph/graph.db}"
+
+if ! command -v sqlite3 >/dev/null 2>&1; then
+ echo "ERROR: sqlite3 not found in PATH." >&2
+ exit 1
+fi
+if [[ ! -f "$DB" ]]; then
+ echo "ERROR: database not found at: $DB" >&2
+ echo "Pass the correct path as the first argument." >&2
+ exit 1
+fi
+
+STAMP="$(date +%Y%m%d-%H%M%S)"
+BACKUP="${DB}.bak-pre-attention-remediation-${STAMP}"
+
+echo "==> Database: $DB"
+echo "==> Backup: $BACKUP"
+# .backup is safe on a live WAL DB (consistent snapshot).
+sqlite3 "$DB" ".backup '$BACKUP'"
+echo "==> Backup created."
+
+echo
+echo "==> BEFORE:"
+sqlite3 "$DB" <<'SQL'
+.mode column
+.headers on
+SELECT 'memories.pending-review' AS metric, COUNT(*) AS n FROM memories WHERE status='pending-review'
+UNION ALL SELECT 'memories.active.ssv>=15', COUNT(*) FROM memories WHERE status='active' AND sessions_since_validation>=15
+UNION ALL SELECT 'decaying(real: conf<4 & ssv>=5)', COUNT(*) FROM memories WHERE status='active' AND confidence<4 AND sessions_since_validation>=5 AND id NOT LIKE 'M-_system_%'
+UNION ALL SELECT 'skill_proposals.pending', COUNT(*) FROM skill_proposals WHERE status='pending'
+UNION ALL SELECT 'design_system_scans.pending', COUNT(*) FROM design_system_scans WHERE status='pending';
+SQL
+
+echo
+echo "==> Applying remediation (single transaction)…"
+sqlite3 "$DB" <<'SQL'
+BEGIN;
+
+-- 1. Auto-approve every queued memory in place + reset its staleness so the next
+-- decay cycle does not immediately re-flag it (these were timer-generated, not
+-- human-queued).
+UPDATE memories
+ SET status = 'active',
+ sessions_since_validation = 0,
+ last_validated = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
+ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
+ WHERE status = 'pending-review';
+
+-- 2. Clean slate for the kept auto-enqueue mechanism: reset the staleness clock on
+-- every active memory that had already aged, so the 15-cycle countdown restarts
+-- fresh after the fix instead of immediately re-queuing a wave of old memories.
+UPDATE memories
+ SET sessions_since_validation = 0,
+ updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
+ WHERE status = 'active'
+ AND sessions_since_validation >= 5
+ AND id NOT LIKE 'M-_system_%';
+
+-- 3. Dismiss the auto-generated proposal/scan nudges (content is untouched; they
+-- re-propose only when genuinely warranted now that dedup indexes exist).
+UPDATE skill_proposals SET status = 'dismissed', reviewed_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE status = 'pending';
+UPDATE design_system_scans SET status = 'dismissed' WHERE status = 'pending';
+
+-- 4. Reset the decay clock so the next scheduler tick starts a fresh 24h window.
+UPDATE memories SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = 'M-_system_decay_last_run';
+
+COMMIT;
+SQL
+
+echo "==> Done."
+echo
+echo "==> AFTER:"
+sqlite3 "$DB" <<'SQL'
+.mode column
+.headers on
+SELECT 'memories.pending-review' AS metric, COUNT(*) AS n FROM memories WHERE status='pending-review'
+UNION ALL SELECT 'memories.active.ssv>=15', COUNT(*) FROM memories WHERE status='active' AND sessions_since_validation>=15
+UNION ALL SELECT 'decaying(real: conf<4 & ssv>=5)', COUNT(*) FROM memories WHERE status='active' AND confidence<4 AND sessions_since_validation>=5 AND id NOT LIKE 'M-_system_%'
+UNION ALL SELECT 'skill_proposals.pending', COUNT(*) FROM skill_proposals WHERE status='pending'
+UNION ALL SELECT 'design_system_scans.pending', COUNT(*) FROM design_system_scans WHERE status='pending';
+SQL
+
+echo
+echo "All five counters should now read 0. If anything looks wrong, restore with:"
+echo " cp '$BACKUP' '$DB' # (stop the server first)"
+echo
+echo "NOTE: pending skill/component DRAFTS (skills.status='pending' / ui_components.status='pending')"
+echo "are intentional human/agent submissions and are NOT auto-approved here. Review them in the"
+echo "dashboard, or to also auto-approve them (publishes unreviewed drafts) run:"
+echo " sqlite3 '$DB' \"UPDATE skills SET status='active' WHERE status='pending'; UPDATE ui_components SET status='active' WHERE status='pending';\""
diff --git a/packages/codegraph/src/mcp/http-server.ts b/packages/codegraph/src/mcp/http-server.ts
index 7154d45..193905d 100644
--- a/packages/codegraph/src/mcp/http-server.ts
+++ b/packages/codegraph/src/mcp/http-server.ts
@@ -164,8 +164,20 @@ export function computeAttentionCounts(db: import('better-sqlite3').Database): {
} {
const staleCutoff = new Date(Date.now() - STALE_DAYS * 24 * 60 * 60 * 1000).toISOString()
+ // "Decaying" must mean a memory that has ACTUALLY decayed — not one merely born
+ // below the threshold. New memories are created with low confidence (default 1),
+ // and applyDecay only lowers confidence once sessions_since_validation >= 5, so the
+ // bare `confidence < 4` predicate counted every fresh/unvalidated memory as
+ // "decaying" — definitional noise that could never be cleared. Requiring
+ // ssv >= 5 means the row went through >=5 unvalidated decay cycles. The system
+ // metadata row (M-_system_*) is excluded for parity with allActive().
const decayCount = (db.prepare(
- `SELECT COUNT(*) AS n FROM memories WHERE status = 'active' AND confidence IS NOT NULL AND confidence < 4`
+ `SELECT COUNT(*) AS n FROM memories
+ WHERE status = 'active'
+ AND confidence IS NOT NULL
+ AND confidence < 4
+ AND sessions_since_validation >= 5
+ AND id NOT LIKE 'M-_system_%'`
).get() as { n: number }).n
const staleCount = (db.prepare(
diff --git a/packages/codegraph/src/mcp/routes/memories.ts b/packages/codegraph/src/mcp/routes/memories.ts
index 57f4536..254ea97 100644
--- a/packages/codegraph/src/mcp/routes/memories.ts
+++ b/packages/codegraph/src/mcp/routes/memories.ts
@@ -116,5 +116,23 @@ export function createMemoriesRouter(ctx: RouteContext): Router {
}
})
+ // Non-destructive "Reinforce / Keep" action: bumps confidence +1 and resets the
+ // staleness counter so a valuable low-confidence memory can be rehabilitated from
+ // the dashboard instead of only Deprecate/Delete. Also doubles as the human
+ // counterpart to the auto-decay's pending-review flagging.
+ router.post('/api/memories/:id/reinforce', (req, res) => {
+ const userId = (req as any).userId as string | undefined
+ try {
+ const db = openDb(ctx.skillbrainRoot)
+ const store = new MemoryStore(db)
+ const ok = store.reinforce(req.params.id, userId)
+ closeDb(db)
+ if (!ok) { res.status(404).json({ error: 'Memory not found' }); return }
+ res.json({ ok: true })
+ } catch (err: any) {
+ res.status(500).json({ error: err.message })
+ }
+ })
+
return router
}
diff --git a/packages/codegraph/src/mcp/routes/review.ts b/packages/codegraph/src/mcp/routes/review.ts
index 65101e9..cce8fcf 100644
--- a/packages/codegraph/src/mcp/routes/review.ts
+++ b/packages/codegraph/src/mcp/routes/review.ts
@@ -60,8 +60,15 @@ export function createReviewRouter(ctx: RouteContext): Router {
router.post('/api/review/memory/:id/approve', (req, res) => {
const db = openDb(ctx.skillbrainRoot)
const now = new Date().toISOString()
- db.prepare(`UPDATE memories SET status = 'active', updated_at = ? WHERE id = ?`)
- .run(now, req.params.id)
+ // Reset the staleness counter on approve. A memory lands in 'pending-review'
+ // because markPendingReview fired (sessions_since_validation >= 15). If we only
+ // flip status back to 'active', the very next 24h decay cycle increments ssv and
+ // markPendingReview re-flags it — so the approval never sticks and the queue never
+ // drains. Treating a human approval as a validation event (ssv=0 + last_validated)
+ // mirrors reinforceMemory, minus the confidence bump (approve = "leave it", not
+ // "this proved useful"). The memory must now age 15 fresh cycles before re-queuing.
+ db.prepare(`UPDATE memories SET status = 'active', sessions_since_validation = 0, last_validated = ?, updated_at = ? WHERE id = ?`)
+ .run(now, now, req.params.id)
new AuditStore(db).log({ entityType: 'memory', entityId: req.params.id, action: 'approve', reviewedBy: (req as any).userId ?? 'unknown' })
closeDb(db)
res.json({ ok: true })
diff --git a/packages/codegraph/src/mcp/tools/sessions.ts b/packages/codegraph/src/mcp/tools/sessions.ts
index 36415d9..1ee9fe6 100644
--- a/packages/codegraph/src/mcp/tools/sessions.ts
+++ b/packages/codegraph/src/mcp/tools/sessions.ts
@@ -185,10 +185,15 @@ export function registerSessionTools(server: McpServer, ctx: ToolContext): void
if (memIds.length >= 2) {
const propId = `SP-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
try {
- ;(store as any).db.prepare(
+ // The partial unique index idx_skill_proposals_open (migration 034)
+ // enforces one OPEN proposal per skill, so OR IGNORE now actually
+ // dedups across sessions. Only report a proposal as "created" when a
+ // row was truly inserted — otherwise every session would re-announce
+ // the same pending proposal that nothing cleared.
+ const info = (store as any).db.prepare(
`INSERT OR IGNORE INTO skill_proposals (id, skill_name, session_id, memory_ids) VALUES (?, ?, ?, ?)`
).run(propId, skillName, sessionId, JSON.stringify(memIds))
- created.push(skillName)
+ if (info.changes > 0) created.push(skillName)
} catch { /* table may not exist yet in old DBs */ }
}
}
diff --git a/packages/codegraph/tests/attention-counts.test.ts b/packages/codegraph/tests/attention-counts.test.ts
index 7d128db..6caa9e8 100644
--- a/packages/codegraph/tests/attention-counts.test.ts
+++ b/packages/codegraph/tests/attention-counts.test.ts
@@ -40,21 +40,43 @@ describe('computeAttentionCounts', () => {
expect(result.pendingReviews).toBe(0)
})
- it('counts decayCount: active memories with confidence < 4', () => {
- // confidence 3 (active) — should be counted
- store.add({ type: 'Pattern', context: 'ctx-decay', problem: '', solution: 'sol', reason: '', tags: [], confidence: 3 })
- // confidence 4 — boundary, should NOT be counted (< 4, not <= 4)
- store.add({ type: 'Pattern', context: 'ctx-boundary', problem: '', solution: 'sol', reason: '', tags: [], confidence: 4 })
+ it('counts decayCount: active memories with confidence < 4 that have ACTUALLY decayed (ssv >= 5)', () => {
+ // confidence 3, aged (ssv >= 5) — should be counted (real decay)
+ const m1 = store.add({ type: 'Pattern', context: 'ctx-decay', problem: '', solution: 'sol', reason: '', tags: [], confidence: 3 })
+ db.prepare('UPDATE memories SET sessions_since_validation = 6 WHERE id = ?').run(m1.id)
+ // confidence 4, aged — boundary, should NOT be counted (< 4, not <= 4)
+ const m2 = store.add({ type: 'Pattern', context: 'ctx-boundary', problem: '', solution: 'sol', reason: '', tags: [], confidence: 4 })
+ db.prepare('UPDATE memories SET sessions_since_validation = 6 WHERE id = ?').run(m2.id)
// confidence 7 — should NOT be counted
store.add({ type: 'Pattern', context: 'ctx-healthy', problem: '', solution: 'sol', reason: '', tags: [], confidence: 7 })
// confidence 2, deprecated — should NOT be counted (not active)
const m4 = store.add({ type: 'Pattern', context: 'ctx-dep', problem: '', solution: 'sol', reason: '', tags: [], confidence: 2 })
- db.prepare("UPDATE memories SET status = 'deprecated' WHERE id = ?").run(m4.id)
+ db.prepare("UPDATE memories SET status = 'deprecated', sessions_since_validation = 6 WHERE id = ?").run(m4.id)
const result = computeAttentionCounts(db)
expect(result.decayCount).toBe(1)
})
+ it('decayCount excludes BORN-low-confidence memories that have not aged (ssv < 5)', () => {
+ // Fresh low-confidence memories (the common case: created at default/seed confidence)
+ // are NOT decay — they have ssv 0/1 and must not pollute the "decaying" signal.
+ store.add({ type: 'Pattern', context: 'born-low-1', problem: '', solution: 'sol', reason: '', tags: [], confidence: 1 })
+ store.add({ type: 'Pattern', context: 'born-low-2', problem: '', solution: 'sol', reason: '', tags: [], confidence: 3 })
+
+ const result = computeAttentionCounts(db)
+ expect(result.decayCount).toBe(0)
+ })
+
+ it('decayCount excludes the M-_system_ metadata row even if low confidence', () => {
+ const m = store.add({ type: 'Fact', context: 'sys', problem: '', solution: 'sol', reason: '', tags: [], confidence: 1 })
+ // Rename to a system id and age it — must still be excluded.
+ db.prepare("UPDATE memories SET id = 'M-_system_decay_last_run', sessions_since_validation = 99 WHERE id = ?").run(m.id)
+ db.prepare("UPDATE memories SET confidence = 1 WHERE id = 'M-_system_decay_last_run'")
+
+ const result = computeAttentionCounts(db)
+ expect(result.decayCount).toBe(0)
+ })
+
it('counts staleCount: active memories with COALESCE(updated_at, created_at) older than 90 days', () => {
const old = new Date(Date.now() - 91 * 24 * 60 * 60 * 1000).toISOString()
const recent = new Date().toISOString()
diff --git a/packages/codegraph/tests/attention-reappearance-fix.test.ts b/packages/codegraph/tests/attention-reappearance-fix.test.ts
new file mode 100644
index 0000000..7e782b4
--- /dev/null
+++ b/packages/codegraph/tests/attention-reappearance-fix.test.ts
@@ -0,0 +1,173 @@
+/*
+ * Synapse — The intelligence layer for AI workflows
+ * Copyright (c) 2026 Daniel De Vecchi
+ *
+ * Licensed under AGPL-3.0-or-later.
+ * See LICENSE for details.
+ *
+ * Commercial license: daniel@pixarts.eu
+ */
+
+// Regression tests for the "Needs attention items keep reappearing" bug cluster.
+// See the root-cause analysis: approve didn't reset staleness (memories bounced back
+// to pending-review every decay cycle), skill upsert wiped autolearning state, and
+// session_end re-created duplicate skill_proposals forever.
+
+import { describe, it, expect, beforeEach, afterEach } from 'vitest'
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { openDb, closeDb, runMigrations, MemoryStore, SkillsStore } from '@skillbrain/storage'
+import type Database from 'better-sqlite3'
+
+describe('attention reappearance fixes', () => {
+ let dir: string
+ let db: Database.Database
+
+ beforeEach(() => {
+ dir = mkdtempSync(join(tmpdir(), 'sb-reappear-'))
+ db = openDb(dir)
+ runMigrations(db)
+ })
+
+ afterEach(() => {
+ closeDb(db)
+ rmSync(dir, { recursive: true, force: true })
+ })
+
+ // The exact SQL the dashboard approve endpoint runs (review.ts).
+ const approveMemory = (id: string) => {
+ const now = new Date().toISOString()
+ db.prepare(
+ `UPDATE memories SET status = 'active', sessions_since_validation = 0, last_validated = ?, updated_at = ? WHERE id = ?`
+ ).run(now, now, id)
+ }
+
+ describe('FIX 1.1 — approve resets staleness so the memory does not bounce back', () => {
+ it('an approved memory stays active across the next decay cycle', () => {
+ const store = new MemoryStore(db)
+ const m = store.add({ type: 'Pattern', context: 'bounce', problem: '', solution: 'sol', reason: '', tags: [] })
+ // Simulate decay having pushed it past the pending-review threshold.
+ db.prepare("UPDATE memories SET status = 'pending-review', sessions_since_validation = 15 WHERE id = ?").run(m.id)
+
+ approveMemory(m.id)
+ expect(store.get(m.id)!.status).toBe('active')
+ expect(store.get(m.id)!.sessionsSinceValidation).toBe(0)
+
+ // Next 24h auto-decay cycle (no validated ids).
+ store.applyDecay([], new Date().toISOString().split('T')[0])
+
+ // The fix: counter is now 1, well below 15, so it is NOT re-flagged.
+ expect(store.get(m.id)!.status).toBe('active')
+ })
+
+ it('WITHOUT the staleness reset the memory bounces straight back (proves the bug)', () => {
+ const store = new MemoryStore(db)
+ const m = store.add({ type: 'Pattern', context: 'bug', problem: '', solution: 'sol', reason: '', tags: [] })
+ db.prepare("UPDATE memories SET status = 'pending-review', sessions_since_validation = 15 WHERE id = ?").run(m.id)
+
+ // Old approve behaviour: flip status only, leave ssv untouched.
+ db.prepare("UPDATE memories SET status = 'active', updated_at = ? WHERE id = ?").run(new Date().toISOString(), m.id)
+ store.applyDecay([], new Date().toISOString().split('T')[0])
+
+ // ssv went 15 -> 16, markPendingReview re-flagged it.
+ expect(store.get(m.id)!.status).toBe('pending-review')
+ })
+ })
+
+ describe('FIX 2.2 — manual reinforce', () => {
+ it('bumps confidence +1 and resets the staleness counter', () => {
+ const store = new MemoryStore(db)
+ const m = store.add({ type: 'Pattern', context: 'reinf', problem: '', solution: 'sol', reason: '', tags: [], confidence: 2 })
+ db.prepare('UPDATE memories SET sessions_since_validation = 20 WHERE id = ?').run(m.id)
+
+ expect(store.reinforce(m.id, 'tester')).toBe(true)
+ const after = store.get(m.id)!
+ expect(after.confidence).toBe(3)
+ expect(after.sessionsSinceValidation).toBe(0)
+ })
+
+ it('returns false for an unknown id', () => {
+ const store = new MemoryStore(db)
+ expect(store.reinforce('M-does-not-exist')).toBe(false)
+ })
+ })
+
+ describe('FIX 3.1/3.2 — skill upsert preserves autolearning state and status on replace', () => {
+ const baseSkill = (over: Record = {}) => ({
+ name: 'nextjs', category: 'frontend', description: 'desc', content: '# c',
+ type: 'domain' as const, tags: [] as string[], lines: 1,
+ updatedAt: new Date().toISOString(),
+ ...over,
+ })
+
+ it('keeps confidence/usage/useful/ssv/last_validated across a metadata re-upsert', () => {
+ const store = new SkillsStore(db)
+ store.upsert(baseSkill({ status: 'active' }) as any)
+ const ts = new Date().toISOString()
+ db.prepare(
+ `UPDATE skills SET confidence = 9, usage_count = 42, useful_count = 7, sessions_since_validation = 3, last_validated = ? WHERE name = 'nextjs'`
+ ).run(ts)
+
+ // Re-upsert with NO autolearning fields (dashboard edit / re-import shape).
+ store.upsert(baseSkill({ status: 'active', category: 'web', content: '# new', lines: 2 }) as any)
+
+ const row = db.prepare('SELECT * FROM skills WHERE name = ?').get('nextjs') as any
+ expect(row.confidence).toBe(9)
+ expect(row.usage_count).toBe(42)
+ expect(row.useful_count).toBe(7)
+ expect(row.sessions_since_validation).toBe(3)
+ expect(row.last_validated).toBe(ts)
+ // ...while the metadata the caller DID pass is updated.
+ expect(row.category).toBe('web')
+ expect(row.content).toBe('# new')
+ })
+
+ it('re-import (no status passed) preserves a deprecated skill instead of resurrecting it', () => {
+ const store = new SkillsStore(db)
+ store.upsert(baseSkill({ status: 'active' }) as any)
+ db.prepare("UPDATE skills SET status = 'deprecated' WHERE name = 'nextjs'").run()
+
+ // importSkills builds Skill objects WITHOUT a status field.
+ store.upsert(baseSkill() as any)
+
+ const row = db.prepare('SELECT status FROM skills WHERE name = ?').get('nextjs') as any
+ expect(row.status).toBe('deprecated')
+ })
+
+ it('a brand-new skill with no status still defaults to active', () => {
+ const store = new SkillsStore(db)
+ store.upsert(baseSkill({ name: 'fresh-skill' }) as any)
+ const row = db.prepare('SELECT status FROM skills WHERE name = ?').get('fresh-skill') as any
+ expect(row.status).toBe('active')
+ })
+ })
+
+ describe('FIX 1.3 — skill_proposals dedup (migration 034)', () => {
+ it('creates a partial unique index on open proposals', () => {
+ const idx = db.prepare(
+ "SELECT name FROM sqlite_master WHERE type='index' AND name='idx_skill_proposals_open'"
+ ).get()
+ expect(idx).toBeTruthy()
+ })
+
+ it('INSERT OR IGNORE dedups a second OPEN proposal for the same skill', () => {
+ const ins = (id: string, sid: string) =>
+ db.prepare(
+ `INSERT OR IGNORE INTO skill_proposals (id, skill_name, session_id, memory_ids) VALUES (?, ?, ?, '[]')`
+ ).run(id, 'nextjs', sid)
+
+ expect(ins('SP-1', 's1').changes).toBe(1)
+ expect(ins('SP-2', 's2').changes).toBe(0) // deduped — already an open proposal
+
+ const open = db.prepare(
+ "SELECT COUNT(*) AS n FROM skill_proposals WHERE skill_name = 'nextjs' AND status = 'pending'"
+ ).get() as { n: number }
+ expect(open.n).toBe(1)
+
+ // Once the open one is actioned, a fresh proposal is allowed again.
+ db.prepare("UPDATE skill_proposals SET status = 'dismissed' WHERE id = 'SP-1'").run()
+ expect(ins('SP-3', 's3').changes).toBe(1)
+ })
+ })
+})
diff --git a/packages/storage/src/components-store.ts b/packages/storage/src/components-store.ts
index 89b8636..2d32421 100644
--- a/packages/storage/src/components-store.ts
+++ b/packages/storage/src/components-store.ts
@@ -410,6 +410,28 @@ export class ComponentsStore {
conflicts: Conflict[]
}): DesignSystemScan {
const now = new Date().toISOString()
+ // One OPEN scan per project (partial unique index idx_dss_open, migration 034).
+ // session_start re-scans on every run; without this, an unresolved-conflict
+ // project stacked a brand-new pending scan each session and flooded the review
+ // queue. If an open scan already exists, refresh it in place with the latest
+ // conflict data instead of inserting a duplicate.
+ const existing = this.db.prepare(
+ `SELECT id FROM design_system_scans WHERE project = ? AND status = 'pending' LIMIT 1`
+ ).get(scan.project) as { id: string } | undefined
+ if (existing) {
+ this.db.prepare(`
+ UPDATE design_system_scans
+ SET scanned_at = ?, sources = ?, merged = ?, conflicts = ?
+ WHERE id = ?
+ `).run(
+ now,
+ JSON.stringify(scan.sources),
+ JSON.stringify(scan.merged),
+ JSON.stringify(scan.conflicts),
+ existing.id,
+ )
+ return { id: existing.id, scannedAt: now, status: 'pending' as const, ...scan }
+ }
const id = `DSS-${randomId()}`
this.db.prepare(`
INSERT INTO design_system_scans (id, project, scanned_at, sources, merged, conflicts, status)
diff --git a/packages/storage/src/memory-store.ts b/packages/storage/src/memory-store.ts
index d4091bb..1f90dc7 100644
--- a/packages/storage/src/memory-store.ts
+++ b/packages/storage/src/memory-store.ts
@@ -760,6 +760,23 @@ export class MemoryStore {
// ── Decay ─────────────────────────────────────────
+ /**
+ * Manually reinforce a single memory — the non-destructive "Keep / Reinforce"
+ * action exposed in the dashboard. Bumps confidence +1 (capped at 10), resets
+ * sessions_since_validation to 0, and appends `by` to validated_by. This is the
+ * only dashboard path that can RAISE a memory's confidence; previously the UI
+ * could only Deprecate/Delete, so a valuable-but-low-confidence memory had no
+ * non-destructive remedy. Returns false if the id does not exist.
+ */
+ reinforce(id: string, by?: string): boolean {
+ const mem = this.get(id)
+ if (!mem) return false
+ const now = new Date().toISOString()
+ const newValidatedBy = [...mem.validatedBy, by ?? 'dashboard']
+ this.stmts.reinforceMemory.run(now, JSON.stringify(newValidatedBy), now, id)
+ return true
+ }
+
applyDecay(validatedIds: string[], sessionDate: string): DecayResult {
const now = new Date().toISOString()
let reinforced = 0
diff --git a/packages/storage/src/migrations/034_review_queue_dedup.sql b/packages/storage/src/migrations/034_review_queue_dedup.sql
new file mode 100644
index 0000000..f7356c5
--- /dev/null
+++ b/packages/storage/src/migrations/034_review_queue_dedup.sql
@@ -0,0 +1,33 @@
+-- 034 — Dedup guards for the auto-generated review queues
+-- Why: session_end re-creates skill_proposals on EVERY session, and session_start
+-- re-creates design_system_scans on every scan with unresolved token conflicts.
+-- Both use `INSERT OR IGNORE` but with a fresh-random primary-key id each call, so
+-- OR IGNORE never actually deduped — it only guarded against a missing table. The
+-- pending "Needs attention" queue therefore grew without bound (the primary driver
+-- of the perpetually-high "Pending reviews" count on the dashboard).
+--
+-- Fix: a PARTIAL UNIQUE INDEX scoped to status='pending' makes "at most one OPEN
+-- item per key" an invariant, so INSERT OR IGNORE finally fires when an open item
+-- already exists. dismissed/applied rows are excluded from the index, so a skill or
+-- project can be re-proposed once its previous proposal has been actioned.
+
+-- Collapse any pre-existing duplicate OPEN proposals to the earliest per skill first;
+-- otherwise creating the unique index would fail on the existing duplicates.
+DELETE FROM skill_proposals
+ WHERE status = 'pending'
+ AND id NOT IN (
+ SELECT MIN(id) FROM skill_proposals WHERE status = 'pending' GROUP BY skill_name
+ );
+
+CREATE UNIQUE INDEX IF NOT EXISTS idx_skill_proposals_open
+ ON skill_proposals(skill_name) WHERE status = 'pending';
+
+-- Same treatment for design-system scans: one open scan per project at a time.
+DELETE FROM design_system_scans
+ WHERE status = 'pending'
+ AND id NOT IN (
+ SELECT MIN(id) FROM design_system_scans WHERE status = 'pending' GROUP BY project
+ );
+
+CREATE UNIQUE INDEX IF NOT EXISTS idx_dss_open
+ ON design_system_scans(project) WHERE status = 'pending';
diff --git a/packages/storage/src/skills-store.ts b/packages/storage/src/skills-store.ts
index 5b5b136..99546da 100644
--- a/packages/storage/src/skills-store.ts
+++ b/packages/storage/src/skills-store.ts
@@ -105,9 +105,33 @@ export class SkillsStore {
private prepareStatements() {
return {
+ // INSERT OR REPLACE deletes the existing row and inserts a fresh one, so any
+ // column NOT listed here reverts to its schema default. The original statement
+ // omitted the five autolearning columns (confidence, usage_count, useful_count,
+ // sessions_since_validation, last_validated), silently wiping all skill decay/
+ // reinforcement state on EVERY write — re-import, dashboard edit, skill_update,
+ // and proposal-apply. We COALESCE each from the existing row (same pattern the
+ // statement already used for created_by_user_id) so accumulated learning
+ // survives a replace. `status` is likewise COALESCE'd: callers that pass an
+ // explicit status (dashboard edit, skill_add/update, soft-delete, proposal
+ // apply) keep that value; the importer passes NULL, so a re-import preserves the
+ // skill's current status instead of forcing every skill back to 'active'
+ // (which previously resurrected soft-deleted/deprecated skills and auto-approved
+ // pending ones). New skills (no existing row) still default to 'active'.
upsert: this.db.prepare(`
- INSERT OR REPLACE INTO skills (name, category, description, content, type, tags, lines, updated_at, status, created_by_user_id, updated_by_user_id)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_by_user_id FROM skills WHERE name = ?), ?), ?)
+ INSERT OR REPLACE INTO skills
+ (name, category, description, content, type, tags, lines, updated_at, status,
+ created_by_user_id, updated_by_user_id,
+ confidence, usage_count, useful_count, sessions_since_validation, last_validated)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?,
+ COALESCE(?, (SELECT status FROM skills WHERE name = ?), 'active'),
+ COALESCE((SELECT created_by_user_id FROM skills WHERE name = ?), ?),
+ ?,
+ COALESCE((SELECT confidence FROM skills WHERE name = ?), 5),
+ COALESCE((SELECT usage_count FROM skills WHERE name = ?), 0),
+ COALESCE((SELECT useful_count FROM skills WHERE name = ?), 0),
+ COALESCE((SELECT sessions_since_validation FROM skills WHERE name = ?), 0),
+ (SELECT last_validated FROM skills WHERE name = ?))
`),
get: this.db.prepare('SELECT * FROM skills WHERE name = ?'),
getUpdatedAt: this.db.prepare('SELECT updated_at FROM skills WHERE name = ?'),
@@ -262,9 +286,14 @@ export class SkillsStore {
this.stmts.upsert.run(
skill.name, skill.category, skill.description, skill.content,
skill.type, JSON.stringify(skill.tags), skill.lines, skill.updatedAt,
- skill.status ?? 'active',
- skill.name, skill.createdByUserId ?? null,
- changedBy ?? null,
+ skill.status ?? null, skill.name, // status: explicit value, else preserve existing, else 'active'
+ skill.name, skill.createdByUserId ?? null, // created_by_user_id: preserve existing on replace
+ changedBy ?? null, // updated_by_user_id
+ skill.name, // confidence: preserve
+ skill.name, // usage_count: preserve
+ skill.name, // useful_count: preserve
+ skill.name, // sessions_since_validation: preserve
+ skill.name, // last_validated: preserve
)
this.saveVersion(skill, changedBy ?? null, reason)