mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-07-17 12:56:42 +02:00
feat(reinforcement): compaction prompt wording reuse, migration evidence, and validation baseline
Wave 1 — Compaction prompt improvement: - Add three wording-reuse bullets to buildCompactionPrompt() under CRITICAL MEMORY RULES: do not create rephrased duplicates, reuse existing wording exactly when re-emitting, only emit new memories when the fact is new, materially corrected, or more specific. - This attacks the root cause of zero reinforcement: compaction generating variant text for the same durable fact. Wave 2 — Bug fixes: - Bug #2: Add placeholder comment to superseded_existing branch in decision dedupe (unreachable until v1.5.4 numbered refs). Preserve as const type assertions. - Bug #3: Add memory_migration_superseded evidence event type. Both P0 and quality cleanup migrations now produce evidence events for superseded entries. loadWorkspaceMemory appends migration evidence on first-load migrations only (idempotent via migration IDs). No historical backfill. - Bug #4: Add documentation comment explaining that feedback identity key returns exact key (absorbed_identity currently impossible for feedback). Add test verifying this behavior. Wave 3 — Validation baseline script: - Add scripts/dev/validate-identity-keys.ts: read-only script that scans workspace memory stores, computes exact/identity key collisions, and reports reinforcement statistics. Baseline matches audit: 0 exact collisions, 0 identity collisions, 0 reinforcement events across 123 active memories. Identity extension is gated on measurement: if the prompt change produces measurable reinforcement (reinforcementCount > 0), identity extension may be unnecessary. Decision dedupe stays exact-only (Wave 4 deferred).
This commit is contained in:
@@ -0,0 +1,368 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Read-only baseline report for workspace-memory exact/identity key collisions.
|
||||
*
|
||||
* This script reads workspace-memory.json files from the local workspace memory
|
||||
* data directory. It never writes to workspace memory stores; the only optional
|
||||
* write is the explicit --json report path.
|
||||
*/
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import { workspaceMemoryExactKey, workspaceMemoryIdentityKey } from "../../src/workspace-memory.ts";
|
||||
import type { LongTermMemoryEntry, LongTermSource, LongTermType } from "../../src/types.ts";
|
||||
|
||||
type CliOptions = {
|
||||
workspacesDir: string;
|
||||
jsonPath?: string;
|
||||
};
|
||||
|
||||
type ReinforcementCountStatistics = {
|
||||
min: number;
|
||||
max: number;
|
||||
mean: number;
|
||||
gtZero: number;
|
||||
};
|
||||
|
||||
type MemorySummary = {
|
||||
id: string;
|
||||
type: LongTermType;
|
||||
source: LongTermSource;
|
||||
text: string;
|
||||
reinforcementCount: number;
|
||||
};
|
||||
|
||||
type WorkspaceSummary = {
|
||||
dirName: string;
|
||||
dirPath: string;
|
||||
key: string;
|
||||
root: string;
|
||||
};
|
||||
|
||||
type CollisionGroup = {
|
||||
kind: "exact" | "identity";
|
||||
workspace: WorkspaceSummary;
|
||||
key: string;
|
||||
classification: "unclassified";
|
||||
memories: MemorySummary[];
|
||||
};
|
||||
|
||||
type ValidationReport = {
|
||||
summary: {
|
||||
workspaces: number;
|
||||
activeMemories: number;
|
||||
exactCollisionGroups: number;
|
||||
identityCollisionGroups: number;
|
||||
reinforcementCountStatistics: ReinforcementCountStatistics;
|
||||
};
|
||||
groups: CollisionGroup[];
|
||||
};
|
||||
|
||||
type ScannedMemory = {
|
||||
entry: LongTermMemoryEntry;
|
||||
exactKey: string;
|
||||
identityKey: string;
|
||||
reinforcementCount: number;
|
||||
};
|
||||
|
||||
const DEFAULT_WORKSPACES_DIR = join(homedir(), ".local", "share", "opencode-working-memory", "workspaces");
|
||||
const WORKSPACES_DIR_ENV_KEYS = [
|
||||
"OPENCODE_WORKING_MEMORY_WORKSPACES_DIR",
|
||||
"WORKSPACE_MEMORY_WORKSPACES_DIR",
|
||||
];
|
||||
|
||||
function usage(): string {
|
||||
return `Usage:
|
||||
node --experimental-strip-types scripts/dev/validate-identity-keys.ts [workspaces-dir] [--json <path>]
|
||||
node --experimental-strip-types scripts/dev/validate-identity-keys.ts --data-path <workspaces-dir> [--json <path>]
|
||||
|
||||
Defaults:
|
||||
workspaces-dir defaults to ${DEFAULT_WORKSPACES_DIR}
|
||||
env override: ${WORKSPACES_DIR_ENV_KEYS.join(" or ")}
|
||||
`;
|
||||
}
|
||||
|
||||
function die(message: string): never {
|
||||
console.error(message);
|
||||
console.error(usage());
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function envWorkspacesDir(): string | undefined {
|
||||
for (const key of WORKSPACES_DIR_ENV_KEYS) {
|
||||
const value = process.env[key];
|
||||
if (value) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function expandHome(path: string): string {
|
||||
if (path === "~") return homedir();
|
||||
if (path.startsWith("~/")) return join(homedir(), path.slice(2));
|
||||
return path;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliOptions {
|
||||
let workspacesDir: string | undefined;
|
||||
let jsonPath: string | undefined;
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
console.log(usage());
|
||||
process.exit(0);
|
||||
}
|
||||
if (arg === "--json") {
|
||||
const value = argv[++i];
|
||||
if (!value) die("--json requires a path");
|
||||
jsonPath = expandHome(value);
|
||||
continue;
|
||||
}
|
||||
if (arg === "--data-path" || arg === "--workspaces-dir") {
|
||||
const value = argv[++i];
|
||||
if (!value) die(`${arg} requires a path`);
|
||||
if (workspacesDir) die("Only one workspaces directory may be provided");
|
||||
workspacesDir = expandHome(value);
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("--")) die(`Unknown option: ${arg}`);
|
||||
if (workspacesDir) die("Only one workspaces directory may be provided");
|
||||
workspacesDir = expandHome(arg);
|
||||
}
|
||||
|
||||
return {
|
||||
workspacesDir: resolve(workspacesDir ?? expandHome(envWorkspacesDir() ?? DEFAULT_WORKSPACES_DIR)),
|
||||
jsonPath: jsonPath ? resolve(jsonPath) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function isLongTermType(value: unknown): value is LongTermType {
|
||||
return value === "feedback" || value === "project" || value === "decision" || value === "reference";
|
||||
}
|
||||
|
||||
function isLongTermSource(value: unknown): value is LongTermSource {
|
||||
return value === "explicit" || value === "compaction" || value === "manual";
|
||||
}
|
||||
|
||||
function isMemoryEntry(value: unknown): value is LongTermMemoryEntry {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const entry = value as Partial<LongTermMemoryEntry>;
|
||||
return typeof entry.id === "string"
|
||||
&& isLongTermType(entry.type)
|
||||
&& typeof entry.text === "string"
|
||||
&& isLongTermSource(entry.source);
|
||||
}
|
||||
|
||||
function reinforcementCount(entry: LongTermMemoryEntry): number {
|
||||
const count = entry.reinforcementCount ?? 0;
|
||||
return Number.isFinite(count) && count > 0 ? count : 0;
|
||||
}
|
||||
|
||||
function summarizeMemory(memory: ScannedMemory): MemorySummary {
|
||||
return {
|
||||
id: memory.entry.id,
|
||||
type: memory.entry.type,
|
||||
source: memory.entry.source,
|
||||
text: memory.entry.text,
|
||||
reinforcementCount: memory.reinforcementCount,
|
||||
};
|
||||
}
|
||||
|
||||
function addToGroupMap(map: Map<string, ScannedMemory[]>, key: string, memory: ScannedMemory): void {
|
||||
const group = map.get(key);
|
||||
if (group) {
|
||||
group.push(memory);
|
||||
} else {
|
||||
map.set(key, [memory]);
|
||||
}
|
||||
}
|
||||
|
||||
function computeReinforcementStatistics(counts: number[]): ReinforcementCountStatistics {
|
||||
if (counts.length === 0) {
|
||||
return { min: 0, max: 0, mean: 0, gtZero: 0 };
|
||||
}
|
||||
|
||||
const sum = counts.reduce((total, count) => total + count, 0);
|
||||
return {
|
||||
min: Math.min(...counts),
|
||||
max: Math.max(...counts),
|
||||
mean: sum / counts.length,
|
||||
gtZero: counts.filter(count => count > 0).length,
|
||||
};
|
||||
}
|
||||
|
||||
async function readWorkspaceMemory(workspaceDir: string): Promise<unknown | undefined> {
|
||||
const path = join(workspaceDir, "workspace-memory.json");
|
||||
if (!existsSync(path)) return undefined;
|
||||
try {
|
||||
const raw = await readFile(path, "utf8");
|
||||
return JSON.parse(raw);
|
||||
} catch (error) {
|
||||
const code = error && typeof error === "object" && "code" in error ? String(error.code) : "";
|
||||
if (code === "ENOENT" || code === "EISDIR") return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function scanWorkspaces(workspacesDir: string): Promise<ValidationReport> {
|
||||
const dirents = await readdir(workspacesDir, { withFileTypes: true });
|
||||
const workspaceDirs = dirents
|
||||
.filter(dirent => dirent.isDirectory())
|
||||
.map(dirent => dirent.name)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
const collisionGroups: CollisionGroup[] = [];
|
||||
const reinforcementCounts: number[] = [];
|
||||
let workspaces = 0;
|
||||
let activeMemories = 0;
|
||||
|
||||
for (const dirName of workspaceDirs) {
|
||||
const dirPath = join(workspacesDir, dirName);
|
||||
const store = await readWorkspaceMemory(dirPath);
|
||||
if (!store || typeof store !== "object") continue;
|
||||
|
||||
const storeObject = store as {
|
||||
workspace?: { key?: unknown; root?: unknown };
|
||||
entries?: unknown;
|
||||
};
|
||||
const rawEntries = Array.isArray(storeObject.entries) ? storeObject.entries : [];
|
||||
const workspace: WorkspaceSummary = {
|
||||
dirName,
|
||||
dirPath,
|
||||
key: typeof storeObject.workspace?.key === "string" ? storeObject.workspace.key : dirName,
|
||||
root: typeof storeObject.workspace?.root === "string" ? storeObject.workspace.root : "<unknown>",
|
||||
};
|
||||
|
||||
workspaces += 1;
|
||||
|
||||
const exactGroups = new Map<string, ScannedMemory[]>();
|
||||
const identityGroups = new Map<string, ScannedMemory[]>();
|
||||
|
||||
for (const rawEntry of rawEntries) {
|
||||
if (!isMemoryEntry(rawEntry)) continue;
|
||||
if (rawEntry.status === "superseded") continue;
|
||||
|
||||
const scanned: ScannedMemory = {
|
||||
entry: rawEntry,
|
||||
exactKey: workspaceMemoryExactKey(rawEntry),
|
||||
identityKey: workspaceMemoryIdentityKey(rawEntry),
|
||||
reinforcementCount: reinforcementCount(rawEntry),
|
||||
};
|
||||
activeMemories += 1;
|
||||
reinforcementCounts.push(scanned.reinforcementCount);
|
||||
addToGroupMap(exactGroups, scanned.exactKey, scanned);
|
||||
addToGroupMap(identityGroups, scanned.identityKey, scanned);
|
||||
}
|
||||
|
||||
for (const [key, memories] of exactGroups.entries()) {
|
||||
if (memories.length < 2) continue;
|
||||
collisionGroups.push({
|
||||
kind: "exact",
|
||||
workspace,
|
||||
key,
|
||||
classification: "unclassified",
|
||||
memories: memories.map(summarizeMemory),
|
||||
});
|
||||
}
|
||||
|
||||
for (const [key, memories] of identityGroups.entries()) {
|
||||
if (memories.length < 2) continue;
|
||||
collisionGroups.push({
|
||||
kind: "identity",
|
||||
workspace,
|
||||
key,
|
||||
classification: "unclassified",
|
||||
memories: memories.map(summarizeMemory),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const exactCollisionGroups = collisionGroups.filter(group => group.kind === "exact").length;
|
||||
const identityCollisionGroups = collisionGroups.filter(group => group.kind === "identity").length;
|
||||
|
||||
return {
|
||||
summary: {
|
||||
workspaces,
|
||||
activeMemories,
|
||||
exactCollisionGroups,
|
||||
identityCollisionGroups,
|
||||
reinforcementCountStatistics: computeReinforcementStatistics(reinforcementCounts),
|
||||
},
|
||||
groups: collisionGroups,
|
||||
};
|
||||
}
|
||||
|
||||
function oneLine(text: string): string {
|
||||
return text.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function renderHumanReport(report: ValidationReport, workspacesDir: string): string {
|
||||
const stats = report.summary.reinforcementCountStatistics;
|
||||
const lines: string[] = [];
|
||||
lines.push("Workspace memory identity key validation baseline");
|
||||
lines.push(`Data path: ${workspacesDir}`);
|
||||
lines.push("");
|
||||
lines.push("Summary:");
|
||||
lines.push(`- Workspaces scanned: ${report.summary.workspaces}`);
|
||||
lines.push(`- Active memories: ${report.summary.activeMemories}`);
|
||||
lines.push(`- Exact collision groups: ${report.summary.exactCollisionGroups}`);
|
||||
lines.push(`- Identity collision groups: ${report.summary.identityCollisionGroups}`);
|
||||
lines.push(`- Reinforcement counts: min=${stats.min} max=${stats.max} mean=${stats.mean.toFixed(2)} gtZero=${stats.gtZero}`);
|
||||
|
||||
if (report.groups.length === 0) {
|
||||
lines.push("");
|
||||
lines.push("Collision breakdown: none");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
const groupsByWorkspace = new Map<string, CollisionGroup[]>();
|
||||
for (const group of report.groups) {
|
||||
const key = group.workspace.key;
|
||||
const existing = groupsByWorkspace.get(key);
|
||||
if (existing) existing.push(group);
|
||||
else groupsByWorkspace.set(key, [group]);
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push("Collision breakdown:");
|
||||
for (const [workspaceKey, groups] of [...groupsByWorkspace.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
|
||||
const workspace = groups[0].workspace;
|
||||
const exactCount = groups.filter(group => group.kind === "exact").length;
|
||||
const identityCount = groups.filter(group => group.kind === "identity").length;
|
||||
lines.push(`- Workspace ${workspaceKey}`);
|
||||
lines.push(` root: ${workspace.root}`);
|
||||
lines.push(` dir: ${workspace.dirPath}`);
|
||||
lines.push(` exactCollisionGroups=${exactCount} identityCollisionGroups=${identityCount}`);
|
||||
|
||||
for (const group of groups.sort((a, b) => a.kind.localeCompare(b.kind) || a.key.localeCompare(b.key))) {
|
||||
lines.push(` - ${group.kind} key: ${group.key}`);
|
||||
lines.push(` classification: ${group.classification}`);
|
||||
for (const memory of group.memories) {
|
||||
lines.push(` - id=${memory.id} type=${memory.type} source=${memory.source} reinforcementCount=${memory.reinforcementCount}`);
|
||||
lines.push(` text: ${oneLine(memory.text)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function writeJsonReport(path: string, report: ValidationReport): Promise<void> {
|
||||
if (basename(path) === "workspace-memory.json") {
|
||||
die("Refusing to write JSON output to a workspace-memory.json file");
|
||||
}
|
||||
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const report = await scanWorkspaces(options.workspacesDir);
|
||||
console.log(renderHumanReport(report, options.workspacesDir));
|
||||
|
||||
if (options.jsonPath) {
|
||||
await writeJsonReport(options.jsonPath, report);
|
||||
console.log(`\nJSON report written to: ${options.jsonPath}`);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export type EvidenceEventType =
|
||||
| "promotion_retry_scheduled"
|
||||
| "promotion_retry_exhausted"
|
||||
| "memory_reinforced"
|
||||
| "memory_migration_superseded"
|
||||
| "render_selected"
|
||||
| "render_omitted"
|
||||
| "memory_removed_capacity"
|
||||
|
||||
@@ -116,6 +116,9 @@ function buildCompactionPrompt(privateContext: string): string {
|
||||
"",
|
||||
"CRITICAL MEMORY RULES:",
|
||||
"- Most compactions should produce ZERO memories. Empty is correct when nothing durable changed.",
|
||||
"- Existing workspace memory may already contain durable facts. If a fact is already present and still accurate, do not create a rephrased duplicate.",
|
||||
"- If the same durable fact truly needs to be emitted again, reuse the existing memory wording exactly whenever possible.",
|
||||
"- Only emit a new memory when the fact is new, materially corrected, or materially more specific than the existing memory.",
|
||||
"- NO completion or progress statements: do not extract completed work, passing tests, commits, PR status, wave/task/phase completion, or current state.",
|
||||
"- NO session-internal implementation notes: do not extract what files were edited, what bug was just fixed, what command just ran, or what the assistant reviewed.",
|
||||
"- feedback ONLY means stable user preferences or user instructions, written in imperative/future-facing form.",
|
||||
|
||||
+90
-15
@@ -12,6 +12,7 @@ import {
|
||||
reinforceMemory,
|
||||
} from "./retention.ts";
|
||||
import type { EvidenceEventInput, MemoryEvidenceRef } from "./evidence-log.ts";
|
||||
import { appendEvidenceEvents } from "./evidence-log.ts";
|
||||
|
||||
// Minimum length for workspace_memory envelope: <workspace_memory>\n...\n</workspace_memory>
|
||||
const MIN_ENVELOPE_LENGTH = 80;
|
||||
@@ -73,6 +74,17 @@ export type QualityCleanupMigrationLogEntry = {
|
||||
afterStatus: "superseded";
|
||||
};
|
||||
|
||||
export type P0CleanupMigrationResult = {
|
||||
store: WorkspaceMemoryStore;
|
||||
events: EvidenceEventInput[];
|
||||
};
|
||||
|
||||
export type QualityCleanupMigrationResult = {
|
||||
store: WorkspaceMemoryStore;
|
||||
events: QualityCleanupMigrationLogEntry[];
|
||||
evidence: EvidenceEventInput[];
|
||||
};
|
||||
|
||||
export async function emptyWorkspaceMemory(root: string): Promise<WorkspaceMemoryStore> {
|
||||
const nowIso = new Date().toISOString();
|
||||
return {
|
||||
@@ -114,6 +126,14 @@ export async function loadWorkspaceMemory(root: string): Promise<WorkspaceMemory
|
||||
// writes for ordering/capacity/timestamp-only normalization.
|
||||
if (hasSecurityOrMigrationChange(store, normalized.store)) {
|
||||
await atomicWriteJSON(path, normalized.store);
|
||||
|
||||
// loadWorkspaceMemory has a narrow migration side effect: when a first-load
|
||||
// migration actually supersedes entries, append evidence for those storage
|
||||
// changes. Migration IDs keep this idempotent on repeated loads.
|
||||
const migrationEvidence = normalized.evidence.filter(event => event.type === "memory_migration_superseded");
|
||||
if (migrationEvidence.length > 0) {
|
||||
await appendEvidenceEvents(root, migrationEvidence);
|
||||
}
|
||||
}
|
||||
|
||||
return normalized.store;
|
||||
@@ -159,8 +179,15 @@ export async function updateWorkspaceMemoryWithAccounting(
|
||||
const fallback = await emptyWorkspaceMemory(root);
|
||||
let finalResult: WorkspaceMemoryNormalizationResult | undefined;
|
||||
const store = await updateJSON(path, () => fallback, async current => {
|
||||
const normalized = await normalizeWorkspaceMemory(root, current);
|
||||
finalResult = await normalizeWorkspaceMemoryWithAccounting(root, await updater(normalized));
|
||||
const currentNormalization = await normalizeWorkspaceMemoryWithAccounting(root, current);
|
||||
const currentMigrationEvidence = currentNormalization.evidence.filter(event => event.type === "memory_migration_superseded");
|
||||
finalResult = await normalizeWorkspaceMemoryWithAccounting(root, await updater(currentNormalization.store));
|
||||
if (currentMigrationEvidence.length > 0) {
|
||||
finalResult = {
|
||||
...finalResult,
|
||||
evidence: [...currentMigrationEvidence, ...finalResult.evidence],
|
||||
};
|
||||
}
|
||||
return finalResult.store;
|
||||
});
|
||||
|
||||
@@ -224,10 +251,12 @@ export async function normalizeWorkspaceMemoryWithAccounting(
|
||||
const beforeQualityCleanup = result;
|
||||
const qualityCleanup = runMigrationQualityCleanup(result, nowIso);
|
||||
result = qualityCleanup.store;
|
||||
let migrationEvidence: EvidenceEventInput[] = [];
|
||||
let skipRemainingMigrations = false;
|
||||
if (qualityCleanup.events.length > 0) {
|
||||
try {
|
||||
await appendQualityCleanupMigrationLog(qualityCleanup.events);
|
||||
migrationEvidence = [...migrationEvidence, ...qualityCleanup.evidence];
|
||||
} catch (error) {
|
||||
console.error("[memory] failed to write quality cleanup migration log:", error);
|
||||
console.error("[memory] aborting migration to maintain audit trail integrity");
|
||||
@@ -236,7 +265,9 @@ export async function normalizeWorkspaceMemoryWithAccounting(
|
||||
}
|
||||
}
|
||||
if (!skipRemainingMigrations) {
|
||||
result = runMigrationP0Cleanup(result, nowIso);
|
||||
const p0Cleanup = runMigrationP0Cleanup(result, nowIso);
|
||||
result = p0Cleanup.store;
|
||||
migrationEvidence = [...migrationEvidence, ...p0Cleanup.events];
|
||||
}
|
||||
|
||||
result.entries = result.entries.map(entry => backfillRetentionClock(entry, nowMs));
|
||||
@@ -269,7 +300,7 @@ export async function normalizeWorkspaceMemoryWithAccounting(
|
||||
dropped: accounting.dropped,
|
||||
absorbed: accounting.absorbed,
|
||||
superseded: accounting.superseded,
|
||||
evidence: accounting.evidence,
|
||||
evidence: [...migrationEvidence, ...accounting.evidence],
|
||||
events: [...accounting.dropped, ...accounting.absorbed, ...accounting.superseded],
|
||||
};
|
||||
}
|
||||
@@ -295,31 +326,38 @@ function backfillRetentionClock(entry: LongTermMemoryEntry, nowMs: number): Long
|
||||
export function runMigrationP0Cleanup(
|
||||
store: WorkspaceMemoryStore,
|
||||
nowIso: string,
|
||||
): WorkspaceMemoryStore {
|
||||
): P0CleanupMigrationResult {
|
||||
if (store.migrations?.includes(MIGRATION_ID)) {
|
||||
return store;
|
||||
return { store, events: [] };
|
||||
}
|
||||
|
||||
const events: EvidenceEventInput[] = [];
|
||||
const entries = store.entries.map(entry => {
|
||||
if (entry.source !== "compaction") return entry;
|
||||
if (entry.type !== "project") return entry;
|
||||
if (entry.status === "superseded") return entry;
|
||||
|
||||
if (isProgressSnapshotViolation(entry.text)) {
|
||||
return {
|
||||
const superseded = {
|
||||
...entry,
|
||||
status: "superseded" as const,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
events.push(migrationSupersededEvidence(superseded, ["migration:p0_cleanup"], MIGRATION_ID));
|
||||
return superseded;
|
||||
}
|
||||
|
||||
return entry;
|
||||
});
|
||||
|
||||
return {
|
||||
...store,
|
||||
entries,
|
||||
migrations: [...(store.migrations || []), MIGRATION_ID],
|
||||
updatedAt: nowIso,
|
||||
store: {
|
||||
...store,
|
||||
entries,
|
||||
migrations: [...(store.migrations || []), MIGRATION_ID],
|
||||
updatedAt: nowIso,
|
||||
},
|
||||
events,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -333,12 +371,13 @@ async function appendQualityCleanupMigrationLog(events: QualityCleanupMigrationL
|
||||
export function runMigrationQualityCleanup(
|
||||
store: WorkspaceMemoryStore,
|
||||
nowIso: string,
|
||||
): { store: WorkspaceMemoryStore; events: QualityCleanupMigrationLogEntry[] } {
|
||||
): QualityCleanupMigrationResult {
|
||||
if (store.migrations?.includes(QUALITY_CLEANUP_MIGRATION_ID)) {
|
||||
return { store, events: [] };
|
||||
return { store, events: [], evidence: [] };
|
||||
}
|
||||
|
||||
const events: QualityCleanupMigrationLogEntry[] = [];
|
||||
const evidence: EvidenceEventInput[] = [];
|
||||
let changed = false;
|
||||
const entries = store.entries.map(entry => {
|
||||
if (entry.source !== "compaction") return entry;
|
||||
@@ -372,12 +411,19 @@ export function runMigrationQualityCleanup(
|
||||
...hardReasons.map(reason => `quality:${reason}`),
|
||||
]);
|
||||
|
||||
return {
|
||||
const superseded = {
|
||||
...entry,
|
||||
status: "superseded" as const,
|
||||
updatedAt: nowIso,
|
||||
tags: [...tags],
|
||||
};
|
||||
evidence.push(migrationSupersededEvidence(
|
||||
superseded,
|
||||
["migration:quality_cleanup", ...hardReasons.map(reason => `quality:${reason}`)],
|
||||
QUALITY_CLEANUP_MIGRATION_ID,
|
||||
{ hardReasons },
|
||||
));
|
||||
return superseded;
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -388,6 +434,7 @@ export function runMigrationQualityCleanup(
|
||||
updatedAt: changed ? nowIso : store.updatedAt,
|
||||
},
|
||||
events,
|
||||
evidence,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -525,6 +572,29 @@ function capacityRemovalEvidence(
|
||||
};
|
||||
}
|
||||
|
||||
function migrationSupersededEvidence(
|
||||
memory: LongTermMemoryEntry,
|
||||
reasonCodes: string[],
|
||||
migrationId: string,
|
||||
details: EvidenceEventInput["details"] = {},
|
||||
): EvidenceEventInput {
|
||||
return {
|
||||
type: "memory_migration_superseded",
|
||||
phase: "storage",
|
||||
outcome: "superseded",
|
||||
memory: memoryEvidenceRef(memory),
|
||||
relations: [{ role: "superseded", memory: memoryEvidenceRef(memory) }],
|
||||
reasonCodes,
|
||||
details: {
|
||||
migrationId,
|
||||
type: memory.type,
|
||||
source: memory.source,
|
||||
...details,
|
||||
},
|
||||
textPreview: memory.text,
|
||||
};
|
||||
}
|
||||
|
||||
/** Choose better memory when identity/topic keys conflict */
|
||||
function chooseBetterMemory(
|
||||
a: LongTermMemoryEntry,
|
||||
@@ -631,6 +701,11 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
|
||||
const evidence: EvidenceEventInput[] = [];
|
||||
|
||||
// For project/reference/feedback: dedupe by concrete identity or exact canonical text.
|
||||
// Feedback is grouped with project/reference for entity dedupe, but
|
||||
// workspaceMemoryIdentityKey() returns exact key for feedback (no concrete
|
||||
// identity extraction). This means feedback absorbed_identity is currently
|
||||
// impossible. When identity key extraction is extended to all types, feedback
|
||||
// with matching concrete identifiers will correctly produce absorbed_identity.
|
||||
const projectRefEntries = entries.filter(e => e.type === "project" || e.type === "reference" || e.type === "feedback");
|
||||
|
||||
// Build identity key dedup for project/reference/feedback.
|
||||
@@ -674,7 +749,7 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
|
||||
const dropped = retained === entry ? existing : entry;
|
||||
const reason = workspaceMemoryExactKey(entry) === workspaceMemoryExactKey(existing)
|
||||
? "absorbed_exact" as const
|
||||
: "superseded_existing" as const;
|
||||
: "superseded_existing" as const; // v1.5.4 placeholder: unreachable until numbered refs
|
||||
const reinforced = reinforceMemory(
|
||||
retained,
|
||||
reinforcementSessionId(retained, dropped),
|
||||
|
||||
@@ -174,6 +174,41 @@ test("memory_removed_capacity event round-trips through append and query", async
|
||||
}
|
||||
});
|
||||
|
||||
test("memory_migration_superseded event round-trips through append and query", async () => {
|
||||
const root = await tempRoot();
|
||||
try {
|
||||
await appendEvidenceEvent(root, eventInput({
|
||||
type: "memory_migration_superseded",
|
||||
phase: "storage",
|
||||
outcome: "superseded",
|
||||
reasonCodes: ["migration:quality_cleanup", "quality:progress_snapshot"],
|
||||
memory: { memoryId: "migrated-memory", type: "project", source: "compaction", status: "superseded" },
|
||||
relations: [{ role: "superseded", memory: { memoryId: "migrated-memory", type: "project", source: "compaction", status: "superseded" } }],
|
||||
details: {
|
||||
migrationId: "2026-04-28-quality-cleanup",
|
||||
type: "project",
|
||||
source: "compaction",
|
||||
},
|
||||
}));
|
||||
|
||||
const result = await queryEvidenceEvents(root, {
|
||||
types: ["memory_migration_superseded"],
|
||||
phases: ["storage"],
|
||||
outcomes: ["superseded"],
|
||||
memoryId: "migrated-memory",
|
||||
});
|
||||
|
||||
assert.equal(result.length, 1);
|
||||
assert.equal(result[0].type, "memory_migration_superseded");
|
||||
assert.equal(result[0].phase, "storage");
|
||||
assert.equal(result[0].outcome, "superseded");
|
||||
assert.ok(result[0].reasonCodes.includes("migration:quality_cleanup"));
|
||||
assert.equal(result[0].memory?.status, "superseded");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("queryEvidenceEvents supports newestFirst and limit", async () => {
|
||||
const root = await tempRoot();
|
||||
try {
|
||||
|
||||
@@ -339,6 +339,69 @@ test("compaction prompt forbids progress and session-internal memory candidates"
|
||||
}
|
||||
});
|
||||
|
||||
test("compaction prompt includes existing-memory wording reuse guidance", async () => {
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-prompt-"));
|
||||
try {
|
||||
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
|
||||
const output = { prompt: "", context: [] as string[] };
|
||||
|
||||
await (plugin as Record<string, Function>)["experimental.session.compacting"](
|
||||
{ sessionID: "prompt-wording-reuse-session", model: {} },
|
||||
output,
|
||||
);
|
||||
|
||||
assert.match(output.prompt, /Existing workspace memory may already contain durable facts/);
|
||||
assert.match(output.prompt, /do not create a rephrased duplicate/);
|
||||
assert.match(output.prompt, /reuse the existing memory wording exactly whenever possible/);
|
||||
assert.match(output.prompt, /new, materially corrected, or materially more specific/);
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("compaction prompt does not introduce CRUD memory directives", async () => {
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-prompt-"));
|
||||
try {
|
||||
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
|
||||
const output = { prompt: "", context: [] as string[] };
|
||||
|
||||
await (plugin as Record<string, Function>)["experimental.session.compacting"](
|
||||
{ sessionID: "prompt-no-crud-session", model: {} },
|
||||
output,
|
||||
);
|
||||
|
||||
assert.doesNotMatch(output.prompt, /\bREPLACE\b/);
|
||||
assert.doesNotMatch(output.prompt, /\bDROP\b/);
|
||||
assert.doesNotMatch(output.prompt, /\bREINFORCE\s+\[M/);
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("compaction prompt preserves Memory candidates output format", async () => {
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-prompt-"));
|
||||
try {
|
||||
const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() });
|
||||
const output = { prompt: "", context: [] as string[] };
|
||||
|
||||
await (plugin as Record<string, Function>)["experimental.session.compacting"](
|
||||
{ sessionID: "prompt-format-session", model: {} },
|
||||
output,
|
||||
);
|
||||
|
||||
assert.match(
|
||||
output.prompt,
|
||||
/Format when there ARE durable memories:\nMemory candidates:\n- \[feedback\|decision\|project\|reference\] future-facing durable fact/,
|
||||
);
|
||||
assert.match(
|
||||
output.prompt,
|
||||
/Format when there are NO durable memories:\nMemory candidates:\n\(none\)/,
|
||||
);
|
||||
} finally {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("compaction hook merges existing output.context from other plugins", async () => {
|
||||
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { tmpdir } from "node:os";
|
||||
import type { LongTermMemoryEntry, WorkspaceMemoryStore } from "../src/types.ts";
|
||||
import { HOT_STATE_LIMITS, LONG_TERM_LIMITS } from "../src/types.ts";
|
||||
import { workspaceKey, workspaceMemoryPath } from "../src/paths.ts";
|
||||
import { queryEvidenceEvents } from "../src/evidence-log.ts";
|
||||
import {
|
||||
renderWorkspaceMemory,
|
||||
accountWorkspaceMemoryRender,
|
||||
@@ -562,6 +563,23 @@ test("dedupeLongTermEntriesWithAccounting reinforces absorbed exact duplicates",
|
||||
));
|
||||
});
|
||||
|
||||
test("dedupeLongTermEntriesWithAccounting decision same-identity variants absorb exact only", () => {
|
||||
const retained = entry("decision-a", "Use pnpm for package management!!!", "decision");
|
||||
const duplicate = entry("decision-b", "use pnpm for package management", "decision");
|
||||
|
||||
assert.notEqual(retained.text, duplicate.text);
|
||||
assert.equal(workspaceMemoryIdentityKey(retained), workspaceMemoryIdentityKey(duplicate));
|
||||
assert.equal(workspaceMemoryExactKey(retained), workspaceMemoryExactKey(duplicate));
|
||||
|
||||
const result = dedupeLongTermEntriesWithAccounting([retained, duplicate]);
|
||||
|
||||
assert.equal(result.kept.length, 1);
|
||||
assert.equal(result.absorbed.length, 1);
|
||||
assert.equal(result.absorbed[0].reason, "absorbed_exact");
|
||||
assert.equal(result.superseded.length, 0);
|
||||
assert.equal(result.absorbed.some(event => event.reason === "superseded_existing"), false);
|
||||
});
|
||||
|
||||
test("dedupeLongTermEntriesWithAccounting emits identity reinforcement evidence", () => {
|
||||
const now = Date.now();
|
||||
const retained: LongTermMemoryEntry = {
|
||||
@@ -999,6 +1017,16 @@ test("workspaceMemoryExactKey uses pending-compatible canonical semantics", () =
|
||||
assert.equal(workspaceMemoryExactKey(entry), "decision:opencode uses npm cache for plugin loading");
|
||||
});
|
||||
|
||||
test("workspaceMemoryIdentityKey returns exact key for feedback entries", () => {
|
||||
const feedback = entry(
|
||||
"feedback-exact-identity",
|
||||
"User prefers references to mention `.opencode/opencode.json` explicitly.",
|
||||
"feedback",
|
||||
);
|
||||
|
||||
assert.equal(workspaceMemoryIdentityKey(feedback), workspaceMemoryExactKey(feedback));
|
||||
});
|
||||
|
||||
test("normalizeWorkspaceMemoryWithAccounting redacts credentials before accounting", async () => {
|
||||
const root = "/repo";
|
||||
const now = new Date().toISOString();
|
||||
@@ -1252,6 +1280,50 @@ test("updateWorkspaceMemoryWithAccounting emits accounting events for persisted
|
||||
}
|
||||
});
|
||||
|
||||
test("updateWorkspaceMemoryWithAccounting includes migration evidence from pre-update normalization", async () => {
|
||||
const sandbox = await mkdtemp(join(tmpdir(), "wm-accounting-migration-update-"));
|
||||
const dataHome = join(sandbox, "xdg-data-home");
|
||||
const root = join(sandbox, "workspace");
|
||||
const previousXdgDataHome = process.env.XDG_DATA_HOME;
|
||||
process.env.XDG_DATA_HOME = dataHome;
|
||||
|
||||
try {
|
||||
await mkdir(root, { recursive: true });
|
||||
const now = "2026-04-26T00:00:00.000Z";
|
||||
const storePath = await workspaceMemoryPath(root);
|
||||
await mkdir(dirname(storePath), { recursive: true });
|
||||
await writeFile(storePath, JSON.stringify({
|
||||
version: 1,
|
||||
workspace: { root, key: await workspaceKey(root) },
|
||||
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
|
||||
entries: [{
|
||||
id: "update_p0_progress",
|
||||
type: "project",
|
||||
text: "Phase 1-4 completed successfully",
|
||||
source: "compaction",
|
||||
confidence: 0.75,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}],
|
||||
migrations: ["2026-04-28-quality-cleanup"],
|
||||
updatedAt: now,
|
||||
}, null, 2), "utf8");
|
||||
|
||||
const result = await updateWorkspaceMemoryWithAccounting(root, store => store);
|
||||
const migrationEvidence = result.evidence.filter(event => event.type === "memory_migration_superseded");
|
||||
|
||||
assert.equal(result.store.entries.find(memory => memory.id === "update_p0_progress")?.status, "superseded");
|
||||
assert.equal(migrationEvidence.length, 1);
|
||||
assert.deepEqual(migrationEvidence[0].reasonCodes, ["migration:p0_cleanup"]);
|
||||
assert.equal(migrationEvidence[0].memory?.memoryId, "update_p0_progress");
|
||||
} finally {
|
||||
if (previousXdgDataHome === undefined) delete process.env.XDG_DATA_HOME;
|
||||
else process.env.XDG_DATA_HOME = previousXdgDataHome;
|
||||
await rm(sandbox, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// P0d: identity-key dedup, supersession, staleness
|
||||
// ============================================
|
||||
@@ -1604,7 +1676,7 @@ test("redactCredentials is idempotent and also redacts rationale text", () => {
|
||||
})),
|
||||
},
|
||||
now,
|
||||
);
|
||||
).store;
|
||||
assert.equal(migrated.entries[0].text, "Admin PIN 是 [REDACTED]");
|
||||
assert.equal(migrated.entries[0].rationale, "password: [REDACTED]");
|
||||
});
|
||||
@@ -1663,14 +1735,69 @@ test("runMigrationP0Cleanup marks only non-explicit project snapshots and runs o
|
||||
};
|
||||
|
||||
const once = runMigrationP0Cleanup(store, now);
|
||||
assert.deepEqual(once.migrations, ["2026-04-26-p0-cleanup"]);
|
||||
assert.equal(once.entries.find(e => e.id === "project-snapshot")?.status, "superseded");
|
||||
assert.equal(once.entries.find(e => e.id === "project-explicit")?.status, "active");
|
||||
assert.equal(once.entries.find(e => e.id === "feedback-snapshot-like")?.status, "active");
|
||||
assert.deepEqual(once.store.migrations, ["2026-04-26-p0-cleanup"]);
|
||||
assert.equal(once.store.entries.find(e => e.id === "project-snapshot")?.status, "superseded");
|
||||
assert.equal(once.store.entries.find(e => e.id === "project-explicit")?.status, "active");
|
||||
assert.equal(once.store.entries.find(e => e.id === "feedback-snapshot-like")?.status, "active");
|
||||
assert.equal(once.events.length, 1);
|
||||
assert.equal(once.events[0].type, "memory_migration_superseded");
|
||||
assert.equal(once.events[0].phase, "storage");
|
||||
assert.equal(once.events[0].outcome, "superseded");
|
||||
assert.ok(once.events[0].reasonCodes.includes("migration:p0_cleanup"));
|
||||
assert.equal(once.events[0].memory?.memoryId, "project-snapshot");
|
||||
|
||||
const twice = runMigrationP0Cleanup(once, later);
|
||||
assert.deepEqual(twice.migrations, ["2026-04-26-p0-cleanup"], "migration id should not duplicate");
|
||||
assert.equal(twice.entries.find(e => e.id === "project-snapshot")?.updatedAt, once.entries.find(e => e.id === "project-snapshot")?.updatedAt);
|
||||
const twice = runMigrationP0Cleanup(once.store, later);
|
||||
assert.deepEqual(twice.store.migrations, ["2026-04-26-p0-cleanup"], "migration id should not duplicate");
|
||||
assert.equal(twice.store.entries.find(e => e.id === "project-snapshot")?.updatedAt, once.store.entries.find(e => e.id === "project-snapshot")?.updatedAt);
|
||||
assert.equal(twice.events.length, 0);
|
||||
});
|
||||
|
||||
test("loadWorkspaceMemory appends P0 migration evidence once", async () => {
|
||||
const sandbox = await mkdtemp(join(tmpdir(), "wm-p0-evidence-"));
|
||||
const dataHome = join(sandbox, "xdg-data-home");
|
||||
const root = join(sandbox, "workspace");
|
||||
const previousXdgDataHome = process.env.XDG_DATA_HOME;
|
||||
process.env.XDG_DATA_HOME = dataHome;
|
||||
|
||||
try {
|
||||
await mkdir(root, { recursive: true });
|
||||
const now = "2026-04-26T00:00:00.000Z";
|
||||
const storePath = await workspaceMemoryPath(root);
|
||||
await mkdir(dirname(storePath), { recursive: true });
|
||||
await writeFile(storePath, JSON.stringify({
|
||||
version: 1,
|
||||
workspace: { root, key: await workspaceKey(root) },
|
||||
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
|
||||
entries: [{
|
||||
id: "p0_progress",
|
||||
type: "project",
|
||||
text: "Phase 1-4 completed successfully",
|
||||
source: "compaction",
|
||||
confidence: 0.75,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}],
|
||||
migrations: ["2026-04-28-quality-cleanup"],
|
||||
updatedAt: now,
|
||||
}, null, 2), "utf8");
|
||||
|
||||
const firstLoad = await loadWorkspaceMemory(root);
|
||||
await loadWorkspaceMemory(root);
|
||||
const events = await queryEvidenceEvents(root, { types: ["memory_migration_superseded"] });
|
||||
|
||||
assert.equal(firstLoad.entries.find(memory => memory.id === "p0_progress")?.status, "superseded");
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0].type, "memory_migration_superseded");
|
||||
assert.equal(events[0].phase, "storage");
|
||||
assert.equal(events[0].outcome, "superseded");
|
||||
assert.deepEqual(events[0].reasonCodes, ["migration:p0_cleanup"]);
|
||||
assert.equal(events[0].memory?.memoryId, "p0_progress");
|
||||
} finally {
|
||||
if (previousXdgDataHome === undefined) delete process.env.XDG_DATA_HOME;
|
||||
else process.env.XDG_DATA_HOME = previousXdgDataHome;
|
||||
await rm(sandbox, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("quality cleanup migration preserves soft-only feedback and decision violations", async () => {
|
||||
@@ -1797,6 +1924,56 @@ test("quality cleanup migration writes audit log for hard supersedes", async ()
|
||||
}
|
||||
});
|
||||
|
||||
test("quality cleanup migration appends superseded evidence with hard reasons", async () => {
|
||||
const sandbox = await mkdtemp(join(tmpdir(), "wm-quality-evidence-"));
|
||||
const dataHome = join(sandbox, "xdg-data-home");
|
||||
const root = join(sandbox, "workspace");
|
||||
const previousXdgDataHome = process.env.XDG_DATA_HOME;
|
||||
process.env.XDG_DATA_HOME = dataHome;
|
||||
|
||||
try {
|
||||
await mkdir(root, { recursive: true });
|
||||
const now = "2026-04-28T00:00:00.000Z";
|
||||
const storePath = await workspaceMemoryPath(root);
|
||||
await mkdir(dirname(storePath), { recursive: true });
|
||||
await writeFile(storePath, JSON.stringify({
|
||||
version: 1,
|
||||
workspace: { root, key: await workspaceKey(root) },
|
||||
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
|
||||
entries: [{
|
||||
id: "quality_progress",
|
||||
type: "project",
|
||||
text: "Test suite: 1237 tests pass, 226 suites",
|
||||
source: "compaction",
|
||||
confidence: 0.75,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
staleAfterDays: 60,
|
||||
}],
|
||||
migrations: [],
|
||||
updatedAt: now,
|
||||
}, null, 2), "utf8");
|
||||
|
||||
const loaded = await loadWorkspaceMemory(root);
|
||||
const events = await queryEvidenceEvents(root, { types: ["memory_migration_superseded"] });
|
||||
|
||||
assert.equal(loaded.entries.find(memory => memory.id === "quality_progress")?.status, "superseded");
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0].type, "memory_migration_superseded");
|
||||
assert.equal(events[0].phase, "storage");
|
||||
assert.equal(events[0].outcome, "superseded");
|
||||
assert.ok(events[0].reasonCodes.includes("migration:quality_cleanup"));
|
||||
assert.ok(events[0].reasonCodes.includes("quality:progress_snapshot"));
|
||||
assert.equal(events[0].memory?.memoryId, "quality_progress");
|
||||
assert.equal(events[0].memory?.status, "superseded");
|
||||
} finally {
|
||||
if (previousXdgDataHome === undefined) delete process.env.XDG_DATA_HOME;
|
||||
else process.env.XDG_DATA_HOME = previousXdgDataHome;
|
||||
await rm(sandbox, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("quality cleanup migration aborts supersede when audit log cannot be written", async () => {
|
||||
const sandbox = await mkdtemp(join(tmpdir(), "wm-quality-audit-fail-"));
|
||||
const dataHome = join(sandbox, "xdg-data-home");
|
||||
|
||||
Reference in New Issue
Block a user