diff --git a/src/memory-quality.ts b/src/memory-quality.ts index bf046b8..c473b8b 100644 --- a/src/memory-quality.ts +++ b/src/memory-quality.ts @@ -9,6 +9,21 @@ export type MemoryQualityResult = { reasons: string[]; }; +export const HARD_QUALITY_REASONS: ReadonlySet = new Set([ + "empty", + "progress_snapshot", + "raw_error", + "commit_or_ci_snapshot", + "temporary_status", + "active_file_snapshot", + "code_or_api_signature", + "path_heavy", +]); + +export function isHardQualityReason(reason: string): boolean { + return HARD_QUALITY_REASONS.has(reason); +} + export function assessMemoryQuality(entry: MemoryQualityInput): MemoryQualityResult { const reasons: string[] = []; const text = entry.text.trim(); diff --git a/src/workspace-memory.ts b/src/workspace-memory.ts index af64f0d..3000d7f 100644 --- a/src/workspace-memory.ts +++ b/src/workspace-memory.ts @@ -2,7 +2,7 @@ import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "./types.ts"; import { LONG_TERM_LIMITS } from "./types.ts"; import { workspaceKey, workspaceMemoryPath } from "./paths.ts"; import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts"; -import { assessMemoryQuality, isProgressSnapshotViolation } from "./memory-quality.ts"; +import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "./memory-quality.ts"; // Minimum length for workspace_memory envelope: \n...\n const MIN_ENVELOPE_LENGTH = 80; @@ -188,9 +188,11 @@ export async function normalizeWorkspaceMemoryWithAccounting( }; }); - // One-time migration for legacy snapshot violations - result = runMigrationP0Cleanup(result, nowIso); + // One-time migrations for legacy/low-quality snapshot violations. + // Run quality cleanup first so hard violations receive quality audit tags + // before the older P0 project-only cleanup marks progress snapshots. result = runMigrationQualityCleanup(result, nowIso); + result = runMigrationP0Cleanup(result, nowIso); // P0 accounting only considers active entries. Entries that were already // superseded before this normalization are preserved in storage; entries that @@ -285,7 +287,7 @@ export function runMigrationP0Cleanup( }; } -function runMigrationQualityCleanup( +export function runMigrationQualityCleanup( store: WorkspaceMemoryStore, nowIso: string, ): WorkspaceMemoryStore { @@ -301,11 +303,14 @@ function runMigrationQualityCleanup( const quality = assessMemoryQuality(entry); if (quality.accepted) return entry; + const hardReasons = quality.reasons.filter(isHardQualityReason); + if (hardReasons.length === 0) return entry; + changed = true; const tags = new Set([ ...(entry.tags ?? []), "quality_cleanup", - ...quality.reasons.map(reason => `quality:${reason}`), + ...hardReasons.map(reason => `quality:${reason}`), ]); return { diff --git a/tests/memory-quality-eval.test.ts b/tests/memory-quality-eval.test.ts index cb8e3e8..c51c0a7 100644 --- a/tests/memory-quality-eval.test.ts +++ b/tests/memory-quality-eval.test.ts @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { extractExplicitMemories, parseWorkspaceMemoryCandidates } from "../src/extractors.ts"; -import { assessMemoryQuality } from "../src/memory-quality.ts"; +import { assessMemoryQuality, isHardQualityReason } from "../src/memory-quality.ts"; import { expectedAcceptedFixtureIds, reviewerCurrent28Fixture } from "./fixtures/memory-quality-current-28.ts"; const acceptedCases = [ @@ -159,3 +159,17 @@ test("explicit memories bypass extraction quality gate", () => { assert.equal(entries[0].source, "explicit"); assert.match(entries[0].text, /Wave 1 completed/); }); + +test("hard quality reasons exclude soft whitelist failures", () => { + assert.equal(isHardQualityReason("progress_snapshot"), true); + assert.equal(isHardQualityReason("raw_error"), true); + assert.equal(isHardQualityReason("commit_or_ci_snapshot"), true); + assert.equal(isHardQualityReason("temporary_status"), true); + assert.equal(isHardQualityReason("active_file_snapshot"), true); + assert.equal(isHardQualityReason("code_or_api_signature"), true); + assert.equal(isHardQualityReason("path_heavy"), true); + assert.equal(isHardQualityReason("empty"), true); + + assert.equal(isHardQualityReason("bad_feedback"), false); + assert.equal(isHardQualityReason("bad_decision"), false); +}); diff --git a/tests/workspace-memory.test.ts b/tests/workspace-memory.test.ts index 6898e29..04ac928 100644 --- a/tests/workspace-memory.test.ts +++ b/tests/workspace-memory.test.ts @@ -20,8 +20,8 @@ import { saveWorkspaceMemory, updateWorkspaceMemoryWithAccounting, } from "../src/workspace-memory.ts"; -import { isProgressSnapshotViolation } from "../src/memory-quality.ts"; -import { reviewerCurrent28Fixture, expectedAcceptedFixtureIds } from "./fixtures/memory-quality-current-28.ts"; +import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "../src/memory-quality.ts"; +import { reviewerCurrent28Fixture } from "./fixtures/memory-quality-current-28.ts"; function entry(id: string, text: string, type: LongTermMemoryEntry["type"] = "decision"): LongTermMemoryEntry { const now = new Date().toISOString(); @@ -954,7 +954,84 @@ test("runMigrationP0Cleanup marks only non-explicit project snapshots and runs o assert.equal(twice.entries.find(e => e.id === "project-snapshot")?.updatedAt, once.entries.find(e => e.id === "project-snapshot")?.updatedAt); }); -test("quality cleanup migration supersedes low-quality compaction memories from current-28 fixture", async () => { +test("quality cleanup migration preserves soft-only feedback and decision violations", async () => { + const root = await mkdtemp(join(tmpdir(), "wm-quality-soft-preserve-")); + try { + const now = new Date().toISOString(); + await saveWorkspaceMemory(root, { + version: 1, + workspace: { root, key: await workspaceKey(root) }, + limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries }, + entries: [ + { + id: "soft_feedback", + type: "feedback", + text: "UI 要統一風格:兩個表格都要 scrollable,約 20 rows", + source: "compaction", + confidence: 0.75, + status: "active", + createdAt: now, + updatedAt: now, + }, + { + id: "soft_decision", + type: "decision", + text: "Product branding is \"OpenCode Working Memory\" without \"Plugin\" in the name", + source: "compaction", + confidence: 0.75, + status: "active", + createdAt: now, + updatedAt: now, + staleAfterDays: 45, + }, + ], + migrations: [], + updatedAt: now, + }); + + const loaded = await loadWorkspaceMemory(root); + assert.equal(loaded.entries.find(e => e.id === "soft_feedback")?.status, "active"); + assert.equal(loaded.entries.find(e => e.id === "soft_decision")?.status, "active"); + assert.ok(loaded.migrations?.includes("2026-04-28-quality-cleanup")); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("quality cleanup migration supersedes hard quality violations", async () => { + const root = await mkdtemp(join(tmpdir(), "wm-quality-hard-supersede-")); + try { + const now = new Date().toISOString(); + await saveWorkspaceMemory(root, { + version: 1, + workspace: { root, key: await workspaceKey(root) }, + limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries }, + entries: [{ + id: "hard_progress", + type: "project", + text: "測試套件:1237 tests pass, 226 suites", + source: "compaction", + confidence: 0.75, + status: "active", + createdAt: now, + updatedAt: now, + staleAfterDays: 60, + }], + migrations: [], + updatedAt: now, + }); + + const loaded = await loadWorkspaceMemory(root); + const entry = loaded.entries.find(e => e.id === "hard_progress"); + assert.equal(entry?.status, "superseded"); + assert.ok(entry?.tags?.includes("quality_cleanup")); + assert.ok(entry?.tags?.includes("quality:progress_snapshot")); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("quality cleanup migration supersedes only hard violations from current fixture", async () => { const root = await mkdtemp(join(tmpdir(), "wm-quality-cleanup-")); try { const now = new Date().toISOString(); @@ -972,10 +1049,12 @@ test("quality cleanup migration supersedes low-quality compaction memories from const supersededIds = new Set(loaded.entries.filter(entry => entry.status === "superseded").map(entry => entry.id)); for (const entry of reviewerCurrent28Fixture) { - if (expectedAcceptedFixtureIds.has(entry.id)) { - assert.equal(activeIds.has(entry.id), true, `${entry.id} should remain active`); - } else { + const quality = assessMemoryQuality(entry); + const hasHardReason = quality.reasons.some(isHardQualityReason); + if (entry.source === "compaction" && !quality.accepted && hasHardReason) { assert.equal(supersededIds.has(entry.id), true, `${entry.id} should be superseded`); + } else { + assert.equal(activeIds.has(entry.id), true, `${entry.id} should remain active`); } }